Skip to content

Commit 6d3db25

Browse files
Implement driver for use with setuptools-scm.
Some python projects use `setuptools-scm` to extract version information from git tags instead of setting the version manually inside `pyproject.toml`. This driver adds support for these projects by reading the version information by calling `setuptools-scm` as a library and then setting the appropriate environment variable `SETUPTOOLS_SCM_PRETEND_VERSION_FOR_<DIST_NAME>` in the Sphinx build environment. * pyproject.toml: add `setuptools_scm` and `packaging` as optional dependencies used by the integration. * poetry.lock: Updated using `poetry lock`. * sphinx_polyversion/setuptools_scm.py: Implement `SetuptoolsScmDriver` and `version_for_ref` used by it. * tests/test_setuptools_scm.py: Add unit/integration tests
1 parent a159667 commit 6d3db25

File tree

4 files changed

+351
-9
lines changed

4 files changed

+351
-9
lines changed

poetry.lock

Lines changed: 45 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,16 @@ classifiers = [
2525

2626

2727
[tool.poetry.dependencies]
28-
python = ">=3.8"
29-
virtualenv = { version = ">=20", optional = true }
30-
jinja2 = { version = ">=3", optional = true }
28+
python = ">=3.8"
29+
virtualenv = { version = ">=20", optional = true }
30+
jinja2 = { version = ">=3", optional = true }
31+
setuptools_scm = { version = ">=9.2.0", optional = true }
32+
packaging = { version = ">=25.0", optional = true }
3133

3234
[tool.poetry.extras]
33-
virtualenv = ["virtualenv"]
34-
jinja = ["jinja2"]
35+
virtualenv = ["virtualenv"]
36+
jinja = ["jinja2"]
37+
setuptools_scm = ["setuptools_scm", "packaging"]
3538

3639
[tool.poetry.scripts]
3740
sphinx-polyversion = "sphinx_polyversion.main:main"
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
"""Setuptools SCM integration for sphinx-polyversion."""
2+
3+
from __future__ import annotations
4+
5+
import dataclasses
6+
import shlex
7+
from logging import getLogger
8+
from pathlib import Path
9+
from typing import Tuple, TypeVar
10+
11+
from packaging.utils import canonicalize_name
12+
from setuptools_scm import Configuration, _get_version
13+
from setuptools_scm.git import DEFAULT_DESCRIBE
14+
15+
from sphinx_polyversion.driver import DefaultDriver
16+
from sphinx_polyversion.git import GitRef
17+
from sphinx_polyversion.pyvenv import VirtualPythonEnvironment
18+
from sphinx_polyversion.utils import to_thread
19+
20+
logger = getLogger(__name__)
21+
22+
23+
async def version_for_ref(repo_path: str | Path, ref: str) -> Tuple[str, str] | None:
24+
"""
25+
Get version that `setuptools_scm` determined for a given revision.
26+
27+
Calls `setuptools-scm` using the configuration in the `pyproject.toml`
28+
file. Alters the git describe command configured by appending
29+
the given :paramref:`ref`.
30+
31+
.. warning::
32+
33+
Only works when using git vcs.
34+
35+
.. warning::
36+
37+
Doesn't work for legacy python projects that do not use a `pyproject.toml`
38+
file.
39+
40+
41+
Parameters
42+
----------
43+
repo_path : str | Path
44+
The location of the git repository.
45+
ref : str
46+
The reference of the revision.
47+
48+
Returns
49+
-------
50+
Tuple[str, str] | None
51+
The version determined by `setuptools-scm`
52+
and the canonical distribution name, optional
53+
54+
Raises
55+
------
56+
FileNotFoundError
57+
No `pyproject.toml` file was found in the repo.
58+
59+
"""
60+
# Load project config for `setuptools-scm`
61+
repo_path = Path(repo_path)
62+
pyproject = repo_path / "pyproject.toml"
63+
if not pyproject.exists():
64+
raise FileNotFoundError(f"Could not find configuration file {pyproject}")
65+
config = Configuration.from_file(pyproject)
66+
67+
# determine distribution name
68+
dist_name = canonicalize_name(config.dist_name)
69+
70+
# Alter `git describe` command to use the ref
71+
cmd = config.scm.git.describe_command
72+
if cmd is None:
73+
# Use the `setuptools-scm`'s default describe command
74+
cmd = list(DEFAULT_DESCRIBE)
75+
elif isinstance(cmd, str):
76+
cmd = shlex.split(cmd)
77+
cmd = list(cmd)
78+
cmd.append(ref)
79+
80+
# remove "--dirty" if present
81+
# its incompatible with describing a specific ref
82+
if "--dirty" in cmd:
83+
cmd.remove("--dirty")
84+
85+
# Update configuration
86+
git_cfg = dataclasses.replace(config.scm.git, describe_command=cmd)
87+
scm_cfg = dataclasses.replace(config.scm, git=git_cfg)
88+
config = dataclasses.replace(config, scm=scm_cfg)
89+
90+
# Get the version (don't write any version files).
91+
version = await to_thread(_get_version, config, force_write_version_files=False)
92+
if not version:
93+
return None
94+
return (version, dist_name)
95+
96+
97+
RT = TypeVar("RT", bound=GitRef)
98+
ENV = TypeVar("ENV", bound=VirtualPythonEnvironment)
99+
S = TypeVar("S")
100+
101+
102+
class SetuptoolsScmDriver(DefaultDriver[RT, ENV, S]):
103+
"""
104+
Driver that uses `setuptools-scm` to determine the version of each revision.
105+
106+
This driver requires that the project uses `setuptools-scm` and has a
107+
`pyproject.toml` file in the root of the repository.
108+
109+
.. note::
110+
111+
Must be used with
112+
:class:`~sphinx_polyversion.git.GitRef` (thus git vcs)
113+
and subclasses of :class:`~sphinx_polyversion.pyvenv.VirtualPythonEnvironment`
114+
115+
.. note::
116+
117+
Doesn't work for legacy python projects that do not use a `pyproject.toml`
118+
file.
119+
120+
Parameters
121+
----------
122+
cwd : Path
123+
The current working directory
124+
output_dir : Path
125+
The directory where to place the built docs.
126+
vcs : VersionProvider[RT]
127+
The version provider to use.
128+
builder : Builder[ENV, Any]
129+
The builder to use.
130+
env : Callable[[Path, str], ENV]
131+
A factory producing the environments to use.
132+
data_factory : Callable[[DefaultDriver[RT, ENV, S], RT, ENV], JSONable], optional
133+
A callable returning the data to pass to the builder.
134+
root_data_factory : Callable[[DefaultDriver[RT, ENV, S]], dict[str, Any]], optional
135+
A callable returning the variables to pass to the jinja templates.
136+
namer : Callable[[RT], str], optional
137+
A callable determining the name of a revision.
138+
selector: Callable[[RT, Iterable[S]], S | Coroutine[Any, Any, S]], optional
139+
The selector to use when either `env` or `builder` are a dict.
140+
encoder : Encoder, optional
141+
The encoder to use for dumping `versions.json` to the output dir.
142+
static_dir : Path, optional
143+
The source directory for root level static files.
144+
template_dir : Path, optional
145+
The source directory for root level templates.
146+
mock : MockData[RT] | None | Literal[False], optional
147+
Only build from local files and mock building all docs using the data provided.
148+
149+
"""
150+
151+
async def init_environment(self, path: Path, rev: RT) -> ENV:
152+
"""
153+
Initialize the build environment for a revision and path.
154+
155+
The environment will be used to build the given revision and
156+
the path specifies the location where the revision is checked out.
157+
158+
This implementation calls `setuptools-scm` to determine the version
159+
for the given revision and sets the environment variable
160+
`SETUPTOOLS_SCM_PRETEND_VERSION_FOR_<DIST_NAME>` in the returned
161+
environment.
162+
163+
Parameters
164+
----------
165+
path : Path
166+
The location of the revisions files.
167+
rev : GitRef
168+
The revision the environment is used for.
169+
170+
Returns
171+
-------
172+
VirtualPythonEnvironment
173+
174+
"""
175+
f = await super().init_environment(path, rev)
176+
177+
logger.info("Calling setuptools-scm to determine version for %s", rev.name)
178+
try:
179+
r = await version_for_ref(self.root, rev.obj)
180+
except FileNotFoundError:
181+
logger.warning(
182+
"Could not find pyproject.toml file in %s, "
183+
"skipping setuptools-scm integration",
184+
self.root,
185+
)
186+
r = None
187+
188+
if r is None:
189+
logger.warning(
190+
"Couldn't determine `setuptools-scm` version for %s", rev.name
191+
)
192+
return f
193+
194+
version, dist_name = r
195+
var_dist_name = dist_name.replace("-", "_").upper()
196+
197+
f.env.setdefault(f"SETUPTOOLS_SCM_PRETEND_VERSION_FOR_{var_dist_name}", version)
198+
return f

tests/test_setuptools_scm.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""Integration tests for the `setuptools_scm` nmodule."""
2+
3+
import asyncio
4+
import shutil
5+
from pathlib import Path
6+
from types import SimpleNamespace
7+
8+
import pytest
9+
import pytest_asyncio
10+
11+
from sphinx_polyversion.setuptools_scm import version_for_ref
12+
from tests.test_git import NO_FS_MONITOR, no_git_env
13+
14+
# Skip tests if git is not available
15+
if shutil.which("git") is None:
16+
pytest.skip("git is required for these integration tests", allow_module_level=True)
17+
18+
19+
async def _run_git(args, cwd):
20+
git = ("git", *NO_FS_MONITOR)
21+
env = no_git_env()
22+
p = await asyncio.create_subprocess_exec(*git, *args, cwd=cwd, env=env)
23+
r = await p.wait()
24+
assert r == 0, f"git {' '.join(args)} failed with exit code {r}"
25+
26+
27+
@pytest_asyncio.fixture
28+
async def temp_git_repo(tmp_path: Path) -> Path:
29+
"""Create dummy git repo using `setuptools_scm`."""
30+
repo = tmp_path / "repo"
31+
repo.mkdir(parents=True, exist_ok=True)
32+
33+
# Write minimal pyproject.toml for setuptools_scm
34+
(repo / "pyproject.toml").write_text(
35+
"""
36+
[build-system]
37+
requires = ["setuptools>=61", "setuptools-scm"]
38+
build-backend = "setuptools.build_meta"
39+
40+
[project]
41+
name = "My-Dist_Name"
42+
43+
[tool.setuptools_scm]
44+
""",
45+
encoding="utf-8",
46+
)
47+
(repo / "README.md").write_text("# Test Project\n", encoding="utf-8")
48+
49+
# Initialize git repo and create a tag
50+
await _run_git(["init"], cwd=repo)
51+
await _run_git(["config", "user.email", "[email protected]"], cwd=repo)
52+
await _run_git(["config", "user.name", "example"], cwd=repo)
53+
await _run_git(["config", "commit.gpgsign", "false"], cwd=repo)
54+
await _run_git(["config", "init.defaultBranch", "main"], cwd=repo)
55+
await _run_git(["add", "."], cwd=repo)
56+
await _run_git(["commit", "-m", "initial"], cwd=repo)
57+
await _run_git(["tag", "v1.2.3"], cwd=repo)
58+
59+
return repo
60+
61+
62+
@pytest.mark.asyncio
63+
async def test_version_for_ref_determines_dist_name_and_version(temp_git_repo: Path):
64+
"""Test that `version_for_ref` determines version and dist name correctly."""
65+
result = await version_for_ref(temp_git_repo, "HEAD")
66+
assert result is not None, "Expected a version from setuptools-scm"
67+
version, dist_name = result
68+
# Distribution name should be canonicalized
69+
assert dist_name == "my-dist-name"
70+
# On exact tag, setuptools-scm should return the tag version
71+
assert version == "1.2.3"
72+
73+
74+
@pytest.mark.asyncio
75+
async def test_driver_sets_env_variable_from_setuptools_scm(
76+
temp_git_repo: Path, monkeypatch
77+
):
78+
"""Test that the driver sets the correct env variable from setuptools-scm."""
79+
import sphinx_polyversion.setuptools_scm as scm_mod
80+
81+
# Patch DefaultDriver.init_environment to return a fake env holder
82+
async def fake_init_environment(self, path, rev):
83+
return SimpleNamespace(env={})
84+
85+
monkeypatch.setattr(
86+
scm_mod.DefaultDriver, "init_environment", fake_init_environment
87+
)
88+
89+
# Create driver instance without calling __init__
90+
driver = object.__new__(scm_mod.SetuptoolsScmDriver)
91+
driver.root = temp_git_repo
92+
93+
# Fake GitRef-like object expected by the driver
94+
rev = SimpleNamespace(name="HEAD", obj="HEAD")
95+
96+
env_holder = await driver.init_environment(temp_git_repo, rev)
97+
# dist name from the repo is "my-dist-name" -> var name uses "-" -> "_" uppercased
98+
expected_key = "SETUPTOOLS_SCM_PRETEND_VERSION_FOR_MY_DIST_NAME"
99+
assert expected_key in env_holder.env
100+
assert env_holder.env[expected_key] == "1.2.3"

0 commit comments

Comments
 (0)