Skip to content

Commit a3d60a3

Browse files
authored
Add setuptools-scm support for dynamic version (#31)
* setuptools-scm added * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip
1 parent f1ae333 commit a3d60a3

6 files changed

Lines changed: 235 additions & 38 deletions

File tree

README.md

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,8 @@ Configure in `pyproject.toml` under `[tool.conan-py-build]`:
7676

7777
| Option | Description | Default |
7878
|--------|-------------|---------|
79-
| `version-file` | Path to a Python file from which to read `__version__` when `[project].version` is dynamic | (none) |
79+
| `version.file` | Path to a Python file containing `__version__ = "x.y.z"` (see [Dynamic version](#dynamic-version)) | (none) |
80+
| `version.provider` | Set to `"setuptools_scm"` to resolve version from git tags (see [Dynamic version](#dynamic-version)). Mutually exclusive with `version.file`. | (none) |
8081
| `conanfile-path` | Path to the Conan recipe (directory containing `conanfile.py` or path to the file), relative to project root | `"."` (project root) |
8182
| `wheel.packages` | List of paths (relative to project root) of Python packages to include in the wheel; each must be a directory with `__init__.py` | `["src/<normalized_project_name>"]` |
8283
| `sdist.include` | List of paths or patterns to add to the sdist | `[]` |
@@ -85,10 +86,26 @@ Configure in `pyproject.toml` under `[tool.conan-py-build]`:
8586

8687
### Dynamic version
8788

88-
There is limited support for dynamic version: set `dynamic = ["version"]` in
89-
`[project]` (no `version` key) and point to a Python file via
90-
`[tool.conan-py-build].version-file` (e.g. `"src/mypackage/__init__.py"`). The
91-
backend reads `__version__ = "x.y.z"` from that file.
89+
Set `dynamic = ["version"]` in `[project]` (no `version` key) and configure the version source in `[tool.conan-py-build.version]`:
90+
91+
**From a file** — reads `__version__ = "x.y.z"` from a Python file:
92+
93+
```toml
94+
[tool.conan-py-build.version]
95+
file = "src/mypackage/__init__.py"
96+
```
97+
98+
**From git tags (setuptools-scm)** — resolves version from VCS tags (e.g. `v1.0.0``1.0.0`):
99+
100+
```toml
101+
[tool.conan-py-build.version]
102+
provider = "setuptools_scm"
103+
```
104+
105+
The `setuptools-scm` options are configured in `[tool.setuptools_scm]` — see the
106+
[setuptools-scm docs](https://setuptools-scm.readthedocs.io/) for available options.
107+
108+
> **Note:** `version.file` and `provider = "setuptools_scm"` are mutually exclusive.
92109
93110
### License files (PEP 639)
94111

examples/basic-pybind11/pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ license = "MIT"
1010
license-files = ["LICENSE"]
1111
requires-python = ">=3.8"
1212

13-
[tool.conan-py-build]
14-
version-file = "python/myadder_pybind11/__init__.py"
13+
[tool.conan-py-build.version]
14+
file = "python/myadder_pybind11/__init__.py"
1515

1616
[tool.conan-py-build.wheel]
1717
packages = ["python/myadder_pybind11", "src/extra_utils"]

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ dependencies = [
3939
dev = [
4040
"pytest",
4141
"build",
42+
"setuptools-scm",
4243
]
4344

4445
[project.urls]

src/conan_py_build/build.py

Lines changed: 73 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -118,33 +118,88 @@ def _resolve_conanfile_path(conanfile_path: str, source_dir: Path) -> Path:
118118
return Path(full_path)
119119

120120

121-
def _get_version_from_config(source_dir: Path) -> Optional[str]:
122-
"""Read version from [tool.conan-py-build] version-file if set. Reads pyproject from source_dir."""
123-
tool = _get_tool_config(source_dir)
124-
version_file = tool.get("version-file")
121+
def _validate_version_config(source_dir: Path) -> None:
122+
"""Validate [tool.conan-py-build.version] settings."""
123+
version_cfg = _get_tool_config(source_dir).get("version", {})
124+
provider = version_cfg.get("provider")
125+
has_file = bool(version_cfg.get("file"))
126+
127+
if provider is not None and provider != "setuptools_scm":
128+
raise RuntimeError(
129+
f"[tool.conan-py-build.version].provider must be 'setuptools_scm', "
130+
f"got {provider!r}. To read version from a file, use version.file instead."
131+
)
132+
133+
if has_file and provider:
134+
raise RuntimeError(
135+
"[tool.conan-py-build.version].file and provider = 'setuptools_scm' "
136+
"are mutually exclusive. Use one or the other."
137+
)
138+
139+
if not has_file and not provider:
140+
raise RuntimeError(
141+
"[tool.conan-py-build.version] must define 'file' or 'provider'."
142+
)
143+
144+
145+
def _uses_setuptools_scm(source_dir: Path) -> bool:
146+
"""True when [tool.conan-py-build.version].provider is 'setuptools_scm'."""
147+
return _get_tool_config(source_dir).get("version", {}).get("provider") == "setuptools_scm"
148+
149+
150+
def _get_version_from_file(source_dir: Path) -> Optional[str]:
151+
"""Read __version__ from the path in [tool.conan-py-build.version].file."""
152+
version_file = _get_tool_config(source_dir).get("version", {}).get("file")
125153
if not version_file:
126154
return None
127155
resolved = (source_dir / version_file).resolve()
128156
try:
129157
resolved.relative_to(source_dir.resolve())
130158
except ValueError:
131159
raise RuntimeError(
132-
f"version-file must be inside project: {version_file!r}"
160+
f"[tool.conan-py-build.version].file must be inside project: {version_file!r}"
133161
)
134162
return _read_version_from_file(resolved)
135163

136164

165+
def _get_version_from_scm(source_dir: Path) -> str:
166+
"""Get version from setuptools-scm."""
167+
try:
168+
from setuptools_scm import Configuration, _get_version
169+
except ImportError:
170+
raise RuntimeError(
171+
"setuptools-scm is required when provider = 'setuptools_scm'. "
172+
"Add 'setuptools-scm' to [build-system].requires."
173+
)
174+
175+
config = Configuration.from_file(str(source_dir / "pyproject.toml"))
176+
try:
177+
version = _get_version(config, force_write_version_files=True)
178+
except TypeError:
179+
version = _get_version(config)
180+
if version is None:
181+
raise LookupError(
182+
"setuptools-scm could not detect a version. "
183+
"Build from a git repo with tags, or from an sdist."
184+
)
185+
return version
186+
187+
137188
def _resolve_version(project_metadata: dict, source_dir: Path) -> str:
138189
version = project_metadata.get("version")
139190
dynamic = project_metadata.get("dynamic")
140191
version_is_dynamic = isinstance(dynamic, list) and "version" in dynamic
141192

142-
if not version:
143-
version = _get_version_from_config(source_dir)
144-
if version_is_dynamic and not version:
193+
if not version and version_is_dynamic:
194+
_validate_version_config(source_dir)
195+
if _uses_setuptools_scm(source_dir):
196+
version = _get_version_from_scm(source_dir)
197+
else:
198+
version = _get_version_from_file(source_dir)
199+
if not version:
145200
raise RuntimeError(
146201
"dynamic = [\"version\"] but version could not be resolved. "
147-
"Set [tool.conan-py-build] version-file to a file with __version__ = \"x.y.z\" at module level."
202+
"Set [tool.conan-py-build.version].file or provider = 'setuptools_scm'."
148203
)
149204

150205
version = version or "0.0.0"
@@ -275,14 +330,17 @@ def _create_dist_info(staging_dir: Path, metadata: dict, project_dir: Path) -> P
275330

276331
# PEP 517 Hooks
277332

278-
279333
def get_requires_for_build_wheel(config_settings: Optional[dict] = None) -> list:
280334
"""PEP 517 hook: Return additional dependencies needed to build a wheel."""
335+
if _uses_setuptools_scm(Path.cwd()):
336+
return ["setuptools-scm"]
281337
return []
282338

283339

284340
def get_requires_for_build_sdist(config_settings: Optional[dict] = None) -> list:
285341
"""PEP 517 hook: Return additional dependencies needed to build an sdist."""
342+
if _uses_setuptools_scm(Path.cwd()):
343+
return ["setuptools-scm"]
286344
return []
287345

288346

@@ -546,6 +604,11 @@ def build_sdist(sdist_directory: str, config_settings: Optional[dict] = None) ->
546604
resolved_conanfile = _resolve_conanfile_path(conanfile_path, source_dir)
547605
default_include.append(resolved_conanfile.relative_to(source_dir).as_posix())
548606

607+
if _uses_setuptools_scm(source_dir):
608+
version_file = _read_pyproject(source_dir).get("tool", {}).get("setuptools_scm", {}).get("version_file")
609+
if version_file:
610+
default_include.append(version_file)
611+
549612
default_exclude = [
550613
"__pycache__",
551614
"*.pyc",

tests/test_integration.py

Lines changed: 106 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
"""Integration tests: run real PEP 517 hooks (build_sdist, build_wheel) on a project layout."""
2+
import os
3+
import subprocess
24
import tarfile
35
import types
46
import zipfile
@@ -9,10 +11,8 @@
911
from conan_py_build.build import build_sdist, build_wheel
1012

1113

12-
def make_integration_project(path: Path) -> None:
13-
"""Create a minimal conan-py-build project"""
14-
15-
(path / "pyproject.toml").write_text("""[project]
14+
_DEFAULT_PYPROJECT = """\
15+
[project]
1616
name = "integration-pkg"
1717
version = "0.1.0"
1818
description = "For integration tests"
@@ -21,11 +21,10 @@ def make_integration_project(path: Path) -> None:
2121
[build-system]
2222
requires = ["conan-py-build"]
2323
build-backend = "conan_py_build.build"
24-
""", encoding="utf-8")
25-
26-
(path / "LICENSE").write_text("MIT", encoding="utf-8")
24+
"""
2725

28-
(path / "conanfile.py").write_text("""from conan import ConanFile
26+
_DEFAULT_CONANFILE = """\
27+
from conan import ConanFile
2928
from conan.tools.cmake import cmake_layout
3029
3130
@@ -43,15 +42,30 @@ def source(self):
4342
4443
def build(self):
4544
pass
46-
""", encoding="utf-8")
47-
48-
(path / "CMakeLists.txt").write_text("""cmake_minimum_required(VERSION 3.15)
49-
project(integration_pkg)
50-
""", encoding="utf-8")
51-
52-
(path / "src" / "integration_pkg" / "__init__.py").parent.mkdir(parents=True)
53-
54-
(path / "src" / "integration_pkg" / "__init__.py").write_text("", encoding="utf-8")
45+
"""
46+
47+
48+
def make_integration_project(
49+
path: Path,
50+
*,
51+
pyproject_toml: str = _DEFAULT_PYPROJECT,
52+
conanfile: str = _DEFAULT_CONANFILE,
53+
pkg_name: str = "integration_pkg",
54+
init_content: str = "",
55+
license_text: str = "MIT",
56+
) -> None:
57+
"""Create a minimal conan-py-build project."""
58+
path.mkdir(exist_ok=True)
59+
(path / "pyproject.toml").write_text(pyproject_toml, encoding="utf-8")
60+
(path / "conanfile.py").write_text(conanfile, encoding="utf-8")
61+
(path / "CMakeLists.txt").write_text(
62+
"cmake_minimum_required(VERSION 3.15)\nproject(x)\n", encoding="utf-8"
63+
)
64+
if license_text:
65+
(path / "LICENSE").write_text(license_text, encoding="utf-8")
66+
pkg = path / "src" / pkg_name
67+
pkg.mkdir(parents=True, exist_ok=True)
68+
(pkg / "__init__.py").write_text(init_content, encoding="utf-8")
5569

5670

5771
@pytest.fixture
@@ -149,3 +163,78 @@ def test_build_wheel_with_profile_autodetect(integration_project, monkeypatch):
149163
assert profile_path.is_file(), "conan-py-build.profile should be created when autodetect is set"
150164
content = profile_path.read_text()
151165
assert "[settings]" in content or "os=" in content, "Profile should contain Conan settings"
166+
167+
168+
def _git_init_and_tag(cwd, tag):
169+
"""Initialise a throw-away git repo, commit everything and create *tag*."""
170+
env = {
171+
**os.environ,
172+
"GIT_AUTHOR_NAME": "test",
173+
"GIT_AUTHOR_EMAIL": "t@t",
174+
"GIT_COMMITTER_NAME": "test",
175+
"GIT_COMMITTER_EMAIL": "t@t",
176+
}
177+
for cmd in (
178+
["git", "init"],
179+
["git", "add", "."],
180+
["git", "commit", "-m", "init"],
181+
["git", "tag", tag],
182+
):
183+
subprocess.run(cmd, cwd=cwd, check=True, env=env, capture_output=True)
184+
185+
186+
def test_build_sdist_version_file(tmp_path, monkeypatch):
187+
"""Integration: build_sdist resolves dynamic version from [tool.conan-py-build.version].file."""
188+
proj = tmp_path / "proj"
189+
make_integration_project(proj, pkg_name="file_pkg", pyproject_toml="""\
190+
[project]
191+
name = "file-pkg"
192+
dynamic = ["version"]
193+
description = "Test"
194+
195+
[build-system]
196+
requires = ["conan-py-build"]
197+
build-backend = "conan_py_build.build"
198+
199+
[tool.conan-py-build.version]
200+
file = "src/file_pkg/__init__.py"
201+
""", init_content='__version__ = "2.3.4"')
202+
monkeypatch.chdir(proj)
203+
monkeypatch.setenv("CONAN_HOME", str(tmp_path / "conan_home"))
204+
205+
sdist_dir = tmp_path / "dist"
206+
sdist_dir.mkdir()
207+
assert build_sdist(str(sdist_dir)) == "file-pkg-2.3.4.tar.gz"
208+
209+
210+
def test_build_sdist_version_scm(tmp_path, monkeypatch):
211+
"""Integration: build_sdist resolves dynamic version from setuptools_scm (git tag)."""
212+
proj = tmp_path / "proj"
213+
make_integration_project(proj, pkg_name="scm_pkg", pyproject_toml="""\
214+
[project]
215+
name = "scm-pkg"
216+
dynamic = ["version"]
217+
description = "Test"
218+
219+
[build-system]
220+
requires = ["conan-py-build"]
221+
build-backend = "conan_py_build.build"
222+
223+
[tool.conan-py-build.version]
224+
provider = "setuptools_scm"
225+
226+
[tool.setuptools_scm]
227+
version_file = "src/scm_pkg/_version.py"
228+
""")
229+
_git_init_and_tag(proj, "v3.0.0")
230+
monkeypatch.chdir(proj)
231+
monkeypatch.setenv("CONAN_HOME", str(tmp_path / "conan_home"))
232+
233+
sdist_dir = tmp_path / "dist"
234+
sdist_dir.mkdir()
235+
filename = build_sdist(str(sdist_dir))
236+
assert filename == "scm-pkg-3.0.0.tar.gz"
237+
238+
with tarfile.open(sdist_dir / filename, "r:gz") as tar:
239+
names = tar.getnames()
240+
assert "scm-pkg-3.0.0/src/scm_pkg/_version.py" in names

0 commit comments

Comments
 (0)