build: remove versioningit build-req from sdist

- Replace `tool.versioningit.onbuild` hook with a custom implementation
  which replaces the entire `streamlink._version` module (similar to
  before) and which additionally removes `versioningit` from the
  `build-system.requires` field in `pyproject.toml` and which sets
  a static version string in `setup.py`
- Rewrite `streamlink._version` module
- Add and update comments
- Update docs
- Add tests
This commit is contained in:
bastimeyer 2023-10-22 17:17:55 +02:00 committed by Sebastian Meyer
parent 80a76451f3
commit 13762836c2
6 changed files with 181 additions and 12 deletions

81
build_backend/onbuild.py Normal file
View File

@ -0,0 +1,81 @@
import re
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Dict, Generator, Generic, TypeVar, Union
# noinspection PyUnusedLocal
def onbuild(
build_dir: Union[str, Path],
is_source: bool,
template_fields: Dict[str, Any],
params: Dict[str, Any],
):
"""
Remove the ``versioningit`` build-requirement from Streamlink's source distribution.
Also set the static version string in the :mod:`streamlink._version` module when building the sdist/bdist.
The version string already gets set by ``versioningit`` when building, so the sdist doesn't need to have
``versioningit`` added as a build-requirement. Previously, the generated version string was only applied
to the :mod:`streamlink._version` module while ``versioningit`` was still set as a build-requirement.
This custom onbuild hook gets called via the ``tool.versioningit.onbuild`` config in ``pyproject.toml``,
since ``versioningit`` does only support modifying one file via its default onbuild hook configuration.
"""
base_dir: Path = Path(build_dir).resolve()
pkg_dir: Path = base_dir / "src" if is_source else base_dir
version: str = template_fields["version"]
cmproxy: Proxy[str]
# Remove versioningit from ``build-system.requires`` in ``pyproject.toml``
if is_source:
with update_file(base_dir / "pyproject.toml") as cmproxy:
cmproxy.set(re.sub(
r"^(\s*)(\"versioningit\b.+?\",).*$",
"\\1# \\2",
cmproxy.get(),
flags=re.MULTILINE,
count=1,
))
# Set the static version string that gets passed directly to setuptools via ``setup.py``.
# This is much easier compared to adding the ``project.version`` field and removing "version" from ``project.dynamic``
# in ``pyproject.toml``.
if is_source:
with update_file(base_dir / "setup.py") as cmproxy:
cmproxy.set(re.sub(
r"^(\s*)# (version=\"\",).*$",
f"\\1version=\"{version}\",",
cmproxy.get(),
flags=re.MULTILINE,
count=1,
))
# Overwrite the entire ``streamlink._version`` module
with update_file(pkg_dir / "streamlink" / "_version.py") as cmproxy:
cmproxy.set(f"__version__ = \"{version}\"\n")
TProxyItem = TypeVar("TProxyItem")
class Proxy(Generic[TProxyItem]):
def __init__(self, data: TProxyItem):
self._data = data
def get(self) -> TProxyItem:
return self._data
def set(self, data: TProxyItem) -> None:
self._data = data
@contextmanager
def update_file(file: Path) -> Generator[Proxy[str], None, None]:
with file.open("r+", encoding="utf-8") as fh:
proxy = Proxy(fh.read())
yield proxy
fh.seek(0)
fh.write(proxy.get())
fh.truncate()

View File

