Skip to content

Commit ed3f677

Browse files
introduce a mechanism to configure git pre-parse+submodule checks
fixes #846
1 parent c6e6e3f commit ed3f677

File tree

6 files changed

+311
-7
lines changed

6 files changed

+311
-7
lines changed

docs/config.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,20 @@ Callables or other Python objects have to be passed in `setup.py` (via the `use_
9797

9898
Defaults to the value set by [setuptools_scm.git.DEFAULT_DESCRIBE][]
9999

100+
`scm.git.pre_parse`
101+
: A string specifying which git pre-parse function to use before parsing version information.
102+
Available options:
103+
104+
- `"warn_on_shallow"` (default): Warns when the repository is shallow
105+
- `"fail_on_shallow"`: Fails with an error when the repository is shallow
106+
- `"fetch_on_shallow"`: Automatically fetches to rectify shallow repositories
107+
- `"fail_on_missing_submodules"`: Fails when submodules are defined but not initialized
108+
109+
The `"fail_on_missing_submodules"` option is useful to prevent packaging incomplete
110+
projects when submodules are required for a complete build.
111+
112+
Note: This setting is overridden by any explicit `pre_parse` parameter passed to the git parse function.
113+
100114
`normalize`
101115
: A boolean flag indicating if the version string should be normalized.
102116
Defaults to `True`. Setting this to `False` is equivalent to setting

docs/usage.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ dynamic = ["version"]
3939
[tool.setuptools_scm]
4040
# Configure custom options here (version schemes, file writing, etc.)
4141
version_file = "src/mypackage/_version.py"
42+
43+
# Example: Fail if submodules are not initialized (useful for projects requiring submodules)
44+
[tool.setuptools_scm.scm.git]
45+
pre_parse = "fail_on_missing_submodules"
4246
```
4347

4448
Both approaches will work with projects that support PEP 518 ([pip](https://pypi.org/project/pip) and

src/setuptools_scm/_config.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,14 @@
88
import warnings
99

1010
from pathlib import Path
11+
from typing import TYPE_CHECKING
1112
from typing import Any
1213
from typing import Pattern
1314
from typing import Protocol
1415

16+
if TYPE_CHECKING:
17+
from . import git
18+
1519
from . import _log
1620
from . import _types as _t
1721
from ._integration.pyproject_reading import PyProjectData
@@ -52,6 +56,13 @@ def _check_tag_regex(value: str | Pattern[str] | None) -> Pattern[str]:
5256
return regex
5357

5458

59+
def _get_default_git_pre_parse() -> git.GitPreParse:
60+
"""Get the default git pre_parse enum value"""
61+
from . import git
62+
63+
return git.GitPreParse.WARN_ON_SHALLOW
64+
65+
5566
class ParseFunction(Protocol):
5667
def __call__(
5768
self, root: _t.PathT, *, config: Configuration
@@ -83,6 +94,53 @@ def _check_absolute_root(root: _t.PathT, relative_to: _t.PathT | None) -> str:
8394
return os.path.abspath(root)
8495

8596

97+
@dataclasses.dataclass
98+
class GitConfiguration:
99+
"""Git-specific configuration options"""
100+
101+
pre_parse: git.GitPreParse = dataclasses.field(
102+
default_factory=lambda: _get_default_git_pre_parse()
103+
)
104+
105+
@classmethod
106+
def from_data(cls, data: dict[str, Any]) -> GitConfiguration:
107+
"""Create GitConfiguration from configuration data, converting strings to enums"""
108+
git_data = data.copy()
109+
110+
# Convert string pre_parse values to enum instances
111+
if "pre_parse" in git_data and isinstance(git_data["pre_parse"], str):
112+
from . import git
113+
114+
try:
115+
git_data["pre_parse"] = git.GitPreParse(git_data["pre_parse"])
116+
except ValueError as e:
117+
valid_options = [option.value for option in git.GitPreParse]
118+
raise ValueError(
119+
f"Invalid git pre_parse function '{git_data['pre_parse']}'. "
120+
f"Valid options are: {', '.join(valid_options)}"
121+
) from e
122+
123+
return cls(**git_data)
124+
125+
126+
@dataclasses.dataclass
127+
class ScmConfiguration:
128+
"""SCM-specific configuration options"""
129+
130+
git: GitConfiguration = dataclasses.field(default_factory=GitConfiguration)
131+
132+
@classmethod
133+
def from_data(cls, data: dict[str, Any]) -> ScmConfiguration:
134+
"""Create ScmConfiguration from configuration data"""
135+
scm_data = data.copy()
136+
137+
# Handle git-specific configuration
138+
git_data = scm_data.pop("git", {})
139+
git_config = GitConfiguration.from_data(git_data)
140+
141+
return cls(git=git_config, **scm_data)
142+
143+
86144
@dataclasses.dataclass
87145
class Configuration:
88146
"""Global configuration model"""
@@ -107,6 +165,11 @@ class Configuration:
107165

108166
parent: _t.PathT | None = None
109167

168+
# Nested SCM configurations
169+
scm: ScmConfiguration = dataclasses.field(
170+
default_factory=lambda: ScmConfiguration()
171+
)
172+
110173
def __post_init__(self) -> None:
111174
self.tag_regex = _check_tag_regex(self.tag_regex)
112175

@@ -161,8 +224,14 @@ def from_data(
161224
version_cls = _validate_version_cls(
162225
data.pop("version_cls", None), data.pop("normalize", True)
163226
)
227+
228+
# Handle nested SCM configuration
229+
scm_data = data.pop("scm", {})
230+
scm_config = ScmConfiguration.from_data(scm_data)
231+
164232
return cls(
165233
relative_to=relative_to,
166234
version_cls=version_cls,
235+
scm=scm_config,
167236
**data,
168237
)

src/setuptools_scm/_types.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,6 @@
2626
VERSION_SCHEME: TypeAlias = Union[str, Callable[["version.ScmVersion"], str]]
2727
VERSION_SCHEMES: TypeAlias = Union[List[str], Tuple[str, ...], VERSION_SCHEME]
2828
SCMVERSION: TypeAlias = "version.ScmVersion"
29+
30+
# Git pre-parse function types
31+
GIT_PRE_PARSE: TypeAlias = Union[str, None]

src/setuptools_scm/git.py

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from datetime import date
1212
from datetime import datetime
1313
from datetime import timezone
14+
from enum import Enum
1415
from os.path import samefile
1516
from pathlib import Path
1617
from typing import TYPE_CHECKING
@@ -52,6 +53,15 @@
5253
]
5354

5455

56+
class GitPreParse(Enum):
57+
"""Available git pre-parse functions"""
58+
59+
WARN_ON_SHALLOW = "warn_on_shallow"
60+
FAIL_ON_SHALLOW = "fail_on_shallow"
61+
FETCH_ON_SHALLOW = "fetch_on_shallow"
62+
FAIL_ON_MISSING_SUBMODULES = "fail_on_missing_submodules"
63+
64+
5565
def run_git(
5666
args: Sequence[str | os.PathLike[str]],
5767
repo: Path,
@@ -209,6 +219,65 @@ def fail_on_shallow(wd: GitWorkdir) -> None:
209219
)
210220

211221

222+
def fail_on_missing_submodules(wd: GitWorkdir) -> None:
223+
"""
224+
Fail if submodules are defined but not initialized/cloned.
225+
226+
This pre_parse function checks if there are submodules defined in .gitmodules
227+
but not properly initialized (cloned). This helps prevent packaging incomplete
228+
projects when submodules are required for a complete build.
229+
"""
230+
gitmodules_path = wd.path / ".gitmodules"
231+
if not gitmodules_path.exists():
232+
# No submodules defined, nothing to check
233+
return
234+
235+
# Get submodule status - lines starting with '-' indicate uninitialized submodules
236+
status_result = run_git(["submodule", "status"], wd.path)
237+
if status_result.returncode != 0:
238+
# Command failed, might not be in a git repo or other error
239+
log.debug("Failed to check submodule status: %s", status_result.stderr)
240+
return
241+
242+
status_lines = (
243+
status_result.stdout.strip().split("\n") if status_result.stdout.strip() else []
244+
)
245+
uninitialized_submodules = []
246+
247+
for line in status_lines:
248+
line = line.strip()
249+
if line.startswith("-"):
250+
# Extract submodule path (everything after the commit hash)
251+
parts = line.split()
252+
if len(parts) >= 2:
253+
submodule_path = parts[1]
254+
uninitialized_submodules.append(submodule_path)
255+
256+
# If .gitmodules exists but git submodule status returns nothing,
257+
# it means submodules are defined but not properly set up (common after cloning without --recurse-submodules)
258+
if not status_lines and gitmodules_path.exists():
259+
raise ValueError(
260+
f"Submodules are defined in .gitmodules but not initialized in {wd.path}. "
261+
f"Please run 'git submodule update --init --recursive' to initialize them."
262+
)
263+
264+
if uninitialized_submodules:
265+
submodule_list = ", ".join(uninitialized_submodules)
266+
raise ValueError(
267+
f"Submodules are not initialized in {wd.path}: {submodule_list}. "
268+
f"Please run 'git submodule update --init --recursive' to initialize them."
269+
)
270+
271+
272+
# Mapping from enum items to actual pre_parse functions
273+
_GIT_PRE_PARSE_FUNCTIONS: dict[GitPreParse, Callable[[GitWorkdir], None]] = {
274+
GitPreParse.WARN_ON_SHALLOW: warn_on_shallow,
275+
GitPreParse.FAIL_ON_SHALLOW: fail_on_shallow,
276+
GitPreParse.FETCH_ON_SHALLOW: fetch_on_shallow,
277+
GitPreParse.FAIL_ON_MISSING_SUBMODULES: fail_on_missing_submodules,
278+
}
279+
280+
212281
def get_working_directory(config: Configuration, root: _t.PathT) -> GitWorkdir | None:
213282
"""
214283
Return the working directory (``GitWorkdir``).
@@ -231,16 +300,26 @@ def parse(
231300
root: _t.PathT,
232301
config: Configuration,
233302
describe_command: str | list[str] | None = None,
234-
pre_parse: Callable[[GitWorkdir], None] = warn_on_shallow,
303+
pre_parse: Callable[[GitWorkdir], None] | None = None,
235304
) -> ScmVersion | None:
236305
"""
237-
:param pre_parse: experimental pre_parse action, may change at any time
306+
:param pre_parse: experimental pre_parse action, may change at any time.
307+
Takes precedence over config.git_pre_parse if provided.
238308
"""
239309
_require_command("git")
240310
wd = get_working_directory(config, root)
241311
if wd:
312+
# Use function parameter first, then config setting, then default
313+
if pre_parse is not None:
314+
effective_pre_parse = pre_parse
315+
else:
316+
# config.scm.git.pre_parse is always a GitPreParse enum instance
317+
effective_pre_parse = _GIT_PRE_PARSE_FUNCTIONS.get(
318+
config.scm.git.pre_parse, warn_on_shallow
319+
)
320+
242321
return _git_parse_inner(
243-
config, wd, describe_command=describe_command, pre_parse=pre_parse
322+
config, wd, describe_command=describe_command, pre_parse=effective_pre_parse
244323
)
245324
else:
246325
return None

0 commit comments

Comments
 (0)