Skip to content

Commit ad9ebc8

Browse files
authored
fix(build): derive versions without VERSION (#705)
1 parent 3b1bc27 commit ad9ebc8

10 files changed

Lines changed: 311 additions & 55 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
[package]
22
name = "pipdeptree"
3-
# Placeholder: the authoritative release version lives in the VERSION file and is wired into the
4-
# binary through build.rs (PIPDEPTREE_VERSION); this crate is never published to crates.io.
3+
# The build resolves the release version; this crate is never published to crates.io.
54
version = "0.0.0"
65
description = "Display Python package dependency trees"
76
repository = "https://github.com/tox-dev/pipdeptree"

VERSION

Lines changed: 0 additions & 1 deletion
This file was deleted.

build.rs

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,74 @@
1+
use std::env;
2+
use std::fs;
3+
use std::path::Path;
4+
use std::process::Command;
5+
16
fn main() {
2-
// Meson passes the version it derived from the git tags; a bare cargo build falls back to the VERSION file.
37
println!("cargo:rerun-if-env-changed=PIPDEPTREE_VERSION");
4-
let version = std::env::var("PIPDEPTREE_VERSION").unwrap_or_else(|_| {
5-
let version_file = format!("{}/VERSION", env!("CARGO_MANIFEST_DIR"));
6-
println!("cargo:rerun-if-changed={version_file}");
7-
std::fs::read_to_string(&version_file).expect("VERSION must be readable")
8-
});
8+
println!("cargo:rerun-if-changed=PKG-INFO");
9+
println!("cargo:rerun-if-changed=.git");
10+
let version = env::var("PIPDEPTREE_VERSION")
11+
.ok()
12+
.or_else(from_git)
13+
.or_else(from_metadata)
14+
.unwrap_or_else(|| "0.0.0".to_string());
915
println!("cargo:rustc-env=PIPDEPTREE_VERSION={}", version.trim());
10-
if std::env::var_os("CARGO_FEATURE_EXTENSION_MODULE").is_some()
11-
&& std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos")
16+
if env::var_os("CARGO_FEATURE_EXTENSION_MODULE").is_some()
17+
&& env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos")
1218
{
1319
println!("cargo:rustc-link-arg=-undefined");
1420
println!("cargo:rustc-link-arg=dynamic_lookup");
1521
}
1622
}
23+
24+
fn from_git() -> Option<String> {
25+
if !Path::new(".git").exists() {
26+
return None;
27+
}
28+
if let Some(paths) = git(&[
29+
"rev-parse",
30+
"--git-path",
31+
"HEAD",
32+
"--git-path",
33+
"refs",
34+
"--git-path",
35+
"packed-refs",
36+
]) {
37+
for path in paths.lines() {
38+
println!("cargo:rerun-if-changed={path}");
39+
}
40+
}
41+
let described = git(&["describe", "--tags", "--long", "--match", "[0-9]*"])?;
42+
let (release, commit) = described
43+
.rsplit_once('-')
44+
.expect("git describe includes a commit");
45+
let (tag, distance) = release
46+
.rsplit_once('-')
47+
.expect("git describe includes a distance");
48+
Some(if distance == "0" {
49+
tag.to_string()
50+
} else {
51+
format!("{tag}.dev{distance}+{commit}")
52+
})
53+
}
54+
55+
fn git(args: &[&str]) -> Option<String> {
56+
let output = Command::new("git").args(args).output().ok()?;
57+
output
58+
.status
59+
.success()
60+
.then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
61+
}
62+
63+
fn from_metadata() -> Option<String> {
64+
fs::read_to_string("PKG-INFO")
65+
.ok()?
66+
.lines()
67+
.take_while(|line| !line.is_empty())
68+
.find_map(|line| {
69+
line.strip_prefix("Version:")
70+
.map(str::trim)
71+
.filter(|version| !version.is_empty())
72+
.map(ToOwned::to_owned)
73+
})
74+
}

docs/changelog/699.packaging.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Remove the tracked ``VERSION`` file. Resolve build versions from ``PIPDEPTREE_VERSION``, Git tags, or sdist metadata,
2+
falling back to ``0.0.0`` when none is available.

docs/development.rst

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,23 @@ Sphinx writes ``.tox/docs_out/html/index.html``.
106106
Releasing
107107
---------
108108

109-
``tools/version.py`` derives the version from the release tags, so the tag a release carries is the version its wheels
110-
and sdist report. Nothing in the tree names the version, and the ``VERSION`` file exists for builds with no tags to
111-
read, such as one from an unpacked sdist.
109+
Builds use the following version sources, in order:
110+
111+
1. ``PIPDEPTREE_VERSION``, if set.
112+
2. A reachable Git release tag, with a development suffix for commits after the tag.
113+
3. The ``Version`` header in the source distribution's ``PKG-INFO`` metadata.
114+
4. ``0.0.0`` if none of those sources provides a version.
115+
116+
Meson-Python includes ``PKG-INFO`` in sdists, so rebuilding a wheel from an sdist needs no Git history. Bare Cargo
117+
builds use the same precedence. Fetch release tags in shallow clones to obtain the release-derived version.
118+
119+
Package maintainers building without Git history or distribution metadata can supply their own version:
120+
121+
.. code-block:: bash
122+
123+
PIPDEPTREE_VERSION=4.2.3 uv build
124+
125+
The override takes precedence over tags and metadata; the build uses the value the maintainer supplies.
112126

113127
Every user-visible change brings a news fragment under ``docs/changelog``, named ``<issue>.<type>.rst`` with one of the
114128
types ``breaking``, ``feature``, ``bugfix``, ``doc`` or ``packaging``. The unreleased fragments render as a draft

meson.build

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,6 @@ project(
88
default_options: ['python.allow_limited_api=false'],
99
)
1010

11-
# Freeze the git-derived version into the sdist so a wheel built from it needs no git history.
12-
meson.add_dist_script('tools/version.py', '--write')
13-
1411
py = import('python').find_installation(pure: false)
1512
py.install_sources(
1613
files(

rust/tests/public_api/common.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,7 @@ use tempfile::{TempDir, tempdir};
1616
static PYTHON_LOCK: Mutex<()> = Mutex::new(());
1717
static RESOLVER: Once = Once::new();
1818

19-
pub const VERSION: &str =
20-
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/VERSION")).trim_ascii_end();
19+
pub const VERSION: &str = env!("PIPDEPTREE_VERSION");
2120

2221
mockall::mock! {
2322
pub Processes {}

tests/test_build_version.py

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
from __future__ import annotations
2+
3+
import runpy
4+
import subprocess # ruff:ignore[suspicious-subprocess-import] # Integration tests need real Git and Cargo processes.
5+
from pathlib import Path
6+
from shutil import which
7+
from typing import TYPE_CHECKING, Final
8+
from unittest.mock import create_autospec
9+
10+
import pytest
11+
12+
if TYPE_CHECKING:
13+
from collections.abc import Callable
14+
15+
16+
@pytest.mark.usefixtures("repository", "package_metadata")
17+
@pytest.mark.parametrize(
18+
("release", "override", "expected"),
19+
[
20+
pytest.param("4.2.3", None, "4.2.3", id="release"),
21+
pytest.param("4.3.0rc1", None, "4.3.0rc1", id="prerelease"),
22+
pytest.param("4.2.3", "5.0.0rc1", "5.0.0rc1", id="override"),
23+
],
24+
)
25+
def test_build_version_tag(
26+
build_version: Callable[[], str],
27+
git: Callable[..., str],
28+
monkeypatch: pytest.MonkeyPatch,
29+
release: str,
30+
override: str | None,
31+
expected: str,
32+
) -> None:
33+
git("tag", release)
34+
if override is not None:
35+
monkeypatch.setenv("PIPDEPTREE_VERSION", override)
36+
37+
assert build_version() == expected
38+
39+
40+
@pytest.mark.usefixtures("repository")
41+
def test_build_version_after_tag(build_version: Callable[[], str], git: Callable[..., str]) -> None:
42+
git("tag", "4.2.3")
43+
git("commit", "--allow-empty", "-m", "After release")
44+
45+
assert build_version() == f"4.2.3.dev1+g{git('rev-parse', '--short', 'HEAD')}"
46+
47+
48+
@pytest.mark.usefixtures("repository")
49+
def test_build_version_without_release_tag(build_version: Callable[[], str], git: Callable[..., str]) -> None:
50+
git("tag", "unrelated")
51+
52+
assert build_version() == "0.0.0"
53+
54+
55+
@pytest.mark.parametrize(
56+
("metadata", "expected"),
57+
[
58+
pytest.param("Metadata-Version: 2.4\nName: pipdeptree\nVersion: 4.2.3\n\nDescription", "4.2.3", id="sdist"),
59+
pytest.param("Version: 4.2.3.dev2+g1234567\n", "4.2.3.dev2+g1234567", id="development-sdist"),
60+
pytest.param("Name: pipdeptree\n", "0.0.0", id="missing-version"),
61+
pytest.param("Version: \n", "0.0.0", id="empty-version"),
62+
pytest.param("Name: pipdeptree\n\nVersion: 9.9.9\n", "0.0.0", id="description-is-not-metadata"),
63+
pytest.param(None, "0.0.0", id="missing-metadata"),
64+
],
65+
)
66+
def test_build_version_metadata(
67+
build_version: Callable[[], str], tmp_path: Path, metadata: str | None, expected: str
68+
) -> None:
69+
if metadata is not None:
70+
(tmp_path / "PKG-INFO").write_text(metadata, encoding="utf-8")
71+
72+
assert build_version() == expected
73+
74+
75+
@pytest.mark.usefixtures("repository", "package_metadata")
76+
@pytest.mark.parametrize("git_available", [pytest.param(True, id="untagged"), pytest.param(False, id="missing-git")])
77+
def test_build_version_metadata_fallback(
78+
build_version: Callable[[], str],
79+
git: Callable[..., str],
80+
monkeypatch: pytest.MonkeyPatch,
81+
*,
82+
git_available: bool,
83+
) -> None:
84+
if not git_available:
85+
git("tag", "4.2.3")
86+
monkeypatch.setenv("PATH", "")
87+
88+
assert build_version() == "4.1.0"
89+
90+
91+
@pytest.mark.usefixtures("parent_repository", "package_metadata")
92+
def test_build_version_parent_repository(build_version: Callable[[], str]) -> None:
93+
assert build_version() == "4.1.0"
94+
95+
96+
@pytest.fixture(params=[pytest.param("meson", id="meson"), pytest.param("cargo", id="cargo")])
97+
def build_version(
98+
request: pytest.FixtureRequest,
99+
meson_version: Callable[[], str],
100+
cargo_version: Callable[[], str],
101+
monkeypatch: pytest.MonkeyPatch,
102+
) -> Callable[[], str]:
103+
monkeypatch.delenv("PIPDEPTREE_VERSION", raising=False)
104+
return {"meson": meson_version, "cargo": cargo_version}[request.param]
105+
106+
107+
@pytest.fixture
108+
def meson_version(
109+
project_root: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
110+
) -> Callable[[], str]:
111+
def resolve() -> str:
112+
with monkeypatch.context() as context:
113+
context.setattr(Path, "resolve", create_autospec(Path.resolve, return_value=tmp_path / "tools/version.py"))
114+
runpy.run_path(str(project_root / "tools/version.py"), run_name="tools.version")["main"]()
115+
return capsys.readouterr().out.strip()
116+
117+
return resolve
118+
119+
120+
@pytest.fixture(scope="session")
121+
def project_root() -> Path:
122+
return Path(__file__).resolve().parents[1]
123+
124+
125+
@pytest.fixture
126+
def cargo_version(cargo_build_script: Path, tmp_path: Path) -> Callable[[], str]:
127+
def resolve() -> str:
128+
result: Final = subprocess.run(
129+
[str(cargo_build_script)], cwd=tmp_path, capture_output=True, text=True, check=True
130+
)
131+
return next(
132+
line.split("=", 2)[2]
133+
for line in result.stdout.splitlines()
134+
if line.startswith("cargo:rustc-env=PIPDEPTREE_VERSION=")
135+
)
136+
137+
return resolve
138+
139+
140+
@pytest.fixture(scope="session")
141+
def cargo_build_script(project_root: Path, tmp_path_factory: pytest.TempPathFactory) -> Path:
142+
rustc: Final = which("rustc")
143+
assert rustc is not None
144+
executable: Final = tmp_path_factory.mktemp("build-version") / "build-version.exe"
145+
subprocess.run([rustc, "--edition=2024", str(project_root / "build.rs"), "-o", str(executable)], check=True)
146+
return executable
147+
148+
149+
@pytest.fixture(params=[pytest.param(False, id="git-directory"), pytest.param(True, id="git-file")])
150+
def repository(request: pytest.FixtureRequest, git: Callable[..., str], tmp_path: Path) -> None:
151+
if request.param:
152+
git("init", "--separate-git-dir", str(tmp_path.parent / f"{tmp_path.name}.git"))
153+
else:
154+
git("init")
155+
git("commit", "--allow-empty", "-m", "Initial commit")
156+
157+
158+
@pytest.fixture
159+
def git(tmp_path: Path) -> Callable[..., str]:
160+
executable: Final = which("git")
161+
assert executable is not None
162+
163+
def run(*args: str) -> str:
164+
return subprocess.run(
165+
[
166+
executable,
167+
"-c",
168+
"user.name=Test",
169+
"-c",
170+
"user.email=test@example.com",
171+
"-c",
172+
"commit.gpgsign=false",
173+
*args,
174+
],
175+
cwd=tmp_path,
176+
capture_output=True,
177+
text=True,
178+
check=True,
179+
).stdout.strip()
180+
181+
return run
182+
183+
184+
@pytest.fixture
185+
def package_metadata(tmp_path: Path) -> None:
186+
(tmp_path / "PKG-INFO").write_text("Version: 4.1.0\n", encoding="utf-8")
187+
188+
189+
@pytest.fixture
190+
def parent_repository(git: Callable[..., str], tmp_path: Path) -> None:
191+
git("-C", str(tmp_path.parent), "init")
192+
git("-C", str(tmp_path.parent), "commit", "--allow-empty", "-m", "Parent repository")
193+
git("-C", str(tmp_path.parent), "tag", "--force", "9.9.9")

0 commit comments

Comments
 (0)