@ -0,0 +1,66 @@
import re
import shutil
from pathlib import Path
import pytest
from build_backend.onbuild import onbuild
PROJECT_ROOT = Path(__file__).parents[1]
@pytest.fixture()
def template_fields(request: pytest.FixtureRequest) -> dict:
template_fields = {
"version": "1.2.3+fake",
}
template_fields.update(getattr(request, "param", {}))
return template_fields
@pytest.fixture(autouse=True)
def build(request: pytest.FixtureRequest, tmp_path: Path, template_fields: dict) -> Path:
param = getattr(request, "param", {})
is_source = param.get("is_source", True)
pkg_dir = tmp_path / "src" if is_source else tmp_path
(pkg_dir / "streamlink").mkdir(parents=True)
shutil.copy(PROJECT_ROOT / "pyproject.toml", tmp_path / "pyproject.toml")
shutil.copy(PROJECT_ROOT / "setup.py", tmp_path / "setup.py")
shutil.copy(PROJECT_ROOT / "src" / "streamlink" / "_version.py", pkg_dir / "streamlink" / "_version.py")
onbuild(tmp_path, is_source, template_fields, {})
return tmp_path
@pytest.mark.parametrize("build", [pytest.param({"is_source": True}, id="is_source=True")], indirect=True)
def test_sdist(build: Path):
assert re.search(
r"^(\s*)# (\"versioningit\b.+?\",).*$",
(build / "pyproject.toml").read_text(encoding="utf-8"),
re.MULTILINE,
), "versioningit is not a build-requirement"
assert re.search(
r"^(\s*)(version=\"1\.2\.3\+fake\",).*$",
(build / "setup.py").read_text(encoding="utf-8"),
re.MULTILINE,
), "setup() call defines a static version string"
assert (build / "src" / "streamlink" / "_version.py").read_text(encoding="utf-8") \
== "__version__ = \"1.2.3+fake\"\n", \
"streamlink._version exports a static version string"
@pytest.mark.parametrize("build", [pytest.param({"is_source": False}, id="is_source=False")], indirect=True)
def test_bdist(build: Path):
assert (build / "pyproject.toml").read_text(encoding="utf-8") \
== (PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8"), \
"Doesn't touch pyproject.toml (irrelevant for non-sdist)"
assert (build / "setup.py").read_text(encoding="utf-8") \
== (PROJECT_ROOT / "setup.py").read_text(encoding="utf-8"), \
"Doesn't touch setup.py (irrelevant for non-sdist)"
assert (build / "streamlink" / "_version.py").read_text(encoding="utf-8") \
== "__version__ = \"1.2.3+fake\"\n", \
"streamlink._version exports a static version string"

View File

@ -401,6 +401,14 @@ To install Streamlink from source you will need these dependencies.
Since :ref:`4.0.0 <changelog:streamlink 4.0.0 (2022-05-01)>`,
Streamlink defines a `build system <pyproject.toml_>`__ according to `PEP-517`_ / `PEP-518`_.
.. warning::
Do not build Streamlink from tarballs generated by GitHub from (tagged) git commits,
as they are lacking the release version string.
Instead, install from Streamlink's signed source-distribution tarballs which are uploaded to PyPI and GitHub releases,
or from the cloned git repository.
.. list-table::
:header-rows: 1
:class: table-custom-layout table-custom-layout-dependencies
@ -421,7 +429,8 @@ Streamlink defines a `build system <pyproject.toml_>`__ according to `PEP-517`_
* - build
- `versioningit`_
- At least version **2.0.0** |br|
Used for generating the version string from git when building, or when running in an editable install
Used for generating the version string from git when building, or when running in an editable install.
Not needed when building wheels and installing from the source distribution.
* - runtime
- `certifi`_
- Used for loading the CA bundle extracted from the Mozilla Included CA Certificate List

View File

@ -2,7 +2,9 @@
requires = [
"setuptools >=64",
"wheel",
"versioningit >=2.0.0, <3",
# The versioningit build-requirement gets removed from the source distribution,
# as the version string is already built into it (see the onbuild versioningit hook):
"versioningit >=2.0.0, <3", # disabled in sdist
]
# setuptools build-backend override
# https://setuptools.pypa.io/en/stable/build_meta.html
@ -93,6 +95,10 @@ streamlink = [
# https://versioningit.readthedocs.io/en/stable/index.html
[tool.versioningit]
# Packagers: don't patch the `default-version` string while using the tarball built by GitHub from the tagged git commit!
# Instead, use Streamlink's signed source distribution as package source, which has the correct version string built in.
# This fallback `default-version` string is only used when not building from the sdist or a git repo with at least one tag.
# See the versioningit comment at the very top of this file!
default-version = "0.0.0+unknown"
[tool.versioningit.vcs]
@ -107,8 +113,8 @@ distance-dirty = "{base_version}+{distance}.{vcs}{rev}.dirty"
method = "null"
[tool.versioningit.onbuild]
source-file = "src/streamlink/_version.py"
build-file = "streamlink/_version.py"
# When building the sdist or wheel, remove versioningit build-requirement and set the static version string
method = { module = "build_backend.onbuild", value = "onbuild" }
# https://docs.pytest.org/en/latest/reference/customize.html#configuration

View File

@ -74,10 +74,17 @@ data_files = [
if __name__ == "__main__":
from setuptools import setup # type: ignore[import]
from versioningit import get_cmdclasses
try:
# versioningit is only required when building from git (see pyproject.toml)
from versioningit import get_cmdclasses
except ImportError: # pragma: no cover
def get_cmdclasses(): # type: ignore
return {}
setup(
cmdclass=get_cmdclasses(),
entry_points=entry_points,
data_files=data_files,
# version="", # static version string template, uncommented and substituted by versioningit's onbuild hook
)

View File

@ -1,15 +1,15 @@
# Always get the current version in "editable" installs
# `pip install -e .` / `python setup.py develop`
# This module will get replaced by versioningit when building a source distribution
# and instead of trying to get the version string from git, a static version string will be set
def _get_version() -> str:
"""
Get the current version from git in "editable" installs
"""
from pathlib import Path
from versioningit import get_version
import streamlink
return get_version(
project_dir=Path(streamlink.__file__).parents[2],
)
return get_version(project_dir=Path(streamlink.__file__).parents[2])
# The following _get_version() call will get replaced by versioningit with a static version string when building streamlink
# `pip install .` / `pip wheel .` / `python setup.py build` / `python setup.py bdist_wheel` / etc.
__version__ = _get_version()