Skip to content

Commit

Permalink
feat: Custom version format for source="scm" (#217)
Browse files Browse the repository at this point in the history
* feat: Custom version format for source="scm"
Fixes #208

Signed-off-by: Frost Ming <me@frostming.com>

* fix: rename

Signed-off-by: Frost Ming <me@frostming.com>
  • Loading branch information
frostming committed Mar 1, 2024
1 parent e6fcd5f commit 4f20cfd
Show file tree
Hide file tree
Showing 6 changed files with 116 additions and 46 deletions.
19 changes: 19 additions & 0 deletions docs/metadata.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,25 @@ source = "scm"
tag_regex = '^(?:\D*)?(?P<version>([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 `version_format` option with a format function:

```toml
[tool.pdm.version]
source = "scm"
version_format = "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
Expand Down
49 changes: 14 additions & 35 deletions src/pdm/backend/hooks/version/__init__.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -91,11 +75,20 @@ def resolve_version_from_scm(
write_to: str | None = None,
write_template: str = "{}\n",
tag_regex: 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:
version = get_version_from_scm(context.root, tag_regex=tag_regex)
if version_format is not None:
version_formatter, _ = evaluate_module_attribute(
version_format, 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
Expand Down Expand Up @@ -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
27 changes: 17 additions & 10 deletions src/pdm/backend/hooks/version/scm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -60,7 +60,7 @@ def _subprocess_call(
)


class VersionInfo(NamedTuple):
class SCMVersion(NamedTuple):
version: Version
distance: int | None
dirty: bool
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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("+")

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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 "
Expand Down
49 changes: 48 additions & 1 deletion src/pdm/backend/utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from __future__ import annotations

import ast
import contextlib
import functools
import importlib.util
import os
import re
Expand All @@ -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


Expand Down Expand Up @@ -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 '<module>:<attribute>'
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
5 changes: 5 additions & 0 deletions tests/fixtures/projects/demo-using-scm/version.py
Original file line number Diff line number Diff line change
@@ -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}"
13 changes: 13 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
"version_format": "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"])
Expand Down

0 comments on commit 4f20cfd

Please sign in to comment.