diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8c59b6..e6f14bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: cache: "pip" - name: Install Dependencies run: | - python -m pip install -U pip pytest setuptools editables + python -m pip install -U pip pytest setuptools editables pytest-lazy-fixture pip install . - name: Run Tests run: | diff --git a/docs/metadata.md b/docs/metadata.md index a4f991b..a77d5dd 100644 --- a/docs/metadata.md +++ b/docs/metadata.md @@ -53,14 +53,25 @@ Alternatively, you can specify a default version in the configuration: fallback_version = "0.0.0" ``` -You can specify another regex pattern to match the SCM tag, in which a `version` group is required: +To control which scm tags are used to generate the version, you can use two +fields: `tag_filter` and `tag_regex`. ```toml [tool.pdm.version] source = "scm" -tag_regex = '^(?:\D*)?(?P([1-9][0-9]*!)?(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*))*((a|b|c|rc)(0|[1-9][0-9]*))?(\.post(0|[1-9][0-9]*))?(\.dev(0|[1-9][0-9]*))?$)$' +tag_filter = "test/*" +tag_regex = '^test/(?:\D*)?(?P([1-9][0-9]*!)?(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*))*((a|b|c|rc)(0|[1-9][0-9]*))?(\.post(0|[1-9][0-9]*))?(\.dev(0|[1-9][0-9]*))?$)$' ``` +`tag_filter` filters the set of tags which are considered as candidates to +capture your project's version. For `git` repositories, this field is a glob +matched against the tag. For `hg` repositories, it is a regular expression used +with the `latesttag` function. + +`tag_regex` configures how you extract a version from a tag. It is applied after +`tag_filter` extracts candidate tags to extract the version from that tag. It is +a python style regular expression. + +++ 2.2.0 To customize the format of the version string, specify the `version_format` option with a format function: @@ -117,10 +128,10 @@ write_template = "__version__ = '{}'" ``` !!! note - The path in `write_to` is relative to the root of the wheel file, hence the `package-dir` part should be stripped. +The path in `write_to` is relative to the root of the wheel file, hence the `package-dir` part should be stripped. !!! note - `pdm-backend` will rewrite the whole file each time, so you can't have additional contents in that file. +`pdm-backend` will rewrite the whole file each time, so you can't have additional contents in that file. ## Variables expansion @@ -159,7 +170,7 @@ dependencies = [ ``` !!! note - The triple slashes `///` is required for the compatibility of Windows and POSIX systems. +The triple slashes `///` is required for the compatibility of Windows and POSIX systems. !!! note - The relative paths will be expanded into the absolute paths on the local machine. So it makes no sense to include them in a distribution, since others who install the package will not have the same paths. +The relative paths will be expanded into the absolute paths on the local machine. So it makes no sense to include them in a distribution, since others who install the package will not have the same paths. diff --git a/pdm.lock b/pdm.lock index ae64333..b8ba5b2 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "docs", "dev", "test"] strategy = ["cross_platform", "inherit_metadata"] lock_version = "4.4.1" -content_hash = "sha256:cf39df6b8559b58b2831274a6fa4329af2fa2d41f4e5b4e2fda292f56907a29b" +content_hash = "sha256:7580f3c914b801cd111ec0b96506f0813cae35400bc9c9ae293d22a43a2078ef" [[package]] name = "attrs" @@ -733,6 +733,19 @@ files = [ {file = "pytest_cov-4.0.0-py3-none-any.whl", hash = "sha256:2feb1b751d66a8bd934e5edfa2e961d11309dc37b73b0eabe73b5945fee20f6b"}, ] +[[package]] +name = "pytest-lazy-fixture" +version = "0.6.3" +summary = "It helps to use fixtures in pytest.mark.parametrize" +groups = ["test"] +dependencies = [ + "pytest>=3.2.5", +] +files = [ + {file = "pytest-lazy-fixture-0.6.3.tar.gz", hash = "sha256:0e7d0c7f74ba33e6e80905e9bfd81f9d15ef9a790de97993e34213deb5ad10ac"}, + {file = "pytest_lazy_fixture-0.6.3-py3-none-any.whl", hash = "sha256:e0b379f38299ff27a653f03eaa69b08a6fd4484e46fd1c9907d984b9f9daeda6"}, +] + [[package]] name = "pytest-xdist" version = "3.0.2" @@ -750,7 +763,7 @@ files = [ [[package]] name = "python-dateutil" -version = "2.8.2" +version = "2.9.0.post0" requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" summary = "Extensions to the standard Python datetime module" groups = ["docs"] @@ -758,8 +771,8 @@ dependencies = [ "six>=1.5", ] files = [ - {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, - {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index d718914..78777b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,6 +86,7 @@ source-includes = ["tests"] test = [ "pytest", "pytest-cov", + "pytest-lazy-fixture>=0.6.3", "pytest-xdist", "setuptools", ] diff --git a/src/pdm/backend/hooks/version/__init__.py b/src/pdm/backend/hooks/version/__init__.py index 2e106ef..df0a667 100644 --- a/src/pdm/backend/hooks/version/__init__.py +++ b/src/pdm/backend/hooks/version/__init__.py @@ -75,6 +75,7 @@ def resolve_version_from_scm( write_to: str | None = None, write_template: str = "{}\n", tag_regex: str | None = None, + tag_filter: str | None = None, version_format: str | None = None, fallback_version: str | None = None, ) -> str: @@ -88,7 +89,10 @@ def resolve_version_from_scm( else: version_formatter = None version = get_version_from_scm( - context.root, tag_regex=tag_regex, version_formatter=version_formatter + context.root, + tag_regex=tag_regex, + version_formatter=version_formatter, + tag_filter=tag_filter, ) if version is None: if fallback_version is not None: diff --git a/src/pdm/backend/hooks/version/scm.py b/src/pdm/backend/hooks/version/scm.py index 0a9e201..831604e 100644 --- a/src/pdm/backend/hooks/version/scm.py +++ b/src/pdm/backend/hooks/version/scm.py @@ -14,7 +14,7 @@ from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path -from typing import TYPE_CHECKING, Callable, Iterable, NamedTuple +from typing import TYPE_CHECKING, Callable, NamedTuple from pdm.backend._vendor.packaging.version import Version @@ -29,6 +29,7 @@ @dataclass(frozen=True) class Config: tag_regex: re.Pattern + tag_filter: str | None def _subprocess_call( @@ -164,28 +165,27 @@ def tag_to_version(config: Config, tag: str) -> Version: return Version(version) -def tags_to_versions(config: Config, tags: Iterable[str]) -> list[Version]: - """ - take tags that might be prefixed with a keyword and return only the version part - :param tags: an iterable of tags - :param config: optional configuration object - """ - return [tag_to_version(config, tag) for tag in tags if tag] - - def git_parse_version(root: StrPath, config: Config) -> SCMVersion | None: - GIT = shutil.which("git") - if not GIT: + git = shutil.which("git") + if not git: return None - ret, repo, _ = _subprocess_call([GIT, "rev-parse", "--show-toplevel"], root) + ret, repo, _ = _subprocess_call([git, "rev-parse", "--show-toplevel"], root) if ret or not repo: return None if os.path.isfile(os.path.join(repo, ".git/shallow")): warnings.warn(f"{repo!r} is shallow and may cause errors") - describe_cmd = [GIT, "describe", "--dirty", "--tags", "--long", "--match", "*.*"] - ret, output, err = _subprocess_call(describe_cmd, repo) + describe_cmd = [ + git, + "describe", + "--dirty", + "--tags", + "--long", + "--match", + config.tag_filter or "*.*", + ] + ret, output, _ = _subprocess_call(describe_cmd, repo) branch = _git_get_branch(repo) if ret: @@ -201,54 +201,44 @@ def git_parse_version(root: StrPath, config: Config) -> SCMVersion | None: return meta(config, tag, number or None, dirty, node, branch) -def get_latest_normalizable_tag(root: StrPath) -> str: - # Gets all tags containing a '.' from oldest to newest - cmd = [ - "hg", - "log", - "-r", - "ancestors(.) and tag('re:\\.')", - "--template", - "{tags}\n", - ] - _, output, _ = _subprocess_call(cmd, root) - outlines = output.split() - if not outlines: - return "null" - tag = outlines[-1].split()[-1] - return tag +def get_distance_revset(tag: str | None) -> str: + return ( + "(branch(.)" # look for revisions in this branch only + " and {rev}::." # after the last tag + # ignore commits that only modify .hgtags and nothing else: + " and (merge() or file('re:^(?!\\.hgtags).*$'))" + " and not {rev})" # ignore the tagged commit itself + ).format(rev=f"tag({tag!r})" if tag is not None else "null") -def hg_get_graph_distance(root: StrPath, rev1: str, rev2: str = ".") -> int: - cmd = ["hg", "log", "-q", "-r", f"{rev1}::{rev2}"] +def hg_get_graph_distance(root: StrPath, tag: str | None) -> int: + cmd = ["hg", "log", "-q", "-r", get_distance_revset(tag)] _, out, _ = _subprocess_call(cmd, root) - return len(out.strip().splitlines()) - 1 + return len(out.strip().splitlines()) def _hg_tagdist_normalize_tagcommit( - config: Config, root: StrPath, tag: str, dist: int, node: str, branch: str + config: Config, + root: StrPath, + tag: str, + dist: int, + node: str, + branch: str, + dirty: bool, ) -> SCMVersion: - dirty = node.endswith("+") - node = "h" + node.strip("+") - # Detect changes since the specified tag - revset = ( - "(branch(.)" # look for revisions in this branch only - " and tag({tag!r})::." # after the last tag - # ignore commits that only modify .hgtags and nothing else: - " and (merge() or file('re:^(?!\\.hgtags).*$'))" - " and not tag({tag!r}))" # ignore the tagged commit itself - ).format(tag=tag) if tag != "0.0": _, commits, _ = _subprocess_call( - ["hg", "log", "-r", revset, "--template", "{node|short}"], + ["hg", "log", "-r", get_distance_revset(tag), "--template", "{node|short}"], root, ) else: commits = "True" if commits or dirty: - return meta(config, tag, distance=dist, node=node, dirty=dirty, branch=branch) + return meta( + config, tag, distance=dist or None, node=node, dirty=dirty, branch=branch + ) else: return meta(config, tag) @@ -280,32 +270,40 @@ def _bump_regex(version: str) -> str: def hg_parse_version(root: StrPath, config: Config) -> SCMVersion | None: - if not shutil.which("hg"): + hg = shutil.which("hg") + if not hg: return None - _, output, _ = _subprocess_call("hg id -i -b -t", root) - identity_data = output.split() - if not identity_data: - return None - node = identity_data.pop(0) - branch = identity_data.pop(0) - if "tip" in identity_data: - # tip is not a real tag - identity_data.remove("tip") - tags = tags_to_versions(config, identity_data) - dirty = node[-1] == "+" - if tags: - return meta(config, tags[0], dirty=dirty, branch=branch) - - if node.strip("+") == "0" * 12: - return meta(config, "0.0", dirty=dirty, branch=branch) + tag_filter = config.tag_filter or "\\." + _, output, _ = _subprocess_call( + [ + hg, + "log", + "-r", + ".", + "--template", + f"{{latesttag(r're:{tag_filter}')}}-{{node|short}}-{{branch}}", + ], + root, + ) + tag: str | None + tag, node, branch = output.rsplit("-", 2) + # If no tag exists passes the tag filter. + if tag == "null": + tag = None + + _, id_output, _ = _subprocess_call( + [hg, "id", "-i"], + root, + ) + dirty = id_output.endswith("+") try: - tag = get_latest_normalizable_tag(root) dist = hg_get_graph_distance(root, tag) - if tag == "null": + if tag is None: tag = "0.0" - dist = int(dist) + 1 - return _hg_tagdist_normalize_tagcommit(config, root, tag, dist, node, branch) + return _hg_tagdist_normalize_tagcommit( + config, root, tag, dist, node, branch, dirty=dirty + ) except ValueError: return None # unpacking failed, old hg @@ -332,11 +330,15 @@ def get_version_from_scm( root: str | Path, *, tag_regex: str | None = None, + tag_filter: str | None = None, version_formatter: Callable[[SCMVersion], str] | None = None, ) -> str | None: - config = Config(tag_regex=re.compile(tag_regex) if tag_regex else DEFAULT_TAG_REGEX) + config = Config( + tag_regex=re.compile(tag_regex) if tag_regex else DEFAULT_TAG_REGEX, + tag_filter=tag_filter, + ) for func in (git_parse_version, hg_parse_version): - version = func(root, config) # type: ignore + version = func(root, config) if version: if version_formatter is None: version_formatter = format_version diff --git a/src/pdm/backend/utils.py b/src/pdm/backend/utils.py index 66d72eb..73c8ea7 100644 --- a/src/pdm/backend/utils.py +++ b/src/pdm/backend/utils.py @@ -107,7 +107,7 @@ def _build_filter(patterns: Iterable[str]) -> Callable[[str], bool]: @contextmanager -def cd(path: str) -> Generator[None, None, None]: +def cd(path: str | Path) -> Generator[None, None, None]: _old_cwd = os.getcwd() os.chdir(path) try: diff --git a/tests/pdm/backend/hooks/version/test_scm.py b/tests/pdm/backend/hooks/version/test_scm.py new file mode 100644 index 0000000..9431461 --- /dev/null +++ b/tests/pdm/backend/hooks/version/test_scm.py @@ -0,0 +1,287 @@ +import re +import shutil +import subprocess +import tempfile +from abc import ABC, abstractmethod +from collections.abc import Iterable +from datetime import datetime, timezone +from pathlib import Path +from typing import cast + +import pytest +from pytest_lazyfixture import lazy_fixture + +from pdm.backend.hooks.version.scm import get_version_from_scm + +# Copied from https://semver.org/ +# fmt: off +_SEMVER_REGEX = re.compile( + r"^(?P0|[1-9]\d*)" + r"\.(?P0|[1-9]\d*)" + r"\.(?P0|[1-9]\d*)" + r"(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)" + r"(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?" + r"(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$" +) +# fmt: on + + +def increment_patch(version: str) -> str: + m = _SEMVER_REGEX.match(version) + assert m is not None, "Version provided doesn't match semver regex" + + result = [m["major"], ".", m["minor"], ".", str(int(m["patch"]) + 1)] + if "prerelease" in m.groups(): + result.append("-") + result.append(m["prerelease"]) + + if "buildmetadata" in m.groups(): + result.append("+") + result.append(m["buildmetadata"]) + + return "".join(result) + + +class Scm(ABC): + """Common interface for different source code management solutions""" + + __slots__ = ( + "_cmd", + "_cwd", + ) + + def __init__(self, cmd: Path, cwd: Path) -> None: + """Creates a new Scm + + Args: + cmd: The base command to use for the scm + cwd: The working directory to use when running these commands + """ + self._cmd = cmd + self._cwd = cwd + + self._init() + + def run(self, *args: str | Path): + result = subprocess.run( + [self._cmd, *args], + capture_output=True, + encoding="utf-8", + check=True, + cwd=self._cwd, + ) + return result.stdout + + @abstractmethod + def _init(self): + """Initializes the SCM system in the provided directory""" + ... + + @abstractmethod + def commit(self, commit_message: str, files: list[Path]): + """Creates a commit + + Args: + commit_message: The message to store for the commit + files: The files to include when creating the commit + """ + ... + + @abstractmethod + def tag(self, name: str): + """Create a tag + + Args: + name: The name for the provided tag + """ + ... + + @property + @abstractmethod + def current_hash(self) -> str: ... + + +class GitScm(Scm): + def __init__(self, cmd: Path, cwd: Path) -> None: + super().__init__(cmd, cwd) + self.run("config", "commit.gpgsign", "false") + + def _init(self): + self.run("init") + + def commit(self, commit_message: str, files: list[Path]): + self.run("add", *files) + self.run("commit", "-m", commit_message) + + def tag(self, name: str): + self.run("tag", "-am", "some tag", name) + + @property + def current_hash(self) -> str: + return "g" + self.run("rev-parse", "--short", "HEAD").strip() + + +class HgScm(Scm): + def _init(self): + self.run("init") + + def commit(self, commit_message: str, files: list[Path]): + self.run("add", *files) + self.run("commit", "-m", commit_message, *files) + + def tag(self, name: str): + self.run("tag", name) + + @property + def current_hash(self) -> str: + return self.run("id", "-i").strip() + + +@pytest.fixture +def scm_dir() -> Iterable[Path]: + d = Path(tempfile.mkdtemp()) + + yield d + + shutil.rmtree(d) + + +@pytest.fixture +def git(scm_dir: Path) -> GitScm: + git = shutil.which("git") + assert git is not None, "Cannot find git in path" + + scm = GitScm(Path(git), scm_dir) + + return scm + + +@pytest.fixture +def hg(scm_dir: Path) -> HgScm: + hg = shutil.which("hg") + assert hg is not None, "Cannot find git in path" + + scm = HgScm(Path(hg), scm_dir) + + return scm + + +@pytest.fixture(params=[lazy_fixture("git"), lazy_fixture("hg")]) +def scm(request: pytest.FixtureRequest, scm_dir: Path) -> Scm: + scm = cast(Scm, request.param) + + file_path = scm_dir / "test.txt" + with open(file_path, "w") as f: + f.write("a\n") + + scm.commit("Add a", [file_path]) + + return scm + + +def test__get_version_from_scm__returns_tag_if_method_unspecified( + scm_dir: Path, scm: Scm +): + expected_tag = "0.2.52" + scm.tag(expected_tag) + + version = get_version_from_scm(scm_dir) + + assert version is not None + assert version == expected_tag + + +def test__get_version_from_scm__adds_details_if_project_is_dirty( + scm_dir: Path, scm: Scm +): + expected_tag = "0.2.52" + file_path = scm_dir / "some_file.txt" + + with open(file_path, "w") as f: + f.write("having fun\n") + scm.commit("some commit", [file_path]) + scm.tag(expected_tag) + with open(file_path, "a") as f: + f.write("having fun 2\n") + + version = get_version_from_scm(scm_dir) + + assert version is not None + assert version == f"{expected_tag}+d{datetime.now(timezone.utc).strftime('%Y%m%d')}" + + +def test__get_version_from_scm__returns_version_if_tag_has_v(scm_dir: Path, scm: Scm): + expected_tag = "0.2.52" + scm.tag(f"v{expected_tag}") + + version = get_version_from_scm(scm_dir) + + assert version is not None + assert version == expected_tag + + +def test__get_version_from_scm__returns_default_if_tag_cannot_be_parsed( + scm_dir: Path, scm: Scm +): + scm.tag("some-tag-without-numbers") + + version = get_version_from_scm(scm_dir) + + assert version is not None + assert version == f"0.1.dev1+{scm.current_hash}" + + +def test__get_version_from_scm__tag_regex(scm_dir: Path, scm: Scm): + expected_version = "7.2.9" + tag_regex = "foo/bar-v(?P.*)" + scm.tag(f"foo/bar-v{expected_version}") + + version = get_version_from_scm(scm_dir, tag_regex=tag_regex) + + assert version is not None + assert version == expected_version + + +@pytest.mark.parametrize("index", [0, 1]) +def test__get_version_from_scm__selects_by_tag_filter_on_same_commit( + scm_dir: Path, scm: Scm, index: int +): + expected_versions = ["4.8.2", "2.4.9"] + tag_regex = r"tag-\d/(?P.*)" + for i, version in enumerate(expected_versions): + scm.tag(f"tag-{i}/v{version}") + + version = get_version_from_scm( + scm_dir, tag_regex=tag_regex, tag_filter=f"tag-{index}/v*" + ) + + assert version is not None + assert version == expected_versions[index] + + +@pytest.mark.parametrize("index", [0, 1]) +def test__get_version_from_scm__selects_by_tag_filter_on_different_commits( + scm_dir: Path, scm: Scm, index: int +): + expected_versions = ["4.8.2", "2.4.9"] + tag_regex = r"tag-\d/(?P.*)" + for i, version in enumerate(expected_versions): + file_path = scm_dir / "test_{i}.txt" + with open(file_path, "w") as f: + f.write(f"Testing {i}\n") + scm.commit(f"Add {i}", [file_path]) + scm.tag(f"tag-{i}/v{version}") + + file_path = scm_dir / "test_end.txt" + with open(file_path, "w") as f: + f.write("Testing end\n") + scm.commit("Add end", [file_path]) + + version = get_version_from_scm( + scm_dir, tag_regex=tag_regex, tag_filter=f"tag-{index}/v*" + ) + + num_patches = len(expected_versions) - index + next_version = increment_patch(expected_versions[index]) + assert version is not None + assert version == f"{next_version}.dev{num_patches}+{scm.current_hash}"