From ed3f6774a8480f7218cf1b99ce1a158f3741d2d0 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Mon, 28 Jul 2025 18:26:25 +0200 Subject: [PATCH 1/3] introduce a mechanism to configure git pre-parse+submodule checks fixes #846 --- docs/config.md | 14 ++++ docs/usage.md | 4 + src/setuptools_scm/_config.py | 69 ++++++++++++++++ src/setuptools_scm/_types.py | 3 + src/setuptools_scm/git.py | 85 +++++++++++++++++++- testing/test_git.py | 143 +++++++++++++++++++++++++++++++++- 6 files changed, 311 insertions(+), 7 deletions(-) diff --git a/docs/config.md b/docs/config.md index 0f80ca94..bf42099c 100644 --- a/docs/config.md +++ b/docs/config.md @@ -97,6 +97,20 @@ Callables or other Python objects have to be passed in `setup.py` (via the `use_ Defaults to the value set by [setuptools_scm.git.DEFAULT_DESCRIBE][] +`scm.git.pre_parse` +: A string specifying which git pre-parse function to use before parsing version information. + Available options: + + - `"warn_on_shallow"` (default): Warns when the repository is shallow + - `"fail_on_shallow"`: Fails with an error when the repository is shallow + - `"fetch_on_shallow"`: Automatically fetches to rectify shallow repositories + - `"fail_on_missing_submodules"`: Fails when submodules are defined but not initialized + + The `"fail_on_missing_submodules"` option is useful to prevent packaging incomplete + projects when submodules are required for a complete build. + + Note: This setting is overridden by any explicit `pre_parse` parameter passed to the git parse function. + `normalize` : A boolean flag indicating if the version string should be normalized. Defaults to `True`. Setting this to `False` is equivalent to setting diff --git a/docs/usage.md b/docs/usage.md index faede38c..ee7c168e 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -39,6 +39,10 @@ dynamic = ["version"] [tool.setuptools_scm] # Configure custom options here (version schemes, file writing, etc.) version_file = "src/mypackage/_version.py" + +# Example: Fail if submodules are not initialized (useful for projects requiring submodules) +[tool.setuptools_scm.scm.git] +pre_parse = "fail_on_missing_submodules" ``` Both approaches will work with projects that support PEP 518 ([pip](https://pypi.org/project/pip) and diff --git a/src/setuptools_scm/_config.py b/src/setuptools_scm/_config.py index aec2a0f0..210169c6 100644 --- a/src/setuptools_scm/_config.py +++ b/src/setuptools_scm/_config.py @@ -8,10 +8,14 @@ import warnings from pathlib import Path +from typing import TYPE_CHECKING from typing import Any from typing import Pattern from typing import Protocol +if TYPE_CHECKING: + from . import git + from . import _log from . import _types as _t from ._integration.pyproject_reading import PyProjectData @@ -52,6 +56,13 @@ def _check_tag_regex(value: str | Pattern[str] | None) -> Pattern[str]: return regex +def _get_default_git_pre_parse() -> git.GitPreParse: + """Get the default git pre_parse enum value""" + from . import git + + return git.GitPreParse.WARN_ON_SHALLOW + + class ParseFunction(Protocol): def __call__( self, root: _t.PathT, *, config: Configuration @@ -83,6 +94,53 @@ def _check_absolute_root(root: _t.PathT, relative_to: _t.PathT | None) -> str: return os.path.abspath(root) +@dataclasses.dataclass +class GitConfiguration: + """Git-specific configuration options""" + + pre_parse: git.GitPreParse = dataclasses.field( + default_factory=lambda: _get_default_git_pre_parse() + ) + + @classmethod + def from_data(cls, data: dict[str, Any]) -> GitConfiguration: + """Create GitConfiguration from configuration data, converting strings to enums""" + git_data = data.copy() + + # Convert string pre_parse values to enum instances + if "pre_parse" in git_data and isinstance(git_data["pre_parse"], str): + from . import git + + try: + git_data["pre_parse"] = git.GitPreParse(git_data["pre_parse"]) + except ValueError as e: + valid_options = [option.value for option in git.GitPreParse] + raise ValueError( + f"Invalid git pre_parse function '{git_data['pre_parse']}'. " + f"Valid options are: {', '.join(valid_options)}" + ) from e + + return cls(**git_data) + + +@dataclasses.dataclass +class ScmConfiguration: + """SCM-specific configuration options""" + + git: GitConfiguration = dataclasses.field(default_factory=GitConfiguration) + + @classmethod + def from_data(cls, data: dict[str, Any]) -> ScmConfiguration: + """Create ScmConfiguration from configuration data""" + scm_data = data.copy() + + # Handle git-specific configuration + git_data = scm_data.pop("git", {}) + git_config = GitConfiguration.from_data(git_data) + + return cls(git=git_config, **scm_data) + + @dataclasses.dataclass class Configuration: """Global configuration model""" @@ -107,6 +165,11 @@ class Configuration: parent: _t.PathT | None = None + # Nested SCM configurations + scm: ScmConfiguration = dataclasses.field( + default_factory=lambda: ScmConfiguration() + ) + def __post_init__(self) -> None: self.tag_regex = _check_tag_regex(self.tag_regex) @@ -161,8 +224,14 @@ def from_data( version_cls = _validate_version_cls( data.pop("version_cls", None), data.pop("normalize", True) ) + + # Handle nested SCM configuration + scm_data = data.pop("scm", {}) + scm_config = ScmConfiguration.from_data(scm_data) + return cls( relative_to=relative_to, version_cls=version_cls, + scm=scm_config, **data, ) diff --git a/src/setuptools_scm/_types.py b/src/setuptools_scm/_types.py index b655c76f..6cc4e774 100644 --- a/src/setuptools_scm/_types.py +++ b/src/setuptools_scm/_types.py @@ -26,3 +26,6 @@ VERSION_SCHEME: TypeAlias = Union[str, Callable[["version.ScmVersion"], str]] VERSION_SCHEMES: TypeAlias = Union[List[str], Tuple[str, ...], VERSION_SCHEME] SCMVERSION: TypeAlias = "version.ScmVersion" + +# Git pre-parse function types +GIT_PRE_PARSE: TypeAlias = Union[str, None] diff --git a/src/setuptools_scm/git.py b/src/setuptools_scm/git.py index 7d81d40c..f778e96f 100644 --- a/src/setuptools_scm/git.py +++ b/src/setuptools_scm/git.py @@ -11,6 +11,7 @@ from datetime import date from datetime import datetime from datetime import timezone +from enum import Enum from os.path import samefile from pathlib import Path from typing import TYPE_CHECKING @@ -52,6 +53,15 @@ ] +class GitPreParse(Enum): + """Available git pre-parse functions""" + + WARN_ON_SHALLOW = "warn_on_shallow" + FAIL_ON_SHALLOW = "fail_on_shallow" + FETCH_ON_SHALLOW = "fetch_on_shallow" + FAIL_ON_MISSING_SUBMODULES = "fail_on_missing_submodules" + + def run_git( args: Sequence[str | os.PathLike[str]], repo: Path, @@ -209,6 +219,65 @@ def fail_on_shallow(wd: GitWorkdir) -> None: ) +def fail_on_missing_submodules(wd: GitWorkdir) -> None: + """ + Fail if submodules are defined but not initialized/cloned. + + This pre_parse function checks if there are submodules defined in .gitmodules + but not properly initialized (cloned). This helps prevent packaging incomplete + projects when submodules are required for a complete build. + """ + gitmodules_path = wd.path / ".gitmodules" + if not gitmodules_path.exists(): + # No submodules defined, nothing to check + return + + # Get submodule status - lines starting with '-' indicate uninitialized submodules + status_result = run_git(["submodule", "status"], wd.path) + if status_result.returncode != 0: + # Command failed, might not be in a git repo or other error + log.debug("Failed to check submodule status: %s", status_result.stderr) + return + + status_lines = ( + status_result.stdout.strip().split("\n") if status_result.stdout.strip() else [] + ) + uninitialized_submodules = [] + + for line in status_lines: + line = line.strip() + if line.startswith("-"): + # Extract submodule path (everything after the commit hash) + parts = line.split() + if len(parts) >= 2: + submodule_path = parts[1] + uninitialized_submodules.append(submodule_path) + + # If .gitmodules exists but git submodule status returns nothing, + # it means submodules are defined but not properly set up (common after cloning without --recurse-submodules) + if not status_lines and gitmodules_path.exists(): + raise ValueError( + f"Submodules are defined in .gitmodules but not initialized in {wd.path}. " + f"Please run 'git submodule update --init --recursive' to initialize them." + ) + + if uninitialized_submodules: + submodule_list = ", ".join(uninitialized_submodules) + raise ValueError( + f"Submodules are not initialized in {wd.path}: {submodule_list}. " + f"Please run 'git submodule update --init --recursive' to initialize them." + ) + + +# Mapping from enum items to actual pre_parse functions +_GIT_PRE_PARSE_FUNCTIONS: dict[GitPreParse, Callable[[GitWorkdir], None]] = { + GitPreParse.WARN_ON_SHALLOW: warn_on_shallow, + GitPreParse.FAIL_ON_SHALLOW: fail_on_shallow, + GitPreParse.FETCH_ON_SHALLOW: fetch_on_shallow, + GitPreParse.FAIL_ON_MISSING_SUBMODULES: fail_on_missing_submodules, +} + + def get_working_directory(config: Configuration, root: _t.PathT) -> GitWorkdir | None: """ Return the working directory (``GitWorkdir``). @@ -231,16 +300,26 @@ def parse( root: _t.PathT, config: Configuration, describe_command: str | list[str] | None = None, - pre_parse: Callable[[GitWorkdir], None] = warn_on_shallow, + pre_parse: Callable[[GitWorkdir], None] | None = None, ) -> ScmVersion | None: """ - :param pre_parse: experimental pre_parse action, may change at any time + :param pre_parse: experimental pre_parse action, may change at any time. + Takes precedence over config.git_pre_parse if provided. """ _require_command("git") wd = get_working_directory(config, root) if wd: + # Use function parameter first, then config setting, then default + if pre_parse is not None: + effective_pre_parse = pre_parse + else: + # config.scm.git.pre_parse is always a GitPreParse enum instance + effective_pre_parse = _GIT_PRE_PARSE_FUNCTIONS.get( + config.scm.git.pre_parse, warn_on_shallow + ) + return _git_parse_inner( - config, wd, describe_command=describe_command, pre_parse=pre_parse + config, wd, describe_command=describe_command, pre_parse=effective_pre_parse ) else: return None diff --git a/testing/test_git.py b/testing/test_git.py index 9ef1f14a..e739fc94 100644 --- a/testing/test_git.py +++ b/testing/test_git.py @@ -39,15 +39,22 @@ ) -@pytest.fixture(name="wd") -def wd(wd: WorkDir, monkeypatch: pytest.MonkeyPatch, debug_mode: DebugMode) -> WorkDir: - debug_mode.disable() - monkeypatch.delenv("HOME", raising=False) +def setup_git_wd(wd: WorkDir, monkeypatch: pytest.MonkeyPatch | None = None) -> WorkDir: + """Set up a WorkDir with git initialized and configured for testing.""" + if monkeypatch: + monkeypatch.delenv("HOME", raising=False) wd("git init") wd("git config user.email test@example.com") wd('git config user.name "a test"') wd.add_command = "git add ." wd.commit_command = "git commit -m test-{reason}" + return wd + + +@pytest.fixture(name="wd") +def wd(wd: WorkDir, monkeypatch: pytest.MonkeyPatch, debug_mode: DebugMode) -> WorkDir: + debug_mode.disable() + setup_git_wd(wd, monkeypatch) debug_mode.enable() return wd @@ -623,3 +630,131 @@ def test_git_archival_from_unfiltered() -> None: ): version = archival_to_version({"node": "$Format:%H$"}, config=config) assert version is None + + +def test_fail_on_missing_submodules_no_gitmodules(wd: WorkDir) -> None: + """Test that fail_on_missing_submodules does nothing when no .gitmodules exists.""" + wd.commit_testfile() + # Should not raise any exception + git.fail_on_missing_submodules(git.GitWorkdir(wd.cwd)) + + +def test_fail_on_missing_submodules_with_initialized_submodules(wd: WorkDir) -> None: + """Test that fail_on_missing_submodules passes when submodules are initialized.""" + # Create a submodule directory and .gitmodules file + submodule_dir = wd.cwd / "external" + submodule_dir.mkdir() + + # Initialize a git repo in the submodule directory + wd(["git", "-C", str(submodule_dir), "init"]) + wd(["git", "-C", str(submodule_dir), "config", "user.email", "test@example.com"]) + wd(["git", "-C", str(submodule_dir), "config", "user.name", "Test User"]) + + # Create a commit in the submodule + test_file = submodule_dir / "test.txt" + test_file.write_text("test content") + wd(["git", "-C", str(submodule_dir), "add", "test.txt"]) + wd(["git", "-C", str(submodule_dir), "commit", "-m", "Initial commit"]) + + # Add it as a submodule to the main repo + wd(["git", "submodule", "add", str(submodule_dir), "external"]) + wd.commit_testfile() + + # Should not raise any exception since the submodule is initialized + git.fail_on_missing_submodules(git.GitWorkdir(wd.cwd)) + + +def test_fail_on_missing_submodules_with_uninitialized_submodules( + tmp_path: Path, +) -> None: + """Test that fail_on_missing_submodules fails when submodules are not initialized.""" + # Create a test repository with a .gitmodules file but no actual submodule + test_repo = tmp_path / "test_repo" + test_repo.mkdir() + test_wd = setup_git_wd(WorkDir(test_repo)) + + # Create a fake .gitmodules file (this simulates what happens after cloning without --recurse-submodules) + gitmodules_content = """[submodule "external"] + path = external + url = https://example.com/external.git +""" + test_wd.write(".gitmodules", gitmodules_content) + test_wd.add_and_commit("Add-submodule-reference") + + # Should raise ValueError for uninitialized submodules + with pytest.raises( + ValueError, match=r"Submodules are defined in \.gitmodules but not initialized" + ): + git.fail_on_missing_submodules(git.GitWorkdir(test_repo)) + + +def test_git_pre_parse_config_integration(wd: WorkDir) -> None: + """Test that git_pre_parse configuration is used by the parse function.""" + wd.commit_testfile() + + # Test with default (None) - should use warn_on_shallow + config = Configuration() + result = git.parse(str(wd.cwd), config) + assert result is not None + + # Test with explicit configuration + from setuptools_scm._config import GitConfiguration + from setuptools_scm._config import ScmConfiguration + + config_with_pre_parse = Configuration( + scm=ScmConfiguration( + git=GitConfiguration(pre_parse=git.GitPreParse.WARN_ON_SHALLOW) + ) + ) + result = git.parse(str(wd.cwd), config_with_pre_parse) + assert result is not None + + # Test with different pre_parse value + config_fail_shallow = Configuration( + scm=ScmConfiguration( + git=GitConfiguration(pre_parse=git.GitPreParse.FAIL_ON_MISSING_SUBMODULES) + ) + ) + result = git.parse(str(wd.cwd), config_fail_shallow) + assert result is not None + + +def test_nested_scm_git_config_from_toml(tmp_path: Path) -> None: + """Test that nested SCM git configuration is properly parsed from TOML.""" + # Create a test pyproject.toml with nested SCM configuration + pyproject_path = tmp_path / "pyproject.toml" + pyproject_content = """ +[tool.setuptools_scm.scm.git] +pre_parse = "fail_on_missing_submodules" +""" + pyproject_path.write_text(pyproject_content) + + # Parse the configuration from file + config = Configuration.from_file(pyproject_path) + + # Verify the nested configuration was parsed correctly and converted to enum + assert config.scm.git.pre_parse == git.GitPreParse.FAIL_ON_MISSING_SUBMODULES + + +def test_nested_scm_git_config_from_data() -> None: + """Test that nested SCM git configuration parsing works correctly with from_data.""" + # Test configuration parsing directly without file I/O + config_data = {"scm": {"git": {"pre_parse": "fail_on_missing_submodules"}}} + + # Parse the configuration data + config = Configuration.from_data(relative_to=".", data=config_data) + + # Verify the nested configuration was parsed correctly and converted to enum + assert config.scm.git.pre_parse == git.GitPreParse.FAIL_ON_MISSING_SUBMODULES + + +def test_invalid_git_pre_parse_raises_error() -> None: + """Test that invalid git pre_parse values raise a helpful ValueError.""" + # Test configuration parsing directly without file I/O + invalid_config_data = {"scm": {"git": {"pre_parse": "invalid_function"}}} + + # Parse the configuration data - should raise ValueError + with pytest.raises( + ValueError, match="Invalid git pre_parse function 'invalid_function'" + ): + Configuration.from_data(relative_to=".", data=invalid_config_data) From ca11490b35132a28bfba6d3d8e8c3f3905ffa436 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Mon, 28 Jul 2025 21:56:33 +0200 Subject: [PATCH 2/3] Deprecate `git_describe_command` in favor of `scm.git.describe_command` - implement backward compatibility handling - Update documentation and tests to reflect changes --- docs/config.md | 9 ++- docs/usage.md | 5 +- src/setuptools_scm/_config.py | 95 ++++++++++++++++++++++++- src/setuptools_scm/_get_version_impl.py | 15 +++- src/setuptools_scm/git.py | 4 +- testing/test_git.py | 67 ++++++++++++++++- 6 files changed, 185 insertions(+), 10 deletions(-) diff --git a/docs/config.md b/docs/config.md index bf42099c..c8b4d9ff 100644 --- a/docs/config.md +++ b/docs/config.md @@ -92,7 +92,7 @@ Callables or other Python objects have to be passed in `setup.py` (via the `use_ this is a function for advanced use and you should be familiar with the `setuptools-scm` internals to use it. -`git_describe_command` +`scm.git.describe_command` : This command will be used instead the default `git describe --long` command. Defaults to the value set by [setuptools_scm.git.DEFAULT_DESCRIBE][] @@ -106,11 +106,16 @@ Callables or other Python objects have to be passed in `setup.py` (via the `use_ - `"fetch_on_shallow"`: Automatically fetches to rectify shallow repositories - `"fail_on_missing_submodules"`: Fails when submodules are defined but not initialized - The `"fail_on_missing_submodules"` option is useful to prevent packaging incomplete + The `"fail_on_missing_submodules"` option is useful to prevent packaging incomplete projects when submodules are required for a complete build. Note: This setting is overridden by any explicit `pre_parse` parameter passed to the git parse function. +`git_describe_command` (deprecated) +: **Deprecated since 8.4.0**: Use `scm.git.describe_command` instead. + + This field is maintained for backward compatibility but will issue a deprecation warning when used. + `normalize` : A boolean flag indicating if the version string should be normalized. Defaults to `True`. Setting this to `False` is equivalent to setting diff --git a/docs/usage.md b/docs/usage.md index ee7c168e..0579163e 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -40,9 +40,10 @@ dynamic = ["version"] # Configure custom options here (version schemes, file writing, etc.) version_file = "src/mypackage/_version.py" -# Example: Fail if submodules are not initialized (useful for projects requiring submodules) +# Example: Git-specific configuration [tool.setuptools_scm.scm.git] -pre_parse = "fail_on_missing_submodules" +pre_parse = "fail_on_missing_submodules" # Fail if submodules are not initialized +describe_command = "git describe --dirty --tags --long --exclude *js*" # Custom describe command ``` Both approaches will work with projects that support PEP 518 ([pip](https://pypi.org/project/pip) and diff --git a/src/setuptools_scm/_config.py b/src/setuptools_scm/_config.py index 210169c6..0387de9d 100644 --- a/src/setuptools_scm/_config.py +++ b/src/setuptools_scm/_config.py @@ -30,6 +30,57 @@ log = _log.log.getChild("config") + +def _is_called_from_dataclasses() -> bool: + """Check if the current call is from the dataclasses module.""" + import inspect + + frame = inspect.currentframe() + try: + # Walk up to 7 frames to check for dataclasses calls + current_frame = frame + assert current_frame is not None + for _ in range(7): + current_frame = current_frame.f_back + if current_frame is None: + break + if "dataclasses.py" in current_frame.f_code.co_filename: + return True + return False + finally: + del frame + + +class _GitDescribeCommandDescriptor: + """Data descriptor for deprecated git_describe_command field.""" + + def __get__( + self, obj: Configuration | None, objtype: type[Configuration] | None = None + ) -> _t.CMD_TYPE | None: + if obj is None: + return self # type: ignore[return-value] + + # Only warn if not being called by dataclasses.replace or similar introspection + is_from_dataclasses = _is_called_from_dataclasses() + if not is_from_dataclasses: + warnings.warn( + "Configuration field 'git_describe_command' is deprecated. " + "Use 'scm.git.describe_command' instead.", + DeprecationWarning, + stacklevel=2, + ) + return obj.scm.git.describe_command + + def __set__(self, obj: Configuration, value: _t.CMD_TYPE | None) -> None: + warnings.warn( + "Configuration field 'git_describe_command' is deprecated. " + "Use 'scm.git.describe_command' instead.", + DeprecationWarning, + stacklevel=2, + ) + obj.scm.git.describe_command = value + + DEFAULT_TAG_REGEX = re.compile( r"^(?:[\w-]+-)?(?P[vV]?\d+(?:\.\d+){0,2}[^\+]*)(?:\+.*)?$" ) @@ -101,6 +152,7 @@ class GitConfiguration: pre_parse: git.GitPreParse = dataclasses.field( default_factory=lambda: _get_default_git_pre_parse() ) + describe_command: _t.CMD_TYPE | None = None @classmethod def from_data(cls, data: dict[str, Any]) -> GitConfiguration: @@ -158,7 +210,10 @@ class Configuration: version_file: _t.PathT | None = None version_file_template: str | None = None parse: ParseFunction | None = None - git_describe_command: _t.CMD_TYPE | None = None + git_describe_command: dataclasses.InitVar[_t.CMD_TYPE | None] = ( + _GitDescribeCommandDescriptor() + ) + dist_name: str | None = None version_cls: type[_VersionT] = _Version search_parent_directories: bool = False @@ -170,9 +225,42 @@ class Configuration: default_factory=lambda: ScmConfiguration() ) - def __post_init__(self) -> None: + # Deprecated fields (handled in __post_init__) + + def __post_init__(self, git_describe_command: _t.CMD_TYPE | None) -> None: self.tag_regex = _check_tag_regex(self.tag_regex) + # Handle deprecated git_describe_command + # Check if it's a descriptor object (happens when no value is passed) + if git_describe_command is not None and not isinstance( + git_describe_command, _GitDescribeCommandDescriptor + ): + # Check if this is being called from dataclasses + is_from_dataclasses = _is_called_from_dataclasses() + + same_value = ( + self.scm.git.describe_command is not None + and self.scm.git.describe_command == git_describe_command + ) + + if is_from_dataclasses and same_value: + # Ignore the passed value - it's from dataclasses.replace() with same value + pass + else: + warnings.warn( + "Configuration field 'git_describe_command' is deprecated. " + "Use 'scm.git.describe_command' instead.", + DeprecationWarning, + stacklevel=2, + ) + # Check for conflicts + if self.scm.git.describe_command is not None: + raise ValueError( + "Cannot specify both 'git_describe_command' (deprecated) and " + "'scm.git.describe_command'. Please use only 'scm.git.describe_command'." + ) + self.scm.git.describe_command = git_describe_command + @property def absolute_root(self) -> str: return _check_absolute_root(self.root, self.relative_to) @@ -227,6 +315,9 @@ def from_data( # Handle nested SCM configuration scm_data = data.pop("scm", {}) + + # Handle nested SCM configuration + scm_config = ScmConfiguration.from_data(scm_data) return cls( diff --git a/src/setuptools_scm/_get_version_impl.py b/src/setuptools_scm/_get_version_impl.py index 40217cc6..ccbeac3b 100644 --- a/src/setuptools_scm/_get_version_impl.py +++ b/src/setuptools_scm/_get_version_impl.py @@ -154,6 +154,7 @@ def get_version( version_cls: Any | None = None, normalize: bool = True, search_parent_directories: bool = False, + scm: dict[str, Any] | None = None, ) -> str: """ If supplied, relative_to should be a file from which root may @@ -165,7 +166,19 @@ def get_version( version_cls = _validate_version_cls(version_cls, normalize) del normalize tag_regex = parse_tag_regex(tag_regex) - config = Configuration(**locals()) + + # Handle scm parameter by converting it to ScmConfiguration + if scm is not None: + scm_config = _config.ScmConfiguration.from_data(scm) + else: + scm_config = _config.ScmConfiguration() + + # Remove scm from locals() since we handle it separately + config_params = locals().copy() + config_params.pop("scm", None) + config_params.pop("scm_config", None) + + config = _config.Configuration(scm=scm_config, **config_params) maybe_version = _get_version(config, force_write_version_files=True) if maybe_version is None: diff --git a/src/setuptools_scm/git.py b/src/setuptools_scm/git.py index f778e96f..043d8ad4 100644 --- a/src/setuptools_scm/git.py +++ b/src/setuptools_scm/git.py @@ -330,8 +330,8 @@ def version_from_describe( config: Configuration, describe_command: _t.CMD_TYPE | None, ) -> ScmVersion | None: - if config.git_describe_command is not None: - describe_command = config.git_describe_command + if config.scm.git.describe_command is not None: + describe_command = config.scm.git.describe_command if describe_command is not None: if isinstance(describe_command, str): diff --git a/testing/test_git.py b/testing/test_git.py index e739fc94..c10fe476 100644 --- a/testing/test_git.py +++ b/testing/test_git.py @@ -435,7 +435,11 @@ def test_not_matching_tags(wd: WorkDir) -> None: wd.commit_testfile() assert wd.get_version( tag_regex=r"^apache-arrow-([\.0-9]+)$", - git_describe_command="git describe --dirty --tags --long --exclude *js* ", + scm={ + "git": { + "describe_command": "git describe --dirty --tags --long --exclude *js* " + } + }, ).startswith("0.11.2") @@ -758,3 +762,64 @@ def test_invalid_git_pre_parse_raises_error() -> None: ValueError, match="Invalid git pre_parse function 'invalid_function'" ): Configuration.from_data(relative_to=".", data=invalid_config_data) + + +def test_git_describe_command_backward_compatibility() -> None: + """Test backward compatibility for git_describe_command configuration.""" + # Test old configuration style still works with deprecation warning + old_config_data = { + "git_describe_command": "git describe --dirty --tags --long --exclude *js*" + } + + with pytest.warns(DeprecationWarning, match=r"git_describe_command.*deprecated"): + config = Configuration.from_data(relative_to=".", data=old_config_data) + + # Verify it was migrated to the new location + assert ( + config.scm.git.describe_command + == "git describe --dirty --tags --long --exclude *js*" + ) + + +def test_git_describe_command_from_data_conflict() -> None: + """Test that specifying both old and new configuration in from_data raises ValueError.""" + # Both old and new configuration specified - should raise ValueError + mixed_config_data = { + "git_describe_command": "old command", + "scm": {"git": {"describe_command": "new command"}}, + } + + # The Configuration constructor should handle the conflict detection + with pytest.warns(DeprecationWarning, match=r"git_describe_command.*deprecated"): + with pytest.raises( + ValueError, match=r"Cannot specify both.*git_describe_command" + ): + Configuration.from_data(relative_to=".", data=mixed_config_data) + + +def test_git_describe_command_init_argument_deprecation() -> None: + """Test that passing git_describe_command as init argument issues deprecation warning.""" + # Test init argument + with pytest.warns(DeprecationWarning, match=r"git_describe_command.*deprecated"): + config = Configuration(git_describe_command="test command") + + # Verify the value was migrated to the new location + assert config.scm.git.describe_command == "test command" + + +def test_git_describe_command_init_conflict() -> None: + """Test that specifying both old and new configuration raises ValueError.""" + from setuptools_scm._config import GitConfiguration + from setuptools_scm._config import ScmConfiguration + + # Both old init arg and new configuration specified - should raise ValueError + with pytest.warns(DeprecationWarning, match=r"git_describe_command.*deprecated"): + with pytest.raises( + ValueError, match=r"Cannot specify both.*git_describe_command" + ): + Configuration( + git_describe_command="old command", + scm=ScmConfiguration( + git=GitConfiguration(describe_command="new command") + ), + ) From c94d79711cdfd85ac05cb1f5d0b5e4edeb952c0b Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Mon, 28 Jul 2025 22:11:16 +0200 Subject: [PATCH 3/3] changelog --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c16454c8..90d24811 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - make Mercurial command configurable by environment variable `SETUPTOOLS_SCM_HG_COMMAND` - fix #1099 use file modification times for dirty working directory timestamps instead of current time - fix #1059: add `SETUPTOOLS_SCM_PRETEND_METADATA` environment variable to override individual ScmVersion fields +- add `scm` parameter support to `get_version()` function for nested SCM configuration ### Changed - add `pip` to test optional dependencies for improved uv venv compatibility @@ -27,7 +28,9 @@ - fix #1136: update customizing.md to fix missing import - fix #1001: document the missing version schemes and add examples in the docs - fix #1115: explicitly document file finder behaviour -- fix #879: add test that validates caswe differenct behavior +- fix #879: add test that validates case different behavior on windows +- migrate git describe command to new scm config +- add support for failing on missing submodules ## v8.3.1