From c6eaca046ba55ae909756356389143b8939f2ab9 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Fri, 1 Mar 2024 10:22:52 +0800 Subject: [PATCH 1/2] feat: Custom version format for source="scm" Fixes #208 Signed-off-by: Frost Ming --- docs/metadata.md | 19 +++++++ src/pdm/backend/hooks/version/__init__.py | 49 ++++++------------- src/pdm/backend/hooks/version/scm.py | 27 ++++++---- src/pdm/backend/utils.py | 49 ++++++++++++++++++- .../projects/demo-using-scm/version.py | 5 ++ tests/test_api.py | 13 +++++ 6 files changed, 116 insertions(+), 46 deletions(-) create mode 100644 tests/fixtures/projects/demo-using-scm/version.py diff --git a/docs/metadata.md b/docs/metadata.md index f699da0..73004a8 100644 --- a/docs/metadata.md +++ b/docs/metadata.md @@ -52,6 +52,25 @@ 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]*))?$)$' ``` +To customize the format of the version string, specify the `format_version` option with a format function: + +```toml +[tool.pdm.version] +source = "scm" +format_version = "mypackage.version:format_version" +``` + +```python +# mypackage/version.py +from pdm.backend.hooks.version import SCMVersion + +def format_version(version: SCMVersion) -> str: + if version.distance is None: + return str(version.version) + else: + return f"{version.version}.post{version.distance}" +``` + ### Get with a specific function ```toml diff --git a/src/pdm/backend/hooks/version/__init__.py b/src/pdm/backend/hooks/version/__init__.py index 93a4003..0fdb5fd 100644 --- a/src/pdm/backend/hooks/version/__init__.py +++ b/src/pdm/backend/hooks/version/__init__.py @@ -1,31 +1,15 @@ from __future__ import annotations -import ast -import contextlib -import functools -import importlib import os import re -import sys import warnings from pathlib import Path -from typing import Any, Generator from pdm.backend.exceptions import ConfigError, PDMWarning, ValidationError from pdm.backend.hooks.base import Context +from pdm.backend.hooks.version.scm import SCMVersion as SCMVersion from pdm.backend.hooks.version.scm import get_version_from_scm - -_attr_regex = re.compile(r"([\w.]+)\s*:\s*([\w.]+)\s*(\([^)]+\))?") - - -@contextlib.contextmanager -def patch_sys_path(path: str | Path) -> Generator[None, None, None]: - old_path = sys.path[:] - sys.path.insert(0, str(path)) - try: - yield - finally: - sys.path[:] = old_path +from pdm.backend.utils import evaluate_module_attribute class DynamicVersionBuildHook: @@ -91,11 +75,20 @@ def resolve_version_from_scm( write_to: str | None = None, write_template: str = "{}\n", tag_regex: str | None = None, + format_version: str | None = None, ) -> str: if "PDM_BUILD_SCM_VERSION" in os.environ: version = os.environ["PDM_BUILD_SCM_VERSION"] else: - version = get_version_from_scm(context.root, tag_regex=tag_regex) + if format_version is not None: + version_formatter, _ = evaluate_module_attribute( + format_version, context.root + ) + else: + version_formatter = None + version = get_version_from_scm( + context.root, tag_regex=tag_regex, version_formatter=version_formatter + ) self._write_version(context, version, write_to, write_template) return version @@ -129,21 +122,7 @@ def resolve_version_from_call( write_to: str | None = None, write_template: str = "{}\n", ) -> str: - matched = _attr_regex.match(getter) - if matched is None: - raise ConfigError( - "Invalid version getter, must be in the format of " - "`module:attribute`." - ) - with patch_sys_path(context.root): - module = importlib.import_module(matched.group(1)) - attrs = matched.group(2).split(".") - obj: Any = functools.reduce(getattr, attrs, module) - args_group = matched.group(3) - if args_group: - args = ast.literal_eval(args_group) - else: - args = () - version = obj(*args) + version_getter, args = evaluate_module_attribute(getter, context.root) + version = version_getter(*args) self._write_version(context, version, write_to, write_template) return version diff --git a/src/pdm/backend/hooks/version/scm.py b/src/pdm/backend/hooks/version/scm.py index 64c975d..5ab658d 100644 --- a/src/pdm/backend/hooks/version/scm.py +++ b/src/pdm/backend/hooks/version/scm.py @@ -13,7 +13,7 @@ from dataclasses import dataclass from datetime import datetime from pathlib import Path -from typing import TYPE_CHECKING, Iterable, NamedTuple +from typing import TYPE_CHECKING, Callable, Iterable, NamedTuple from pdm.backend._vendor.packaging.version import Version @@ -60,7 +60,7 @@ def _subprocess_call( ) -class VersionInfo(NamedTuple): +class SCMVersion(NamedTuple): version: Version distance: int | None dirty: bool @@ -75,10 +75,10 @@ def meta( dirty: bool = False, node: str | None = None, branch: str | None = None, -) -> VersionInfo: +) -> SCMVersion: if isinstance(tag, str): tag = tag_to_version(config, tag) - return VersionInfo(tag, distance, dirty, node, branch) + return SCMVersion(tag, distance, dirty, node, branch) def _git_get_branch(root: StrPath) -> str | None: @@ -172,7 +172,7 @@ def tags_to_versions(config: Config, tags: Iterable[str]) -> list[Version]: return [tag_to_version(config, tag) for tag in tags if tag] -def git_parse_version(root: StrPath, config: Config) -> VersionInfo | None: +def git_parse_version(root: StrPath, config: Config) -> SCMVersion | None: GIT = shutil.which("git") if not GIT: return None @@ -226,7 +226,7 @@ def hg_get_graph_distance(root: StrPath, rev1: str, rev2: str = ".") -> int: def _hg_tagdist_normalize_tagcommit( config: Config, root: StrPath, tag: str, dist: int, node: str, branch: str -) -> VersionInfo: +) -> SCMVersion: dirty = node.endswith("+") node = "h" + node.strip("+") @@ -278,7 +278,7 @@ def _bump_regex(version: str) -> str: return "%s%d" % (prefix, int(tail) + 1) -def hg_parse_version(root: StrPath, config: Config) -> VersionInfo | None: +def hg_parse_version(root: StrPath, config: Config) -> SCMVersion | None: if not shutil.which("hg"): return None _, output, _ = _subprocess_call("hg id -i -b -t", root) @@ -309,7 +309,7 @@ def hg_parse_version(root: StrPath, config: Config) -> VersionInfo | None: return None # unpacking failed, old hg -def format_version(version: VersionInfo) -> str: +def format_version(version: SCMVersion) -> str: if version.distance is None: main_version = str(version.version) else: @@ -327,12 +327,19 @@ def format_version(version: VersionInfo) -> str: return main_version + local_version -def get_version_from_scm(root: str | Path, *, tag_regex: str | None = None) -> str: +def get_version_from_scm( + root: str | Path, + *, + tag_regex: str | None = None, + version_formatter: Callable[[SCMVersion], str] | None = None, +) -> str: config = Config(tag_regex=re.compile(tag_regex) if tag_regex else DEFAULT_TAG_REGEX) for func in (git_parse_version, hg_parse_version): version = func(root, config) # type: ignore if version: - return format_version(version) + if version_formatter is None: + version_formatter = format_version + return version_formatter(version) raise ValueError( "Cannot find the version from SCM or SCM isn't detected. \n" "You can still specify the version via environment variable " diff --git a/src/pdm/backend/utils.py b/src/pdm/backend/utils.py index 040c757..eb7b2b4 100644 --- a/src/pdm/backend/utils.py +++ b/src/pdm/backend/utils.py @@ -1,5 +1,8 @@ from __future__ import annotations +import ast +import contextlib +import functools import importlib.util import os import re @@ -11,12 +14,13 @@ from contextlib import contextmanager from fnmatch import fnmatchcase from pathlib import Path -from typing import Callable, Generator, Iterable, Match +from typing import Any, Callable, Generator, Iterable, Match from pdm.backend._vendor.packaging import tags from pdm.backend._vendor.packaging.markers import Marker from pdm.backend._vendor.packaging.requirements import Requirement from pdm.backend._vendor.packaging.version import InvalidVersion, Version +from pdm.backend.exceptions import ConfigError from pdm.backend.macosx_platform import calculate_macosx_platform_tag @@ -234,3 +238,46 @@ def normalize_file_permissions(st_mode: int) -> int: new_mode |= 0o111 # Executable: 644 -> 755 return new_mode + + +@contextlib.contextmanager +def patch_sys_path(path: str | Path) -> Generator[None, None, None]: + old_path = sys.path[:] + sys.path.insert(0, str(path)) + try: + yield + finally: + sys.path[:] = old_path + + +_attr_regex = re.compile(r"([\w.]+):([\w.]+)\s*(\([^)]+\))?") + + +def evaluate_module_attribute( + expression: str, context: Path | None = None +) -> tuple[Any, tuple[Any, ...]]: + """Evaluate the value of an expression like ':' + + Returns: + the object and the calling arguments if any + """ + if context is None: + cm = contextlib.nullcontext() + else: + cm = patch_sys_path(context) # type: ignore[assignment] + + matched = _attr_regex.match(expression) + if matched is None: + raise ConfigError( + "Invalid expression, must be in the format of " "`module:attribute`." + ) + with cm: + module = importlib.import_module(matched.group(1)) + attrs = matched.group(2).split(".") + obj: Any = functools.reduce(getattr, attrs, module) + args_group = matched.group(3) + if args_group: + args = ast.literal_eval(args_group) + else: + args = () + return obj, args diff --git a/tests/fixtures/projects/demo-using-scm/version.py b/tests/fixtures/projects/demo-using-scm/version.py new file mode 100644 index 0000000..73e4843 --- /dev/null +++ b/tests/fixtures/projects/demo-using-scm/version.py @@ -0,0 +1,5 @@ +from pdm.backend.hooks.version import SCMVersion + + +def format_version(version: SCMVersion) -> str: + return f"{version.version}rc{version.distance or 0}" diff --git a/tests/test_api.py b/tests/test_api.py index d76311c..ec0689b 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -398,6 +398,19 @@ def test_override_scm_version_via_env_var( assert wheel_name == "foo-1.0.0-py3-none-any.whl" +@pytest.mark.usefixtures("scm") +@pytest.mark.parametrize("name", ["demo-using-scm"]) +def test_build_wheel_custom_version_format(fixture_project: Path, dist) -> None: + builder = WheelBuilder(fixture_project) + builder.config.data.setdefault("tool", {}).setdefault("pdm", {})["version"] = { + "source": "scm", + "format_version": "version:format_version", + } + with builder: + wheel = builder.build(dist) + assert wheel.name == "foo-0.1.0rc0-py3-none-any.whl" + + @pytest.mark.usefixtures("scm") @pytest.mark.parametrize("getter", ["get_version:run", "get_version:run()"]) @pytest.mark.parametrize("name", ["demo-using-scm"]) From 7b603f91721225ddc11c54c54e902c47d1f762ba Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Fri, 1 Mar 2024 10:26:01 +0800 Subject: [PATCH 2/2] fix: rename Signed-off-by: Frost Ming --- docs/metadata.md | 4 ++-- src/pdm/backend/hooks/version/__init__.py | 6 +++--- tests/test_api.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/metadata.md b/docs/metadata.md index 73004a8..04cf418 100644 --- a/docs/metadata.md +++ b/docs/metadata.md @@ -52,12 +52,12 @@ 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]*))?$)$' ``` -To customize the format of the version string, specify the `format_version` option with a format function: +To customize the format of the version string, specify the `version_format` option with a format function: ```toml [tool.pdm.version] source = "scm" -format_version = "mypackage.version:format_version" +version_format = "mypackage.version:format_version" ``` ```python diff --git a/src/pdm/backend/hooks/version/__init__.py b/src/pdm/backend/hooks/version/__init__.py index 0fdb5fd..6186c4c 100644 --- a/src/pdm/backend/hooks/version/__init__.py +++ b/src/pdm/backend/hooks/version/__init__.py @@ -75,14 +75,14 @@ def resolve_version_from_scm( write_to: str | None = None, write_template: str = "{}\n", tag_regex: str | None = None, - format_version: str | None = None, + version_format: str | None = None, ) -> str: if "PDM_BUILD_SCM_VERSION" in os.environ: version = os.environ["PDM_BUILD_SCM_VERSION"] else: - if format_version is not None: + if version_format is not None: version_formatter, _ = evaluate_module_attribute( - format_version, context.root + version_format, context.root ) else: version_formatter = None diff --git a/tests/test_api.py b/tests/test_api.py index ec0689b..9d159e6 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -404,7 +404,7 @@ def test_build_wheel_custom_version_format(fixture_project: Path, dist) -> None: builder = WheelBuilder(fixture_project) builder.config.data.setdefault("tool", {}).setdefault("pdm", {})["version"] = { "source": "scm", - "format_version": "version:format_version", + "version_format": "version:format_version", } with builder: wheel = builder.build(dist)