diff --git a/.github/workflows/api-check.yml b/.github/workflows/api-check.yml index c0066353..4db2526b 100644 --- a/.github/workflows/api-check.yml +++ b/.github/workflows/api-check.yml @@ -24,7 +24,7 @@ jobs: fetch-depth: 0 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.11' @@ -32,22 +32,48 @@ jobs: run: | pip install -U pip setuptools pip install -e .[test] + pip install griffe - name: Run griffe API check id: griffe-check + continue-on-error: true run: | + echo "Running griffe API stability check..." if griffe check setuptools_scm -ssrc -f github; then echo "api_check_result=success" >> $GITHUB_OUTPUT + echo "exit_code=0" >> $GITHUB_OUTPUT else + exit_code=$? echo "api_check_result=warning" >> $GITHUB_OUTPUT - echo "API stability check detected changes but will not fail the build" >> $GITHUB_STEP_SUMMARY + echo "exit_code=$exit_code" >> $GITHUB_OUTPUT + exit $exit_code fi - name: Report API check result - if: steps.griffe-check.outputs.api_check_result == 'warning' - uses: actions/github-script@v7 + if: always() + uses: actions/github-script@v8 with: script: | - core.warning('API stability check detected breaking changes. Please review the API changes above.') - core.summary.addRaw('⚠️ API Stability Warning: Breaking changes detected in the public API') - await core.summary.write() \ No newline at end of file + const result = '${{ steps.griffe-check.outputs.api_check_result }}' + const exitCode = '${{ steps.griffe-check.outputs.exit_code }}' + + if (result === 'success') { + core.notice('API stability check passed - no breaking changes detected') + await core.summary + .addHeading('✅ API Stability Check: Passed', 2) + .addRaw('No breaking changes detected in the public API') + .write() + } else if (result === 'warning') { + core.warning(`API stability check detected breaking changes (exit code: ${exitCode}). Please review the API changes above.`) + await core.summary + .addHeading('⚠️ API Stability Warning', 2) + .addRaw('Breaking changes detected in the public API. Please review the changes reported above.') + .addRaw(`\n\nExit code: ${exitCode}`) + .write() + } else { + core.error('API stability check failed to run properly') + await core.summary + .addHeading('❌ API Stability Check: Failed', 2) + .addRaw('The griffe check failed to execute. This may indicate griffe is not installed or there was an error.') + .write() + } \ No newline at end of file diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 0fc255ca..17953d55 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -54,7 +54,7 @@ jobs: with: fetch-depth: 0 - name: Setup python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 if: matrix.python_version != 'msys2' with: python-version: ${{ matrix.python_version }} @@ -68,7 +68,7 @@ jobs: msystem: MINGW64 install: git mingw-w64-x86_64-python mingw-w64-x86_64-python-setuptools update: true - - name: Setup GnuPG + - name: Setup GnuPG and Mercurial on Windows # At present, the Windows VMs only come with the copy of GnuPG that's bundled # with Git for Windows. If we want to use this version _and_ be able to set # arbitrary GnuPG home directories, then the test would need to figure out when @@ -84,11 +84,15 @@ jobs: # Additionally, we'll explicitly set `gpg.program` to ensure Git for Windows # doesn't invoke the bundled GnuPG, otherwise we'll run into # . See also: . + # + # Windows runners no longer ship with Mercurial pre-installed, so we install + # it via Chocolatey using the 'hg' package. run: | $env:PATH = "C:\Program Files\Git\bin;C:\Windows\system32;C:\Windows;C:\Windows\System32\Wbem;C:\Windows\System32\WindowsPowerShell\v1.0\;C:\ProgramData\Chocolatey\bin" [Environment]::SetEnvironmentVariable("Path", $env:PATH, "Machine") - choco install gnupg -y --no-progress + choco install gnupg hg -y --no-progress echo "C:\Program Files (x86)\gnupg\bin" >> $env:GITHUB_PATH + echo "C:\Program Files\Mercurial\" >> $env:GITHUB_PATH git config --system gpg.program "C:\Program Files (x86)\gnupg\bin\gpg.exe" if: runner.os == 'Windows' - run: uv sync --group test --group docs --extra rich diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a2d32f12..5f66a9f8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,18 +7,17 @@ repos: - id: debug-statements - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.12.8 + rev: v0.13.3 hooks: - id: ruff-check args: [--fix, --exit-non-zero-on-fix, --show-fixes] - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.17.1 + rev: v1.18.2 hooks: - id: mypy args: [--strict] - language_version: "3.10" additional_dependencies: - types-setuptools - tokenize-rt==3.2.0 @@ -28,7 +27,7 @@ repos: - rich - repo: https://github.com/scientific-python/cookie - rev: 2025.05.02 + rev: 2025.10.01 hooks: - id: sp-repo-review diff --git a/CHANGELOG.md b/CHANGELOG.md index caf73ad3..a8d8964f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog + +## v9.2.1 + +### Fixed + +- fix #1216: accept and create a warning for usages of `version = attr:` in setuptools config. + unfortunately dozens of projects cargo-culted that antipattern + + ## v9.2.0 ### Added @@ -11,31 +20,24 @@ version inference is automatically enabled with default settings. -### removed +### Removed - unchecked simplified activation - too many projects use setups where it would fail -### changed - -- refine activation logic and add unittest for the relevant cases instead of trying to speedrun setuptools - -## v9.1.1 - -### fixed - -- fix #1194: correctly handle version keyword when pyproject metadata is missing +### Changed +- refine activation logic and add unittest for the relevant cases instead of trying to speedrun setuptools -## v9.1.1 +## v9.1.1 (yanked) -### fixed +### Fixed - fix #1194: correctly handle version keyword when pyproject metadata is missing -## v9.1.0 +## v9.1.0 (yanked) -### fixed +### Fixed - complete reiteration of the decision logic for enabling version inference on setuptools_scm @@ -47,9 +49,9 @@ ## v9.0.3 (yanked) -### fixed +### Fixed -- fix 1184: verify version is dynamic if the dependency is used as indicator for enabling +- fix #1184: verify version is dynamic if the dependency is used as indicator for enabling ## v9.0.2 (yanked) diff --git a/src/setuptools_scm/_config.py b/src/setuptools_scm/_config.py index 81a78e84..49fac2a4 100644 --- a/src/setuptools_scm/_config.py +++ b/src/setuptools_scm/_config.py @@ -310,7 +310,6 @@ def from_data( # Handle nested SCM configuration scm_config = ScmConfiguration.from_data(scm_data) - return cls( relative_to=relative_to, version_cls=version_cls, diff --git a/src/setuptools_scm/_integration/deprecation.py b/src/setuptools_scm/_integration/deprecation.py new file mode 100644 index 00000000..a1b36155 --- /dev/null +++ b/src/setuptools_scm/_integration/deprecation.py @@ -0,0 +1,20 @@ +import warnings + +from pathlib import Path + + +def warn_dynamic_version(path: Path, section: str, expression: str) -> None: + warnings.warn( + f"{path}: at [{section}]\n" + f"{expression} is forcing setuptools to override the version setuptools-scm did already set\n" + "When using setuptools-scm it's invalid to use setuptools dynamic version as well, please remove it.\n" + "Setuptools-scm is responsible for setting the version, forcing setuptools to override creates errors." + ) + + +def warn_pyproject_setuptools_dynamic_version(path: Path) -> None: + warn_dynamic_version(path, "tool.setuptools.dynamic", "version = {attr = ...}") + + +def warn_setup_cfg_dynamic_version(path: Path) -> None: + warn_dynamic_version(path, "metadata", "version = attr: ...") diff --git a/src/setuptools_scm/_integration/pyproject_reading.py b/src/setuptools_scm/_integration/pyproject_reading.py index f041484f..eb21dfa4 100644 --- a/src/setuptools_scm/_integration/pyproject_reading.py +++ b/src/setuptools_scm/_integration/pyproject_reading.py @@ -174,19 +174,23 @@ def read_pyproject( tool_name: str = DEFAULT_TOOL_NAME, canonical_build_package_name: str = "setuptools-scm", _given_result: _t.GivenPyProjectResult = None, + _given_definition: TOML_RESULT | None = None, ) -> PyProjectData: """Read and parse pyproject configuration. - This function supports dependency injection for tests via `_given_result`. - - Parameters: - - path: Path to the pyproject file - - tool_name: The tool section name (default: `setuptools_scm`) - - canonical_build_package_name: Normalized build requirement name - - _given_result: Optional testing hook. Can be: - - PyProjectData: returned directly - - InvalidTomlError | FileNotFoundError: raised directly - - None: read from filesystem + This function supports dependency injection for tests via ``_given_result`` + and ``_given_definition``. + + :param path: Path to the pyproject file + :param tool_name: The tool section name (default: ``setuptools_scm``) + :param canonical_build_package_name: Normalized build requirement name + :param _given_result: Optional testing hook. Can be: + - ``PyProjectData``: returned directly + - ``InvalidTomlError`` | ``FileNotFoundError``: raised directly + - ``None``: read from filesystem (default) + :param _given_definition: Optional testing hook to provide parsed TOML content. + When provided, this dictionary is used instead of reading and parsing + the file from disk. Ignored if ``_given_result`` is provided. """ if _given_result is not None: @@ -195,7 +199,10 @@ def read_pyproject( if isinstance(_given_result, (InvalidTomlError, FileNotFoundError)): raise _given_result - defn = read_toml_content(path) + if _given_definition is not None: + defn = _given_definition + else: + defn = read_toml_content(path) requires: list[str] = defn.get("build-system", {}).get("requires", []) is_required = has_build_package(requires, canonical_build_package_name) @@ -224,6 +231,17 @@ def read_pyproject( requires, ) + setuptools_dynamic_version = ( + defn.get("tool", {}) + .get("setuptools", {}) + .get("dynamic", {}) + .get("version", None) + ) + if setuptools_dynamic_version is not None: + from .deprecation import warn_pyproject_setuptools_dynamic_version + + warn_pyproject_setuptools_dynamic_version(path) + return pyproject_data diff --git a/src/setuptools_scm/_integration/setup_cfg.py b/src/setuptools_scm/_integration/setup_cfg.py index 4e485600..893a9ad4 100644 --- a/src/setuptools_scm/_integration/setup_cfg.py +++ b/src/setuptools_scm/_integration/setup_cfg.py @@ -25,6 +25,11 @@ def read_setup_cfg(input: str | os.PathLike[str] = "setup.cfg") -> SetuptoolsBas name = parser.get("metadata", "name", fallback=None) version = parser.get("metadata", "version", fallback=None) + if version is not None and "attr" in version: + from .deprecation import warn_setup_cfg_dynamic_version + + warn_setup_cfg_dynamic_version(path) + version = None return SetuptoolsBasicData(path=path, name=name, version=version) diff --git a/testing/test_deprecation.py b/testing/test_deprecation.py new file mode 100644 index 00000000..fa6e5aaf --- /dev/null +++ b/testing/test_deprecation.py @@ -0,0 +1,24 @@ +"""Test deprecation warnings and their exact text.""" + +from pathlib import Path + +import pytest + +from setuptools_scm._integration.deprecation import warn_dynamic_version + + +def test_warn_dynamic_version_full_text() -> None: + """Test the complete warning text for warn_dynamic_version function.""" + test_path = Path("test_file.toml") + expected_warning = ( + f"{test_path}: at [test.section]\n" + "test_expression is forcing setuptools to override the version setuptools-scm did already set\n" + "When using setuptools-scm it's invalid to use setuptools dynamic version as well, please remove it.\n" + "Setuptools-scm is responsible for setting the version, forcing setuptools to override creates errors." + ) + + with pytest.warns(UserWarning) as warning_info: # noqa: PT030 + warn_dynamic_version(test_path, "test.section", "test_expression") + + assert len(warning_info) == 1 + assert str(warning_info[0].message) == expected_warning diff --git a/testing/test_integration.py b/testing/test_integration.py index be6e3cfe..e85b5bba 100644 --- a/testing/test_integration.py +++ b/testing/test_integration.py @@ -18,6 +18,7 @@ from setuptools_scm._integration import setuptools as setuptools_integration from setuptools_scm._integration.pyproject_reading import PyProjectData from setuptools_scm._integration.setup_cfg import SetuptoolsBasicData +from setuptools_scm._integration.setup_cfg import read_setup_cfg from setuptools_scm._requirement_cls import extract_package_name if TYPE_CHECKING: @@ -457,6 +458,29 @@ def test_unicode_in_setup_cfg(tmp_path: Path) -> None: assert data.version == "1.2.3" +@pytest.mark.issue(1216) +def test_setup_cfg_dynamic_version_warns_and_ignores(tmp_path: Path) -> None: + cfg = tmp_path / "setup.cfg" + cfg.write_text( + textwrap.dedent( + """ + [metadata] + name = example-broken + version = attr: example_broken.__version__ + """ + ), + encoding="utf-8", + ) + + with pytest.warns( + UserWarning, + match=r"setup\.cfg: at \[metadata\]", + ): + legacy_data = read_setup_cfg(cfg) + + assert legacy_data.version is None + + def test_setup_cfg_version_prevents_inference_version_keyword( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/testing/test_pyproject_reading.py b/testing/test_pyproject_reading.py index 1962882a..dc26e955 100644 --- a/testing/test_pyproject_reading.py +++ b/testing/test_pyproject_reading.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path +from unittest.mock import Mock import pytest @@ -108,3 +109,39 @@ def test_invalid_requirement_string(self) -> None: assert ( has_build_package_with_extra(requires, "setuptools-scm", "simple") is False ) + + +def test_read_pyproject_with_given_definition(monkeypatch: pytest.MonkeyPatch) -> None: + """Test that read_pyproject reads existing files correctly.""" + monkeypatch.setattr( + "setuptools_scm._integration.pyproject_reading.read_toml_content", + Mock(side_effect=FileNotFoundError("this test should not read")), + ) + + res = read_pyproject( + _given_definition={ + "build-system": {"requires": ["setuptools-scm[simple]"]}, + "project": {"name": "test-package", "dynamic": ["version"]}, + } + ) + + assert res.should_infer() + + +def test_read_pyproject_with_setuptools_dynamic_version_warns() -> None: + with pytest.warns( + UserWarning, + match=r"pyproject\.toml: at \[tool\.setuptools\.dynamic\]", + ): + pyproject_data = read_pyproject( + _given_definition={ + "build-system": {"requires": ["setuptools-scm[simple]"]}, + "project": {"name": "test-package", "dynamic": ["version"]}, + "tool": { + "setuptools": { + "dynamic": {"version": {"attr": "test_package.__version__"}} + } + }, + } + ) + assert pyproject_data.project_version is None