diff --git a/MANIFEST.in b/MANIFEST.in index 6b9e3204..b793e6c0 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -18,8 +18,10 @@ include CHANGELOG.md recursive-include testing *.bash prune nextgen +prune .cursor recursive-include docs *.md include docs/examples/version_scheme_code/*.py include docs/examples/version_scheme_code/*.toml include mkdocs.yml +include uv.lock \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index a8345310..78b8f437 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,11 +48,8 @@ dependencies = [ 'typing-extensions; python_version < "3.10"', ] [project.optional-dependencies] -rich = [ - "rich", -] -toml = [ -] +rich = ["rich"] +toml = [] [dependency-groups] docs = [ diff --git a/src/setuptools_scm/_config.py b/src/setuptools_scm/_config.py index fbc53636..7c1d185a 100644 --- a/src/setuptools_scm/_config.py +++ b/src/setuptools_scm/_config.py @@ -291,24 +291,12 @@ def from_file( - **kwargs: additional keyword arguments to pass to the Configuration constructor """ - try: - if pyproject_data is None: - pyproject_data = _read_pyproject( - Path(name), missing_section_ok=missing_section_ok - ) - except FileNotFoundError: - if missing_file_ok: - log.warning("File %s not found, using empty configuration", name) - pyproject_data = PyProjectData( - path=Path(name), - tool_name="setuptools_scm", - project={}, - section={}, - is_required=False, - section_present=False, - ) - else: - raise + if pyproject_data is None: + pyproject_data = _read_pyproject( + Path(name), + missing_section_ok=missing_section_ok, + missing_file_ok=missing_file_ok, + ) args = _get_args_for_pyproject(pyproject_data, dist_name, kwargs) args.update(read_toml_overrides(args["dist_name"])) diff --git a/src/setuptools_scm/_integration/pyproject_reading.py b/src/setuptools_scm/_integration/pyproject_reading.py index d53778c2..df5d30c8 100644 --- a/src/setuptools_scm/_integration/pyproject_reading.py +++ b/src/setuptools_scm/_integration/pyproject_reading.py @@ -2,12 +2,12 @@ import warnings +from dataclasses import dataclass from pathlib import Path -from typing import NamedTuple from typing import Sequence from .. import _log -from .setuptools import read_dist_name_from_setup_cfg +from .._requirement_cls import extract_package_name from .toml import TOML_RESULT from .toml import read_toml_content @@ -16,13 +16,39 @@ _ROOT = "root" -class PyProjectData(NamedTuple): +@dataclass +class PyProjectData: path: Path tool_name: str project: TOML_RESULT section: TOML_RESULT is_required: bool section_present: bool + project_present: bool + + @classmethod + def for_testing( + cls, + is_required: bool = False, + section_present: bool = False, + project_present: bool = False, + project_name: str | None = None, + ) -> PyProjectData: + """Create a PyProjectData instance for testing purposes.""" + if project_name is not None: + project = {"name": project_name} + assert project_present + else: + project = {} + return cls( + path=Path("pyproject.toml"), + tool_name="setuptools_scm", + project=project, + section={}, + is_required=is_required, + section_present=section_present, + project_present=project_present, + ) @property def project_name(self) -> str | None: @@ -33,6 +59,10 @@ def verify_dynamic_version_when_required(self) -> None: if self.is_required and not self.section_present: # When setuptools-scm is in build-system.requires but no tool section exists, # we need to verify that dynamic=['version'] is set in the project section + # But only if there's actually a project section + if not self.project_present: + # No project section, so don't auto-activate setuptools_scm + return dynamic = self.project.get("dynamic", []) if "version" not in dynamic: raise ValueError( @@ -43,16 +73,11 @@ def verify_dynamic_version_when_required(self) -> None: def has_build_package( - requires: Sequence[str], build_package_names: Sequence[str] + requires: Sequence[str], canonical_build_package_name: str ) -> bool: for requirement in requires: - import re - - # Remove extras like [toml] first - clean_req = re.sub(r"\[.*?\]", "", requirement) - # Split on version operators and take first part - package_name = re.split(r"[><=!~]", clean_req)[0].strip().lower() - if package_name in build_package_names: + package_name = extract_package_name(requirement) + if package_name == canonical_build_package_name: return True return False @@ -60,12 +85,29 @@ def has_build_package( def read_pyproject( path: Path = Path("pyproject.toml"), tool_name: str = "setuptools_scm", - build_package_names: Sequence[str] = ("setuptools_scm", "setuptools-scm"), + canonical_build_package_name: str = "setuptools-scm", missing_section_ok: bool = False, + missing_file_ok: bool = False, ) -> PyProjectData: - defn = read_toml_content(path) + try: + defn = read_toml_content(path) + except FileNotFoundError: + if missing_file_ok: + log.warning("File %s not found, using empty configuration", path) + return PyProjectData( + path=path, + tool_name=tool_name, + project={}, + section={}, + is_required=False, + section_present=False, + project_present=False, + ) + else: + raise + requires: list[str] = defn.get("build-system", {}).get("requires", []) - is_required = has_build_package(requires, build_package_names) + is_required = has_build_package(requires, canonical_build_package_name) try: section = defn.get("tool", {})[tool_name] @@ -87,8 +129,9 @@ def read_pyproject( section_present = False project = defn.get("project", {}) + project_present = "project" in defn pyproject_data = PyProjectData( - path, tool_name, project, section, is_required, section_present + path, tool_name, project, section, is_required, section_present, project_present ) # Verify dynamic version when setuptools-scm is used as build dependency indicator @@ -121,8 +164,6 @@ def get_args_for_pyproject( if dist_name is None: # minimal pep 621 support for figuring the pretend keys dist_name = pyproject.project_name - if dist_name is None: - dist_name = read_dist_name_from_setup_cfg() if _ROOT in kwargs: if kwargs[_ROOT] is None: kwargs.pop(_ROOT, None) diff --git a/src/setuptools_scm/_integration/setup_cfg.py b/src/setuptools_scm/_integration/setup_cfg.py new file mode 100644 index 00000000..e904d7d1 --- /dev/null +++ b/src/setuptools_scm/_integration/setup_cfg.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import os + +import setuptools + + +def read_dist_name_from_setup_cfg( + input: str | os.PathLike[str] = "setup.cfg", +) -> str | None: + # minimal effort to read dist_name off setup.cfg metadata + import configparser + + parser = configparser.ConfigParser() + parser.read([input], encoding="utf-8") + dist_name = parser.get("metadata", "name", fallback=None) + return dist_name + + +def _dist_name_from_legacy(dist: setuptools.Distribution) -> str | None: + return dist.metadata.name or read_dist_name_from_setup_cfg() diff --git a/src/setuptools_scm/_integration/setuptools.py b/src/setuptools_scm/_integration/setuptools.py index 565b5f42..6a98656a 100644 --- a/src/setuptools_scm/_integration/setuptools.py +++ b/src/setuptools_scm/_integration/setuptools.py @@ -1,32 +1,20 @@ from __future__ import annotations import logging -import os import warnings -from pathlib import Path from typing import Any from typing import Callable import setuptools -from .. import _config +from .pyproject_reading import read_pyproject +from .setup_cfg import _dist_name_from_legacy +from .version_inference import get_version_inference_config log = logging.getLogger(__name__) -def read_dist_name_from_setup_cfg( - input: str | os.PathLike[str] = "setup.cfg", -) -> str | None: - # minimal effort to read dist_name off setup.cfg metadata - import configparser - - parser = configparser.ConfigParser() - parser.read([input], encoding="utf-8") - dist_name = parser.get("metadata", "name", fallback=None) - return dist_name - - def _warn_on_old_setuptools(_version: str = setuptools.__version__) -> None: if int(_version.split(".")[0]) < 61: warnings.warn( @@ -46,46 +34,24 @@ def _warn_on_old_setuptools(_version: str = setuptools.__version__) -> None: ) -def _extract_package_name(requirement: str) -> str: - """Extract the package name from a requirement string. - - Examples: - 'setuptools_scm' -> 'setuptools_scm' - 'setuptools-scm>=8' -> 'setuptools-scm' - 'setuptools_scm[toml]>=7.0' -> 'setuptools_scm' - """ - # Split on common requirement operators and take the first part - # This handles: >=, <=, ==, !=, >, <, ~= - import re - - # Remove extras like [toml] first - requirement = re.sub(r"\[.*?\]", "", requirement) - # Split on version operators - package_name = re.split(r"[><=!~]", requirement)[0].strip() - return package_name +_warn_on_old_setuptools() -def _assign_version( - dist: setuptools.Distribution, config: _config.Configuration -) -> None: - from .._get_version_impl import _get_version - from .._get_version_impl import _version_missing +def _log_hookstart(hook: str, dist: setuptools.Distribution) -> None: + log.debug("%s %s %s %r", hook, id(dist), id(dist.metadata), vars(dist.metadata)) - # todo: build time plugin - maybe_version = _get_version(config, force_write_version_files=True) - if maybe_version is None: - _version_missing(config) +def get_keyword_overrides( + value: bool | dict[str, Any] | Callable[[], dict[str, Any]], +) -> dict[str, Any]: + """normalize the version keyword input""" + if value is True: + return {} + elif callable(value): + return value() else: - assert dist.metadata.version is None - dist.metadata.version = maybe_version - - -_warn_on_old_setuptools() - - -def _log_hookstart(hook: str, dist: setuptools.Distribution) -> None: - log.debug("%s %r", hook, vars(dist.metadata)) + assert isinstance(value, dict), "version_keyword expects a dict or True" + return value def version_keyword( @@ -93,85 +59,65 @@ def version_keyword( keyword: str, value: bool | dict[str, Any] | Callable[[], dict[str, Any]], ) -> None: - overrides: dict[str, Any] - if value is True: - overrides = {} - elif callable(value): - overrides = value() - else: - assert isinstance(value, dict), "version_keyword expects a dict or True" - overrides = value + """apply version infernce when setup(use_scm_version=...) is used + this takes priority over the finalize_options based version + """ + + _log_hookstart("version_keyword", dist) + + # Parse overrides (integration point responsibility) + overrides = get_keyword_overrides(value) assert "dist_name" not in overrides, ( "dist_name may not be specified in the setup keyword " ) - dist_name: str | None = dist.metadata.name - _log_hookstart("version_keyword", dist) - if dist.metadata.version is not None: - # Check if version was set by infer_version - was_set_by_infer = getattr(dist, "_setuptools_scm_version_set_by_infer", False) - - if was_set_by_infer: - # Version was set by infer_version, check if we have overrides - if not overrides: - # No overrides, just use the infer_version result - return - # We have overrides, clear the marker and proceed to override the version - dist._setuptools_scm_version_set_by_infer = False # type: ignore[attr-defined] - dist.metadata.version = None - else: - # Version was set by something else, warn and return - warnings.warn(f"version of {dist_name} already set") - return - - if dist_name is None: - dist_name = read_dist_name_from_setup_cfg() - - config = _config.Configuration.from_file( + dist_name: str | None = _dist_name_from_legacy(dist) + + was_set_by_infer = getattr(dist, "_setuptools_scm_version_set_by_infer", False) + + # Get pyproject data + try: + pyproject_data = read_pyproject(missing_section_ok=True, missing_file_ok=True) + except (LookupError, ValueError) as e: + log.debug("Configuration issue in pyproject.toml: %s", e) + return + + result = get_version_inference_config( dist_name=dist_name, - missing_file_ok=True, - missing_section_ok=True, - **overrides, + current_version=dist.metadata.version, + pyproject_data=pyproject_data, + overrides=overrides, + was_set_by_infer=was_set_by_infer, ) - _assign_version(dist, config) + + result.apply(dist) def infer_version(dist: setuptools.Distribution) -> None: + """apply version inference from the finalize_options hook + this is the default for pyproject.toml based projects that don't use the use_scm_version keyword + + if the version keyword is used, it will override the version from this hook + as user might have passed custom code version schemes + """ + _log_hookstart("infer_version", dist) - log.debug("dist %s %s", id(dist), id(dist.metadata)) - if dist.metadata.version is not None: - return # metadata already added by hook - dist_name = dist.metadata.name - if dist_name is None: - dist_name = read_dist_name_from_setup_cfg() - if not os.path.isfile("pyproject.toml"): - return - if dist_name == "setuptools-scm": - return - # Check if setuptools-scm is configured before proceeding + dist_name = _dist_name_from_legacy(dist) + try: - from .pyproject_reading import read_pyproject - - pyproject_data = read_pyproject(Path("pyproject.toml"), missing_section_ok=True) - # Only proceed if setuptools-scm is either in build_requires or has a tool section - if not pyproject_data.is_required and not pyproject_data.section_present: - return # No setuptools-scm configuration, silently return - except (FileNotFoundError, LookupError): - return # No pyproject.toml or other issues, silently return - except ValueError as e: - # Log the error as debug info instead of raising it + pyproject_data = read_pyproject(missing_section_ok=True) + except FileNotFoundError: + log.debug("pyproject.toml not found, skipping infer_version") + return + except (LookupError, ValueError) as e: log.debug("Configuration issue in pyproject.toml: %s", e) - return # Configuration issue, silently return + return - try: - config = _config.Configuration.from_file( - dist_name=dist_name, pyproject_data=pyproject_data - ) - except LookupError as e: - log.info(e, exc_info=True) - else: - _assign_version(dist, config) - # Mark that this version was set by infer_version - dist._setuptools_scm_version_set_by_infer = True # type: ignore[attr-defined] + result = get_version_inference_config( + dist_name=dist_name, + current_version=dist.metadata.version, + pyproject_data=pyproject_data, + ) + result.apply(dist) diff --git a/src/setuptools_scm/_integration/version_inference.py b/src/setuptools_scm/_integration/version_inference.py new file mode 100644 index 00000000..5fd4991e --- /dev/null +++ b/src/setuptools_scm/_integration/version_inference.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING +from typing import Any +from typing import Union + +from .. import _log + +if TYPE_CHECKING: + from .pyproject_reading import PyProjectData + +log = _log.log.getChild("version_inference") + + +@dataclass +class VersionInferenceConfig: + """Configuration for version inference.""" + + dist_name: str | None + pyproject_data: PyProjectData | None + overrides: dict[str, Any] | None + + def apply(self, dist: Any) -> None: + """Apply version inference to the distribution.""" + from .. import _config as _config_module + from .._get_version_impl import _get_version + from .._get_version_impl import _version_missing + + # Clear version if it was set by infer_version (overrides is None means infer_version context) + # OR if we have overrides (version_keyword context) and the version was set by infer_version + was_set_by_infer = getattr(dist, "_setuptools_scm_version_set_by_infer", False) + if was_set_by_infer and (self.overrides is None or self.overrides): + dist._setuptools_scm_version_set_by_infer = False + dist.metadata.version = None + + config = _config_module.Configuration.from_file( + dist_name=self.dist_name, + pyproject_data=self.pyproject_data, + missing_file_ok=True, + missing_section_ok=True, + **(self.overrides or {}), + ) + + # Get and assign version + maybe_version = _get_version(config, force_write_version_files=True) + if maybe_version is None: + _version_missing(config) + else: + assert dist.metadata.version is None + dist.metadata.version = maybe_version + + # Mark that this version was set by infer_version if overrides is None (infer_version context) + if self.overrides is None: + dist._setuptools_scm_version_set_by_infer = True + + +@dataclass +class VersionInferenceError: + """Error message for user.""" + + message: str + should_warn: bool = False + + def apply(self, dist: Any) -> None: + """Apply error handling to the distribution.""" + import warnings + + if self.should_warn: + warnings.warn(self.message) + + +@dataclass +class VersionInferenceException: + """Exception that should be raised.""" + + exception: Exception + + def apply(self, dist: Any) -> None: + """Apply exception handling to the distribution.""" + raise self.exception + + +class VersionInferenceNoOp: + """No operation result - silent skip.""" + + def apply(self, dist: Any) -> None: + """Apply no-op to the distribution.""" + + +VersionInferenceResult = Union[ + VersionInferenceConfig, # Proceed with inference + VersionInferenceError, # Show error/warning + VersionInferenceException, # Raise exception + VersionInferenceNoOp, # Don't infer (silent) +] + + +def get_version_inference_config( + dist_name: str | None, + current_version: str | None, + pyproject_data: PyProjectData, + overrides: dict[str, Any] | None = None, + was_set_by_infer: bool = False, +) -> VersionInferenceResult: + """ + Determine whether and how to perform version inference. + + Args: + dist_name: The distribution name + current_version: Current version if any + pyproject_data: PyProjectData from parser (None if file doesn't exist) + overrides: Override configuration (None for no overrides) + was_set_by_infer: Whether current version was set by infer_version + + Returns: + VersionInferenceResult with the decision and configuration + """ + if dist_name is None: + dist_name = pyproject_data.project_name + + # Handle version already set + if current_version is not None: + if was_set_by_infer: + if overrides is not None and overrides: + # Clear version and proceed with actual overrides (non-empty dict) + return VersionInferenceConfig( + dist_name=dist_name, + pyproject_data=pyproject_data, + overrides=overrides, + ) + else: + # Keep existing version from infer_version (no overrides or empty overrides) + # But allow re-inferring if this is another infer_version call + if overrides is None: + # This is another infer_version call, allow it to proceed + return VersionInferenceConfig( + dist_name=dist_name, + pyproject_data=pyproject_data, + overrides=overrides, + ) + else: + # This is version_keyword with empty overrides, keep existing version + return VersionInferenceNoOp() + else: + # Version set by something else + return VersionInferenceError( + f"version of {dist_name} already set", should_warn=True + ) + + # Handle setuptools-scm package + if dist_name == "setuptools-scm": + return VersionInferenceNoOp() + + # Handle missing configuration + if not pyproject_data.is_required and not pyproject_data.section_present: + # If there are overrides, proceed with inference (explicit use_scm_version) + if overrides is not None: + return VersionInferenceConfig( + dist_name=dist_name, + pyproject_data=pyproject_data, + overrides=overrides, + ) + return VersionInferenceNoOp() + + # Handle missing project section when required + if ( + pyproject_data.is_required + and not pyproject_data.section_present + and not pyproject_data.project_present + ): + return VersionInferenceNoOp() + + # All conditions met - proceed with inference + return VersionInferenceConfig( + dist_name=dist_name, + pyproject_data=pyproject_data, + overrides=overrides, + ) diff --git a/src/setuptools_scm/_requirement_cls.py b/src/setuptools_scm/_requirement_cls.py new file mode 100644 index 00000000..810e91fa --- /dev/null +++ b/src/setuptools_scm/_requirement_cls.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +try: + from packaging.requirements import Requirement + from packaging.utils import canonicalize_name +except ImportError: + from setuptools.extern.packaging.requirements import ( # type: ignore[import-not-found,no-redef] + Requirement as Requirement, + ) + from setuptools.extern.packaging.utils import ( # type: ignore[import-not-found,no-redef] + canonicalize_name as canonicalize_name, + ) + +from . import _log + +log = _log.log.getChild("requirement_cls") + + +def extract_package_name(requirement_string: str) -> str: + """Extract the canonical package name from a requirement string. + + This function uses packaging.requirements.Requirement to properly parse + the requirement and extract the package name, handling all edge cases + that the custom regex-based approach might miss. + + Args: + requirement_string: The requirement string to parse + + Returns: + The package name as a string + """ + return canonicalize_name(Requirement(requirement_string).name) diff --git a/testing/test_basic_api.py b/testing/test_basic_api.py index ca0c3041..7847b352 100644 --- a/testing/test_basic_api.py +++ b/testing/test_basic_api.py @@ -106,7 +106,7 @@ def test_fallback(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: def test_empty_pretend_version(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("SETUPTOOLS_SCM_DEBUG") + # monkeypatch.delenv("SETUPTOOLS_SCM_DEBUG") monkeypatch.setenv("SETUPTOOLS_SCM_PRETEND_VERSION", "") p = tmp_path / "sub/package" p.mkdir(parents=True) diff --git a/testing/test_better_root_errors.py b/testing/test_better_root_errors.py index 31d7733d..0ba964cc 100644 --- a/testing/test_better_root_errors.py +++ b/testing/test_better_root_errors.py @@ -138,7 +138,7 @@ def test_version_missing_with_relative_to_set(wd: WorkDir) -> None: # Create a dummy file to use as relative_to dummy_file = subdir / "setup.py" - dummy_file.write_text("# dummy file") + dummy_file.write_text("# dummy file", encoding="utf-8") # Test error message when relative_to IS set config = Configuration(root=str(subdir), relative_to=str(dummy_file)) diff --git a/testing/test_git.py b/testing/test_git.py index ab105dd1..31cac7a3 100644 --- a/testing/test_git.py +++ b/testing/test_git.py @@ -666,7 +666,7 @@ def test_fail_on_missing_submodules_with_initialized_submodules(wd: WorkDir) -> # Create a commit in the submodule test_file = submodule_dir / "test.txt" - test_file.write_text("test content") + test_file.write_text("test content", encoding="utf-8") wd(["git", "-C", str(submodule_dir), "add", "test.txt"]) wd(["git", "-C", str(submodule_dir), "commit", "-m", "Initial commit"]) @@ -741,7 +741,7 @@ def test_nested_scm_git_config_from_toml(tmp_path: Path) -> None: [tool.setuptools_scm.scm.git] pre_parse = "fail_on_missing_submodules" """ - pyproject_path.write_text(pyproject_content) + pyproject_path.write_text(pyproject_content, encoding="utf-8") # Parse the configuration from file config = Configuration.from_file(pyproject_path) diff --git a/testing/test_integration.py b/testing/test_integration.py index 0f100c1e..89dc9cda 100644 --- a/testing/test_integration.py +++ b/testing/test_integration.py @@ -16,13 +16,13 @@ from packaging.version import Version -import setuptools_scm._integration.setuptools +from setuptools_scm._integration import setuptools as setuptools_integration +from setuptools_scm._requirement_cls import extract_package_name if TYPE_CHECKING: import setuptools from setuptools_scm import Configuration -from setuptools_scm._integration.setuptools import _extract_package_name from setuptools_scm._integration.setuptools import _warn_on_old_setuptools from setuptools_scm._overrides import PRETEND_KEY from setuptools_scm._overrides import PRETEND_KEY_NAMED @@ -71,10 +71,10 @@ def test_pyproject_support(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> N PYPROJECT_FILES = { - "setup.py": "[tool.setuptools_scm]", - "setup.cfg": "[tool.setuptools_scm]", + "setup.py": "[tool.setuptools_scm]\n", + "setup.cfg": "[tool.setuptools_scm]\n", "pyproject tool.setuptools_scm": ( - "[tool.setuptools_scm]\ndist_name='setuptools_scm_example'" + "[project]\nname='setuptools_scm_example'\n[tool.setuptools_scm]" ), "pyproject.project": ( "[project]\nname='setuptools_scm_example'\n" @@ -109,11 +109,116 @@ def test_pyproject_support(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> N def test_pyproject_support_with_git(wd: WorkDir, metadata_in: str) -> None: if sys.version_info < (3, 11): pytest.importorskip("tomli") - wd.write("pyproject.toml", PYPROJECT_FILES[metadata_in]) + + # Write files first + if metadata_in == "pyproject tool.setuptools_scm": + wd.write( + "pyproject.toml", + textwrap.dedent( + """ + [build-system] + requires = ["setuptools>=80", "setuptools-scm>=8"] + build-backend = "setuptools.build_meta" + + [tool.setuptools_scm] + dist_name='setuptools_scm_example' + """ + ), + ) + elif metadata_in == "pyproject.project": + wd.write( + "pyproject.toml", + textwrap.dedent( + """ + [build-system] + requires = ["setuptools>=80", "setuptools-scm>=8"] + build-backend = "setuptools.build_meta" + + [project] + name='setuptools_scm_example' + dynamic=['version'] + [tool.setuptools_scm] + """ + ), + ) + else: + # For "setup.py" and "setup.cfg" cases, use the PYPROJECT_FILES content + wd.write("pyproject.toml", PYPROJECT_FILES[metadata_in]) + wd.write("setup.py", SETUP_PY_FILES[metadata_in]) wd.write("setup.cfg", SETUP_CFG_FILES[metadata_in]) - res = wd([sys.executable, "setup.py", "--version"]) - assert res.endswith("0.1.dev0+d20090213") + + # Now do git operations + wd("git init") + wd("git config user.email test@example.com") + wd('git config user.name "a test"') + wd("git add .") + wd('git commit -m "initial"') + wd("git tag v1.0.0") + + res = run([sys.executable, "setup.py", "--version"], wd.cwd) + assert res.stdout == "1.0.0" + + +def test_pyproject_no_project_section_no_auto_activation(wd: WorkDir) -> None: + """Test that setuptools_scm doesn't auto-activate when pyproject.toml has no project section.""" + if sys.version_info < (3, 11): + pytest.importorskip("tomli") + + # Create pyproject.toml with setuptools-scm in build-system.requires but no project section + wd.write( + "pyproject.toml", + textwrap.dedent( + """ + [build-system] + requires = ["setuptools>=80", "setuptools-scm>=8"] + build-backend = "setuptools.build_meta" + """ + ), + ) + + wd.write("setup.py", "__import__('setuptools').setup(name='test_package')") + + # Now do git operations + wd("git init") + wd("git config user.email test@example.com") + wd('git config user.name "a test"') + wd("git add .") + wd('git commit -m "initial"') + wd("git tag v1.0.0") + + # Should not auto-activate setuptools_scm, so version should be None + res = run([sys.executable, "setup.py", "--version"], wd.cwd) + print(f"Version output: {res.stdout!r}") + # The version should not be from setuptools_scm (which would be 1.0.0 from git tag) + # but should be the default setuptools version (0.0.0) + assert res.stdout == "0.0.0" # Default version when no version is set + + +def test_pyproject_no_project_section_no_error(wd: WorkDir) -> None: + """Test that setuptools_scm doesn't raise an error when there's no project section.""" + if sys.version_info < (3, 11): + pytest.importorskip("tomli") + + # Create pyproject.toml with setuptools-scm in build-system.requires but no project section + wd.write( + "pyproject.toml", + textwrap.dedent( + """ + [build-system] + requires = ["setuptools>=80", "setuptools-scm>=8"] + build-backend = "setuptools.build_meta" + """ + ), + ) + + # This should NOT raise an error because there's no project section + # setuptools_scm should simply not auto-activate + from setuptools_scm._integration.pyproject_reading import read_pyproject + + pyproject_data = read_pyproject(wd.cwd / "pyproject.toml") + # Should not auto-activate when no project section exists + assert not pyproject_data.is_required or not pyproject_data.section_present @pytest.mark.parametrize("use_scm_version", ["True", "{}", "lambda: {}"]) @@ -233,7 +338,7 @@ def test_pretend_metadata_with_version( write_to="src/version.py", write_to_template=version_file_content ) - content = (wd.cwd / "src/version.py").read_text() + content = (wd.cwd / "src/version.py").read_text(encoding="utf-8") assert "commit_hash = 'g1337beef'" in content assert "num_commit = 4" in content @@ -306,7 +411,7 @@ def test_pretend_metadata_with_scm_version( write_to="src/version.py", write_to_template=version_file_content ) - content = (wd.cwd / "src/version.py").read_text() + content = (wd.cwd / "src/version.py").read_text(encoding="utf-8") assert "commit_hash = 'gcustom123'" in content assert "num_commit = 7" in content @@ -539,7 +644,9 @@ def test_unicode_in_setup_cfg(tmp_path: Path) -> None: ), encoding="utf-8", ) - name = setuptools_scm._integration.setuptools.read_dist_name_from_setup_cfg(cfg) + from setuptools_scm._integration.setup_cfg import read_dist_name_from_setup_cfg + + name = read_dist_name_from_setup_cfg(cfg) assert name == "configparser" @@ -550,12 +657,12 @@ def test_setuptools_version_keyword_ensures_regex( wd.commit_testfile("test") wd("git tag 1.0") monkeypatch.chdir(wd.cwd) - import setuptools - - from setuptools_scm._integration.setuptools import version_keyword - dist = setuptools.Distribution({"name": "test"}) - version_keyword(dist, "use_scm_version", {"tag_regex": "(1.0)"}) + dist = create_clean_distribution("test") + setuptools_integration.version_keyword( + dist, "use_scm_version", {"tag_regex": "(1.0)"} + ) + assert dist.metadata.version == "1.0" @pytest.mark.parametrize( @@ -713,17 +820,15 @@ def test_pyproject_build_system_requires_priority_over_tool_section( assert res.endswith("0.1.dev0+d20090213") -def test_extract_package_name() -> None: +@pytest.mark.parametrize("base_name", ["setuptools_scm", "setuptools-scm"]) +@pytest.mark.parametrize( + "requirements", + ["", ">=8", "[toml]>=7", "~=9.0", "[rich,toml]>=8"], + ids=["empty", "version", "extras", "fuzzy", "multiple-extras"], +) +def test_extract_package_name(base_name: str, requirements: str) -> None: """Test the _extract_package_name helper function""" - assert _extract_package_name("setuptools_scm") == "setuptools_scm" - assert _extract_package_name("setuptools-scm") == "setuptools-scm" - assert _extract_package_name("setuptools_scm>=8") == "setuptools_scm" - assert _extract_package_name("setuptools-scm>=8") == "setuptools-scm" - assert _extract_package_name("setuptools_scm[toml]>=7.0") == "setuptools_scm" - assert _extract_package_name("setuptools-scm[toml]>=7.0") == "setuptools-scm" - assert _extract_package_name("setuptools_scm==8.0.0") == "setuptools_scm" - assert _extract_package_name("setuptools_scm~=8.0") == "setuptools_scm" - assert _extract_package_name("setuptools_scm[rich,toml]>=8") == "setuptools_scm" + assert extract_package_name(f"{base_name}{requirements}") == "setuptools-scm" def test_build_requires_integration_with_config_reading(wd: WorkDir) -> None: @@ -813,43 +918,53 @@ def test_improved_error_message_mentions_both_config_options( assert "requires" in error_msg -# Helper functions for testing integration point ordering -def integration_infer_version(dist: setuptools.Distribution) -> str: - """Helper to call infer_version and return the result.""" - from setuptools_scm._integration.setuptools import infer_version +# Helper function for creating and managing distribution objects +def create_clean_distribution(name: str) -> setuptools.Distribution: + """Create a clean distribution object without any setuptools_scm effects. - infer_version(dist) - return "infer_version" + This function creates a new setuptools Distribution and ensures it's completely + clean from any previous setuptools_scm version inference effects, including: + - Clearing any existing version + - Removing the _setuptools_scm_version_set_by_infer flag + """ + import setuptools + + dist = setuptools.Distribution({"name": name}) + + # Clean all setuptools_scm effects + dist.metadata.version = None + if hasattr(dist, "_setuptools_scm_version_set_by_infer"): + delattr(dist, "_setuptools_scm_version_set_by_infer") + + return dist -def integration_version_keyword_default(dist: setuptools.Distribution) -> str: +def version_keyword_default(dist: setuptools.Distribution) -> None: """Helper to call version_keyword with default config and return the result.""" - from setuptools_scm._integration.setuptools import version_keyword - version_keyword(dist, "use_scm_version", True) - return "version_keyword_default" + setuptools_integration.version_keyword(dist, "use_scm_version", True) -def integration_version_keyword_calver(dist: setuptools.Distribution) -> str: +def version_keyword_calver(dist: setuptools.Distribution) -> None: """Helper to call version_keyword with calver-by-date scheme and return the result.""" - from setuptools_scm._integration.setuptools import version_keyword - version_keyword(dist, "use_scm_version", {"version_scheme": "calver-by-date"}) - return "version_keyword_calver" + setuptools_integration.version_keyword( + dist, "use_scm_version", {"version_scheme": "calver-by-date"} + ) # Test cases: (first_func, second_func, expected_final_version) # We use a controlled date to make calver deterministic TEST_CASES = [ # Real-world scenarios: infer_version and version_keyword can be called in either order - (integration_infer_version, integration_version_keyword_default, "1.0.1.dev1"), + (setuptools_integration.infer_version, version_keyword_default, "1.0.1.dev1"), ( - integration_infer_version, - integration_version_keyword_calver, + setuptools_integration.infer_version, + version_keyword_calver, "9.2.13.0.dev1", ), # calver should win but doesn't - (integration_version_keyword_default, integration_infer_version, "1.0.1.dev1"), - (integration_version_keyword_calver, integration_infer_version, "9.2.13.0.dev1"), + (version_keyword_default, setuptools_integration.infer_version, "1.0.1.dev1"), + (version_keyword_calver, setuptools_integration.infer_version, "9.2.13.0.dev1"), ] @@ -885,11 +1000,6 @@ def test_integration_function_call_order( wd.commit_testfile("test2") # Add another commit to get distance monkeypatch.chdir(wd.cwd) - # Generate unique distribution name based on the test combination - first_name = first_integration.__name__.replace("integration_", "") - second_name = second_integration.__name__.replace("integration_", "") - dist_name = f"test-pkg-{first_name}-then-{second_name}" - # Create a pyproject.toml file pyproject_content = f""" [build-system] @@ -897,7 +1007,7 @@ def test_integration_function_call_order( build-backend = "setuptools.build_meta" [project] -name = "{dist_name}" +name = "test-pkg-{first_integration.__name__}-{second_integration.__name__}" dynamic = ["version"] [tool.setuptools_scm] @@ -905,11 +1015,9 @@ def test_integration_function_call_order( """ wd.write("pyproject.toml", pyproject_content) - import setuptools - - # Create distribution and clear any auto-set version - dist = setuptools.Distribution({"name": dist_name}) - dist.metadata.version = None + dist = create_clean_distribution( + f"test-pkg-{first_integration.__name__}-{second_integration.__name__}" + ) # Call both integration functions in order first_integration(dist) @@ -949,12 +1057,10 @@ def test_infer_version_with_build_requires_no_tool_section( """ wd.write("pyproject.toml", pyproject_content) - import setuptools - from setuptools_scm._integration.setuptools import infer_version - # Create distribution - dist = setuptools.Distribution({"name": "test-package-infer-version"}) + # Create clean distribution + dist = create_clean_distribution("test-package-infer-version") # Call infer_version - this should work because setuptools_scm is in build-system.requires infer_version(dist) @@ -991,12 +1097,10 @@ def test_infer_version_with_build_requires_dash_variant_no_tool_section( """ wd.write("pyproject.toml", pyproject_content) - import setuptools - from setuptools_scm._integration.setuptools import infer_version - # Create distribution - dist = setuptools.Distribution({"name": "test-package-infer-version-dash"}) + # Create clean distribution + dist = create_clean_distribution("test-package-infer-version-dash") # Call infer_version - this should work because setuptools-scm is in build-system.requires infer_version(dist) @@ -1033,12 +1137,10 @@ def test_infer_version_without_build_requires_no_tool_section_silently_returns( """ wd.write("pyproject.toml", pyproject_content) - import setuptools - from setuptools_scm._integration.setuptools import infer_version - # Create distribution - dist = setuptools.Distribution({"name": "test-package-no-scm"}) + # Create clean distribution + dist = create_clean_distribution("test-package-no-scm") infer_version(dist) assert dist.metadata.version is None @@ -1193,12 +1295,10 @@ def test_infer_version_logs_debug_when_missing_dynamic_version( """ wd.write("pyproject.toml", pyproject_content) - import setuptools - from setuptools_scm._integration.setuptools import infer_version - # Create distribution - dist = setuptools.Distribution({"name": "test-package-missing-dynamic"}) + # Create clean distribution + dist = create_clean_distribution("test-package-missing-dynamic") # This should not raise an error, but should log debug info about the configuration issue infer_version(dist) diff --git a/testing/test_pyproject_reading.py b/testing/test_pyproject_reading.py new file mode 100644 index 00000000..592adf86 --- /dev/null +++ b/testing/test_pyproject_reading.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from setuptools_scm._integration.pyproject_reading import read_pyproject + + +class TestPyProjectReading: + """Test the pyproject reading functionality.""" + + def test_read_pyproject_missing_file_ok(self, tmp_path: Path) -> None: + """Test that read_pyproject handles missing files when missing_file_ok=True.""" + # Test with missing_file_ok=True + result = read_pyproject( + path=tmp_path / "nonexistent.toml", missing_file_ok=True + ) + + assert result.path == tmp_path / "nonexistent.toml" + assert result.tool_name == "setuptools_scm" + assert result.project == {} + assert result.section == {} + assert result.is_required is False + assert result.section_present is False + assert result.project_present is False + + def test_read_pyproject_missing_file_not_ok(self, tmp_path: Path) -> None: + """Test that read_pyproject raises FileNotFoundError when missing_file_ok=False.""" + with pytest.raises(FileNotFoundError): + read_pyproject(path=tmp_path / "nonexistent.toml", missing_file_ok=False) + + def test_read_pyproject_existing_file(self, tmp_path: Path) -> None: + """Test that read_pyproject reads existing files correctly.""" + # Create a simple pyproject.toml + pyproject_content = """ +[build-system] +requires = ["setuptools>=80", "setuptools-scm>=8"] +build-backend = "setuptools.build_meta" + +[project] +name = "test-package" +dynamic = ["version"] + +[tool.setuptools_scm] +""" + pyproject_file = tmp_path / "pyproject.toml" + pyproject_file.write_text(pyproject_content, encoding="utf-8") + + result = read_pyproject(path=pyproject_file) + + assert result.path == pyproject_file + assert result.tool_name == "setuptools_scm" + assert result.is_required is True + assert result.section_present is True + assert result.project_present is True + assert result.project.get("name") == "test-package" diff --git a/testing/test_regressions.py b/testing/test_regressions.py index 679365e6..326d62b8 100644 --- a/testing/test_regressions.py +++ b/testing/test_regressions.py @@ -114,7 +114,8 @@ def test_case_mismatch_nested_dir_windows_git(tmp_path: Path) -> None: nested_dir.mkdir() # Create a pyproject.toml in the nested directory - (nested_dir / "pyproject.toml").write_text(""" + (nested_dir / "pyproject.toml").write_text( + """ [build-system] requires = ["setuptools>=64", "setuptools-scm"] build-backend = "setuptools.build_meta" @@ -124,7 +125,9 @@ def test_case_mismatch_nested_dir_windows_git(tmp_path: Path) -> None: dynamic = ["version"] [tool.setuptools_scm] -""") +""", + encoding="utf-8", + ) # Add and commit the file run("git add .", repo_path) @@ -159,7 +162,7 @@ def test_case_mismatch_force_assertion_failure(tmp_path: Path) -> None: nested_dir.mkdir() # Add and commit something to make it a valid repo - (nested_dir / "test.txt").write_text("test") + (nested_dir / "test.txt").write_text("test", encoding="utf-8") run("git add .", repo_path) run("git commit -m 'Initial commit'", repo_path) diff --git a/testing/test_version_inference.py b/testing/test_version_inference.py new file mode 100644 index 00000000..d6911358 --- /dev/null +++ b/testing/test_version_inference.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +from setuptools_scm._integration.pyproject_reading import PyProjectData +from setuptools_scm._integration.version_inference import VersionInferenceConfig +from setuptools_scm._integration.version_inference import VersionInferenceError +from setuptools_scm._integration.version_inference import VersionInferenceException +from setuptools_scm._integration.version_inference import VersionInferenceNoOp +from setuptools_scm._integration.version_inference import get_version_inference_config + + +class TestVersionInferenceDecision: + """Test the version inference decision logic.""" + + def test_version_already_set_by_infer_with_overrides(self) -> None: + """Test that we proceed when version was set by infer_version but overrides provided.""" + result = get_version_inference_config( + dist_name="test_package", + current_version="1.0.0", + pyproject_data=PyProjectData.for_testing(True, True, True), + overrides={"key": "value"}, + was_set_by_infer=True, + ) + + assert isinstance(result, VersionInferenceConfig) + assert result.dist_name == "test_package" + assert result.overrides == {"key": "value"} + + def test_version_already_set_by_infer_no_overrides(self) -> None: + """Test that we allow re-inferring when version was set by infer_version and overrides=None (another infer_version call).""" + result = get_version_inference_config( + dist_name="test_package", + current_version="1.0.0", + pyproject_data=PyProjectData.for_testing(True, True, True), + overrides=None, + was_set_by_infer=True, + ) + + assert isinstance(result, VersionInferenceConfig) + assert result.dist_name == "test_package" + assert result.overrides is None + + def test_version_already_set_by_infer_empty_overrides(self) -> None: + """Test that we don't re-infer when version was set by infer_version with empty overrides (version_keyword call).""" + result = get_version_inference_config( + dist_name="test_package", + current_version="1.0.0", + pyproject_data=PyProjectData.for_testing(True, True, True), + overrides={}, + was_set_by_infer=True, + ) + + assert isinstance(result, VersionInferenceNoOp) + + def test_version_already_set_by_something_else(self) -> None: + """Test that we return error when version was set by something else.""" + result = get_version_inference_config( + dist_name="test_package", + current_version="1.0.0", + pyproject_data=PyProjectData.for_testing(True, True, True), + overrides=None, + was_set_by_infer=False, + ) + + assert isinstance(result, VersionInferenceError) + assert result.message == "version of test_package already set" + assert result.should_warn is True + + def test_setuptools_scm_package(self) -> None: + """Test that we don't infer for setuptools-scm package itself.""" + result = get_version_inference_config( + dist_name="setuptools-scm", + current_version=None, + pyproject_data=PyProjectData.for_testing(True, True, True), + ) + + assert isinstance(result, VersionInferenceNoOp) + + def test_no_pyproject_toml(self) -> None: + """Test that we don't infer when no pyproject.toml exists.""" + # When no pyproject.toml exists, the integration points should return early + # and not call get_version_inference_config at all. + # This test is no longer needed as pyproject_data is always required. + + def test_no_setuptools_scm_config(self) -> None: + """Test that we don't infer when setuptools-scm is not configured.""" + result = get_version_inference_config( + dist_name="test_package", + current_version=None, + pyproject_data=PyProjectData.for_testing(False, False, True), + ) + + assert isinstance(result, VersionInferenceNoOp) + + def test_setuptools_scm_required_no_project_section(self) -> None: + """Test that we don't infer when setuptools-scm is required but no project section.""" + result = get_version_inference_config( + dist_name="test_package", + current_version=None, + pyproject_data=PyProjectData.for_testing(True, False, False), + ) + + assert isinstance(result, VersionInferenceNoOp) + + def test_setuptools_scm_required_with_project_section(self) -> None: + """Test that we infer when setuptools-scm is required and project section exists.""" + result = get_version_inference_config( + dist_name="test_package", + current_version=None, + pyproject_data=PyProjectData.for_testing(True, False, True), + ) + + assert isinstance(result, VersionInferenceConfig) + assert result.dist_name == "test_package" + + def test_tool_section_present(self) -> None: + """Test that we infer when tool section is present.""" + result = get_version_inference_config( + dist_name="test_package", + current_version=None, + pyproject_data=PyProjectData.for_testing(False, True, False), + ) + + assert isinstance(result, VersionInferenceConfig) + assert result.dist_name == "test_package" + + def test_both_required_and_tool_section(self) -> None: + """Test that we infer when both required and tool section are present.""" + result = get_version_inference_config( + dist_name="test_package", + current_version=None, + pyproject_data=PyProjectData.for_testing(True, True, True), + ) + + assert isinstance(result, VersionInferenceConfig) + assert result.dist_name == "test_package" + + def test_none_dist_name(self) -> None: + """Test that we handle None dist_name correctly.""" + result = get_version_inference_config( + dist_name=None, + current_version=None, + pyproject_data=PyProjectData.for_testing(True, True, True), + ) + + assert isinstance(result, VersionInferenceConfig) + assert result.dist_name is None + + def test_version_already_set_none_dist_name(self) -> None: + """Test that we handle None dist_name in error case.""" + result = get_version_inference_config( + dist_name=None, + current_version="1.0.0", + pyproject_data=PyProjectData.for_testing(True, True, True), + overrides=None, + was_set_by_infer=False, + ) + + assert isinstance(result, VersionInferenceError) + assert result.message == "version of None already set" + + def test_overrides_passed_through(self) -> None: + """Test that overrides are passed through to the config.""" + overrides = {"version_scheme": "calver"} + result = get_version_inference_config( + dist_name="test_package", + current_version=None, + pyproject_data=PyProjectData.for_testing(True, True, True), + overrides=overrides, + ) + + assert isinstance(result, VersionInferenceConfig) + assert result.overrides == overrides + + +class TestPyProjectData: + """Test the PyProjectData dataclass.""" + + def test_pyproject_data_creation(self) -> None: + """Test creating PyProjectData instances.""" + data = PyProjectData.for_testing(True, False, True) + assert data.is_required is True + assert data.section_present is False + assert data.project_present is True + + def test_pyproject_data_equality(self) -> None: + """Test PyProjectData equality.""" + data1 = PyProjectData.for_testing(True, False, True) + data2 = PyProjectData.for_testing(True, False, True) + data3 = PyProjectData.for_testing(False, False, True) + + assert data1 == data2 + assert data1 != data3 + + +class TestVersionInferenceConfig: + """Test the VersionInferenceConfig dataclass.""" + + def test_config_creation(self) -> None: + """Test creating VersionInferenceConfig instances.""" + pyproject_data = PyProjectData.for_testing(True, True, True) + config = VersionInferenceConfig( + dist_name="test_package", + pyproject_data=pyproject_data, + overrides={"key": "value"}, + ) + + assert config.dist_name == "test_package" + assert config.pyproject_data == pyproject_data + assert config.overrides == {"key": "value"} + + +class TestVersionInferenceError: + """Test the VersionInferenceError dataclass.""" + + def test_error_creation(self) -> None: + """Test creating VersionInferenceError instances.""" + error = VersionInferenceError("test message", should_warn=True) + assert error.message == "test message" + assert error.should_warn is True + + def test_error_default_warn(self) -> None: + """Test VersionInferenceError default should_warn value.""" + error = VersionInferenceError("test message") + assert error.should_warn is False + + +class TestVersionInferenceException: + """Test the VersionInferenceException dataclass.""" + + def test_exception_creation(self) -> None: + """Test creating VersionInferenceException instances.""" + original_exception = ValueError("test error") + wrapper = VersionInferenceException(original_exception) + assert wrapper.exception == original_exception diff --git a/tox.ini b/tox.ini index cdb25590..83af3bbe 100644 --- a/tox.ini +++ b/tox.ini @@ -9,7 +9,12 @@ ignore=E203,W503 [testenv] usedevelop=True -extras=test +dependency_groups = test +deps = + pytest + pytest-cov + pytest-timeout + pytest-xdist commands= python -X warn_default_encoding -m pytest {posargs}