diff --git a/docs/customization.md b/docs/customization.md index a6af38ee..83dc7502 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -215,6 +215,47 @@ implemented. A literal `$` must be quoted as `$$`. Added support for default value syntax `${NAME:-}`. ``` +```{versionchanged} 0.76.0 + +Added the `${__version__}` template variable. +``` + +#### The `__version__` variable + +When fromager resolves the version of a package it injects the version +string into the template environment as `__version__`. You can +reference it in env values with `${__version__}`. + +**When it is available:** + +`__version__` is set for `build_sdist`, `build_wheel`, and all +dependency hooks (`get_build_backend_dependencies`, +`get_build_sdist_dependencies`, etc.) that run *after* version +resolution. + +**When it is NOT available:** + +- During the `resolve` phase itself — the version has not yet been + determined. +- When bootstrapping from a **git URL whose reference is not a valid + PEP 440 version** (for example + `pkg @ git+https://host/repo.git` or + `pkg @ git+https://host/repo.git@main`). In this case fromager + must build the package metadata just to discover the version, so + the early dependency-resolution hooks run with `version=None`. + +If your env var is used in a phase where the version might be +unknown, add a fallback default so the substitution does not fail: + +```yaml +env: + # safe — falls back to empty string when version is not yet known + MY_VAR: "${__version__:-}" +``` + +Without the fallback, a bare `${__version__}` raises an error when +the version is unavailable. + ```yaml # example env: @@ -222,6 +263,10 @@ env: PATH: "/global/bin:$PATH" # default CFLAGS to empty string and append " -g" CFLAGS: "${CFLAGS:-} -g" + # use the resolved package version in a build env variable + LIB_URL: "https://github.com/org/lib/archive/v${__version__}.tar.gz" + # safe for git-URL bootstrapping where version may not yet be known + OPTIONAL_URL: "https://example.com/lib-${__version__:-latest}.tar.gz" variants: cpu: env: diff --git a/e2e/ci_bootstrap_suite.sh b/e2e/ci_bootstrap_suite.sh index deab8dd2..3d4db445 100755 --- a/e2e/ci_bootstrap_suite.sh +++ b/e2e/ci_bootstrap_suite.sh @@ -29,5 +29,6 @@ run_test "bootstrap_sdist_only" test_section "bootstrap git URL tests" run_test "bootstrap_git_url" run_test "bootstrap_git_url_tag" +run_test "version_env_git_url" finish_suite diff --git a/e2e/stevedore_override/src/package_plugins/stevedore.py b/e2e/stevedore_override/src/package_plugins/stevedore.py index 99b35186..a0909790 100644 --- a/e2e/stevedore_override/src/package_plugins/stevedore.py +++ b/e2e/stevedore_override/src/package_plugins/stevedore.py @@ -23,6 +23,11 @@ def update_extra_environ( marker = ctx.work_dir / "update_extra_environ.txt" with marker.open(encoding="utf-8", mode="a") as f: f.write(f"{version}\n") + version_var = extra_environ.get("TEST_VERSION_VAR") + if version_var is not None: + version_var_marker = ctx.work_dir / "test_version_var.txt" + with version_var_marker.open(encoding="utf-8", mode="w") as f: + f.write(f"{version_var}\n") return None diff --git a/e2e/test_pep517_build_sdist.sh b/e2e/test_pep517_build_sdist.sh index 8e61c5cf..ecaf19bb 100755 --- a/e2e/test_pep517_build_sdist.sh +++ b/e2e/test_pep517_build_sdist.sh @@ -38,18 +38,20 @@ fromager \ --wheels-repo "$OUTDIR/wheels-repo" \ step prepare-build --wheel-server-url "https://pypi.org/simple/" "$DIST" "$VERSION" -# Build an updated sdist +# Build an updated sdist (with settings that exercise ${__version__} in env) rm -rf "$OUTDIR/sdists-repo/builds" fromager \ --log-file "$OUTDIR/build-logs/${DIST}-build-sdist.log" \ --work-dir "$OUTDIR/work-dir" \ --sdists-repo "$OUTDIR/sdists-repo" \ --wheels-repo "$OUTDIR/wheels-repo" \ + --settings-dir="$SCRIPTDIR/version_env_settings" \ step build-sdist "$DIST" "$VERSION" EXPECTED_FILES=" $OUTDIR/sdists-repo/builds/stevedore-*.tar.gz $OUTDIR/work-dir/update_extra_environ.txt +$OUTDIR/work-dir/test_version_var.txt " pass=true @@ -60,6 +62,13 @@ for pattern in $EXPECTED_FILES; do fi done +# Verify ${__version__} was correctly substituted in the env var +actual_version=$(cat "$OUTDIR/work-dir/test_version_var.txt" | tr -d '[:space:]') +if [ "$actual_version" != "$VERSION" ]; then + echo "FAIL: TEST_VERSION_VAR='$actual_version', expected '$VERSION'" 1>&2 + pass=false +fi + $pass twine check $OUTDIR/sdists-repo/builds/*.tar.gz diff --git a/e2e/test_version_env_git_url.sh b/e2e/test_version_env_git_url.sh new file mode 100755 index 00000000..332245da --- /dev/null +++ b/e2e/test_version_env_git_url.sh @@ -0,0 +1,67 @@ +#!/bin/bash +# -*- indent-tabs-mode: nil; tab-width: 2; sh-indentation: 2; -*- + +# Test that ${__version__} in env settings fails when bootstrapping from +# a git URL without a PEP 440 version tag, and succeeds when a fallback +# default is provided via ${__version__:-...}. + +SCRIPTDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +source "$SCRIPTDIR/common.sh" + +GIT_REPO_URL="https://github.com/python-wheel-build/stevedore-test-repo.git" + +pass=true + +# --- Part 1: ${__version__} WITHOUT default should fail --- + +echo "=== Part 1: expect failure with \${__version__} (no default) ===" + +if fromager \ + --log-file="$OUTDIR/bootstrap-no-default.log" \ + --error-log-file="$OUTDIR/fromager-errors-no-default.log" \ + --sdists-repo="$OUTDIR/sdists-repo" \ + --wheels-repo="$OUTDIR/wheels-repo" \ + --work-dir="$OUTDIR/work-dir" \ + --settings-dir="$SCRIPTDIR/version_env_settings_no_default" \ + bootstrap "stevedore @ git+${GIT_REPO_URL}" 2>&1; then + echo "FAIL: bootstrap with \${__version__} (no default) should have failed" 1>&2 + pass=false +else + echo "OK: bootstrap with \${__version__} (no default) failed as expected" + if grep -q "__version__" "$OUTDIR/fromager-errors-no-default.log" 2>/dev/null || \ + grep -q "__version__" "$OUTDIR/bootstrap-no-default.log" 2>/dev/null; then + echo "OK: error message mentions __version__" + else + echo "WARN: error log does not mention __version__; check logs manually" + fi +fi + +# --- Part 2: ${__version__:-unresolved} WITH default should succeed --- + +echo "=== Part 2: expect success with \${__version__:-unresolved} ===" + +rm -rf "$OUTDIR/work-dir" "$OUTDIR/sdists-repo" "$OUTDIR/wheels-repo" +mkdir -p "$OUTDIR/build-logs" + +fromager \ + --log-file="$OUTDIR/bootstrap-with-default.log" \ + --error-log-file="$OUTDIR/fromager-errors-with-default.log" \ + --sdists-repo="$OUTDIR/sdists-repo" \ + --wheels-repo="$OUTDIR/wheels-repo" \ + --work-dir="$OUTDIR/work-dir" \ + --settings-dir="$SCRIPTDIR/version_env_settings_with_default" \ + bootstrap "stevedore @ git+${GIT_REPO_URL}" + +EXPECTED_FILES=" +$OUTDIR/wheels-repo/downloads/stevedore-*.whl +$OUTDIR/sdists-repo/builds/stevedore-*.tar.gz +" + +for pattern in $EXPECTED_FILES; do + if [ ! -f "${pattern}" ]; then + echo "FAIL: Did not find $pattern" 1>&2 + pass=false + fi +done + +$pass diff --git a/e2e/version_env_settings/stevedore.yaml b/e2e/version_env_settings/stevedore.yaml new file mode 100644 index 00000000..614b4b2a --- /dev/null +++ b/e2e/version_env_settings/stevedore.yaml @@ -0,0 +1,2 @@ +env: + TEST_VERSION_VAR: "${__version__}" diff --git a/e2e/version_env_settings_no_default/stevedore.yaml b/e2e/version_env_settings_no_default/stevedore.yaml new file mode 100644 index 00000000..614b4b2a --- /dev/null +++ b/e2e/version_env_settings_no_default/stevedore.yaml @@ -0,0 +1,2 @@ +env: + TEST_VERSION_VAR: "${__version__}" diff --git a/e2e/version_env_settings_with_default/stevedore.yaml b/e2e/version_env_settings_with_default/stevedore.yaml new file mode 100644 index 00000000..896a8d38 --- /dev/null +++ b/e2e/version_env_settings_with_default/stevedore.yaml @@ -0,0 +1,2 @@ +env: + TEST_VERSION_VAR: "${__version__:-unresolved}" diff --git a/src/fromager/packagesettings.py b/src/fromager/packagesettings.py index 1ab9abc4..22e1fa7c 100644 --- a/src/fromager/packagesettings.py +++ b/src/fromager/packagesettings.py @@ -899,6 +899,13 @@ def get_extra_environ( else: template_env = template_env.copy() + if version is not None: + template_env["__version__"] = str(version) + else: + # Prevent a stray __version__ in os.environ from being + # silently used when the real version is unknown. + template_env.pop("__version__", None) + # configure max jobs settings, settings depend on package, available # CPU cores, and available virtual memory. jobs = self.parallel_jobs() diff --git a/tests/test_packagesettings.py b/tests/test_packagesettings.py index 07bee01a..7bf2f5d5 100644 --- a/tests/test_packagesettings.py +++ b/tests/test_packagesettings.py @@ -15,8 +15,10 @@ EnvVars, GitOptions, Package, + PackageBuildInfo, PackageSettings, ResolverDist, + Settings, SettingsFile, Variant, substitute_template, @@ -62,6 +64,7 @@ "QUOTES": "A\"BC'$$EGG", "DEF": "${DEF:-default}", "EXTRA_MAX_JOBS": "${MAX_JOBS}", + "MY_VERSION": "${__version__}", }, "git_options": { "submodules": False, @@ -239,9 +242,15 @@ def test_pbi_test_pkg_extra_environ( "EXTRA_MAX_JOBS": "1", } + version = Version("1.0.0") + version_env = { + "MY_VERSION": "1.0.0", + } + pbi = testdata_context.settings.package_build_info(TEST_PKG) + result = pbi.get_extra_environ(template_env={"EXTRA": "extra"}, version=version) assert ( - pbi.get_extra_environ(template_env={"EXTRA": "extra"}) + result == { "EGG": "spam spam", "EGG_AGAIN": "spam spam", @@ -249,10 +258,16 @@ def test_pbi_test_pkg_extra_environ( "SPAM": "alot extra", "DEF": "default", } + | version_env | parallel ) + assert "__version__" not in result + + result = pbi.get_extra_environ( + template_env={"EXTRA": "extra", "DEF": "nondefault"}, version=version + ) assert ( - pbi.get_extra_environ(template_env={"EXTRA": "extra", "DEF": "nondefault"}) + result == { "EGG": "spam spam", "EGG_AGAIN": "spam spam", @@ -260,13 +275,16 @@ def test_pbi_test_pkg_extra_environ( "SPAM": "alot extra", "DEF": "nondefault", } + | version_env | parallel ) + assert "__version__" not in result testdata_context.settings.variant = Variant("rocm") pbi = testdata_context.settings.package_build_info(TEST_PKG) + result = pbi.get_extra_environ(template_env={"EXTRA": "extra"}, version=version) assert ( - pbi.get_extra_environ(template_env={"EXTRA": "extra"}) + result == { "EGG": "spam", "EGG_AGAIN": "spam", @@ -274,13 +292,16 @@ def test_pbi_test_pkg_extra_environ( "SPAM": "", "DEF": "default", } + | version_env | parallel ) + assert "__version__" not in result testdata_context.settings.variant = Variant("cuda") pbi = testdata_context.settings.package_build_info(TEST_PKG) + result = pbi.get_extra_environ(template_env={"EXTRA": "spam"}, version=version) assert ( - pbi.get_extra_environ(template_env={"EXTRA": "spam"}) + result == { "EGG": "spam", "EGG_AGAIN": "spam", @@ -288,15 +309,19 @@ def test_pbi_test_pkg_extra_environ( "SPAM": "alot spam", "DEF": "default", } + | version_env | parallel ) + assert "__version__" not in result build_env = build_environment.BuildEnvironment( testdata_context, parent_dir=tmp_path, ) result = pbi.get_extra_environ( - template_env={"EXTRA": "spam", "PATH": "/sbin:/bin"}, build_env=build_env + template_env={"EXTRA": "spam", "PATH": "/sbin:/bin"}, + build_env=build_env, + version=version, ) assert ( result @@ -314,8 +339,10 @@ def test_pbi_test_pkg_extra_environ( "UV_PYTHON": str(build_env.python), "UV_PYTHON_DOWNLOADS": "never", } + | version_env | parallel ) + assert "__version__" not in result def test_pbi_test_pkg(testdata_context: context.WorkContext) -> None: @@ -805,3 +832,67 @@ def test_use_pypi_org_metadata(testdata_context: context.WorkContext) -> None: "somepackage_without_customization" ) assert pbi.use_pypi_org_metadata + + +def _make_pbi(env_yaml: str, tmp_path: pathlib.Path) -> PackageBuildInfo: + """Create a PackageBuildInfo from inline env YAML.""" + ps = PackageSettings.from_string("version-test-pkg", env_yaml) + settings = Settings( + settings=SettingsFile(), + package_settings=[ps], + variant="cpu", + patches_dir=tmp_path, + max_jobs=1, + ) + return settings.package_build_info("version-test-pkg") + + +def test_version_env_var_raises_when_version_unknown( + tmp_path: pathlib.Path, +) -> None: + """Using ${__version__} in env without a fallback raises when version is None. + + This mirrors the git-URL bootstrap path where the version has not yet + been resolved (e.g. ``pkg @ git+https://host/repo.git@main``). + """ + pbi = _make_pbi( + """ +env: + MY_VERSION: "${__version__}" +""", + tmp_path, + ) + with pytest.raises(ValueError, match="__version__"): + pbi.get_extra_environ(template_env={}, version=None) + + +def test_version_env_var_with_default_when_version_unknown( + tmp_path: pathlib.Path, +) -> None: + """${__version__:-fallback} substitutes the default when version is None.""" + pbi = _make_pbi( + """ +env: + MY_VERSION: "${__version__:-unresolved}" +""", + tmp_path, + ) + result = pbi.get_extra_environ(template_env={}, version=None) + assert result["MY_VERSION"] == "unresolved" + assert "__version__" not in result + + +def test_version_none_no_reference( + tmp_path: pathlib.Path, +) -> None: + """version=None works when no env vars reference __version__.""" + pbi = _make_pbi( + """ +env: + FOO: "bar" +""", + tmp_path, + ) + result = pbi.get_extra_environ(template_env={}, version=None) + assert result["FOO"] == "bar" + assert "__version__" not in result diff --git a/tests/testdata/context/overrides/settings/test_pkg.yaml b/tests/testdata/context/overrides/settings/test_pkg.yaml index b5a5fd89..a1d11352 100644 --- a/tests/testdata/context/overrides/settings/test_pkg.yaml +++ b/tests/testdata/context/overrides/settings/test_pkg.yaml @@ -24,6 +24,7 @@ env: QUOTES: "A\"BC'$$EGG" DEF: "${DEF:-default}" EXTRA_MAX_JOBS: "${MAX_JOBS}" + MY_VERSION: "${__version__}" download_source: url: https://egg.test/${canonicalized_name}/v${version}.tar.gz destination_filename: ${canonicalized_name}-${version}.tar.gz