Skip to content

Commit 1a4dbb5

Browse files
fix #1059: add scm version overrides via env vars
1 parent 1b4cc28 commit 1a4dbb5

File tree

6 files changed

+372
-59
lines changed

6 files changed

+372
-59
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
- add `setuptools-scm` console_scripts entry point to make the CLI directly executable
88
- make Mercurial command configurable by environment variable `SETUPTOOLS_SCM_HG_COMMAND`
99
- fix #1099 use file modification times for dirty working directory timestamps instead of current time
10-
10+
- fix #1059: add `SETUPTOOLS_SCM_PRETEND_METADATA` environment variable to override individual ScmVersion fields
1111
### Changed
1212

1313
- add `pip` to test optional dependencies for improved uv venv compatibility

docs/overrides.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,67 @@ as the override source for the version number unparsed string.
1010
to be specific about the package this applies for, one can use `SETUPTOOLS_SCM_PRETEND_VERSION_FOR_${NORMALIZED_DIST_NAME}`
1111
where the dist name normalization follows adapted PEP 503 semantics.
1212

13+
## pretend metadata
14+
15+
setuptools-scm provides a mechanism to override individual version metadata fields at build time.
16+
17+
The environment variable `SETUPTOOLS_SCM_PRETEND_METADATA` accepts a TOML inline table
18+
with field overrides for the ScmVersion object.
19+
20+
To be specific about the package this applies for, one can use `SETUPTOOLS_SCM_PRETEND_METADATA_FOR_${NORMALIZED_DIST_NAME}`
21+
where the dist name normalization follows adapted PEP 503 semantics.
22+
23+
### Supported fields
24+
25+
The following ScmVersion fields can be overridden:
26+
27+
- `distance` (int): Number of commits since the tag
28+
- `node` (str): The commit hash/node identifier
29+
- `dirty` (bool): Whether the working directory has uncommitted changes
30+
- `branch` (str): The branch name
31+
- `node_date` (date): The date of the commit (TOML date format: `2024-01-15`)
32+
- `time` (datetime): The version timestamp (TOML datetime format)
33+
- `preformatted` (bool): Whether the version string is preformatted
34+
- `tag`: The version tag (can be string or version object)
35+
36+
### Examples
37+
38+
Override commit hash and distance:
39+
```bash
40+
export SETUPTOOLS_SCM_PRETEND_METADATA='{node="g1337beef", distance=4}'
41+
```
42+
43+
Override multiple fields with proper TOML types:
44+
```bash
45+
export SETUPTOOLS_SCM_PRETEND_METADATA='{node="gabcdef12", distance=7, dirty=true, node_date=2024-01-15}'
46+
```
47+
48+
Use with a specific package:
49+
```bash
50+
export SETUPTOOLS_SCM_PRETEND_METADATA_FOR_MY_PACKAGE='{node="g1234567", distance=2}'
51+
```
52+
53+
### Use case: CI/CD environments
54+
55+
This is particularly useful for solving issues where version file templates need access to
56+
commit metadata that may not be available in certain build environments:
57+
58+
```toml
59+
[tool.setuptools_scm]
60+
version_file = "src/mypackage/_version.py"
61+
version_file_template = '''
62+
version = "{version}"
63+
commit_hash = "{scm_version.node}"
64+
commit_count = {scm_version.distance}
65+
'''
66+
```
67+
68+
With pretend metadata, you can ensure the template gets the correct values:
69+
```bash
70+
export SETUPTOOLS_SCM_PRETEND_VERSION="1.2.3.dev4+g1337beef"
71+
export SETUPTOOLS_SCM_PRETEND_METADATA='{node="g1337beef", distance=4}'
72+
```
73+
1374
## config overrides
1475

1576
setuptools-scm parses the environment variable `SETUPTOOLS_SCM_OVERRIDES_FOR_${NORMALIZED_DIST_NAME}`

src/setuptools_scm/_get_version_impl.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,18 @@ def parse_fallback_version(config: Configuration) -> ScmVersion | None:
5656

5757

5858
def parse_version(config: Configuration) -> ScmVersion | None:
59-
return (
59+
# First try to get a version from the normal flow
60+
scm_version = (
6061
_read_pretended_version_for(config)
6162
or parse_scm_version(config)
6263
or parse_fallback_version(config)
6364
)
6465

66+
# Apply any metadata overrides to the version we found
67+
from ._overrides import _apply_metadata_overrides
68+
69+
return _apply_metadata_overrides(scm_version, config)
70+
6571

6672
def write_version_files(
6773
config: Configuration, version: str, scm_version: ScmVersion

src/setuptools_scm/_overrides.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import dataclasses
34
import os
45
import re
56

@@ -14,6 +15,8 @@
1415

1516
PRETEND_KEY = "SETUPTOOLS_SCM_PRETEND_VERSION"
1617
PRETEND_KEY_NAMED = PRETEND_KEY + "_FOR_{name}"
18+
PRETEND_METADATA_KEY = "SETUPTOOLS_SCM_PRETEND_METADATA"
19+
PRETEND_METADATA_KEY_NAMED = PRETEND_METADATA_KEY + "_FOR_{name}"
1720

1821

1922
def read_named_env(
@@ -30,6 +33,124 @@ def read_named_env(
3033
return os.environ.get(f"{tool}_{name}")
3134

3235

36+
def _read_pretended_metadata_for(
37+
config: _config.Configuration,
38+
) -> dict[str, Any] | None:
39+
"""read overridden metadata from the environment
40+
41+
tries ``SETUPTOOLS_SCM_PRETEND_METADATA``
42+
and ``SETUPTOOLS_SCM_PRETEND_METADATA_FOR_$UPPERCASE_DIST_NAME``
43+
44+
Returns a dictionary with metadata field overrides like:
45+
{"node": "g1337beef", "distance": 4}
46+
"""
47+
log.debug("dist name: %s", config.dist_name)
48+
49+
pretended = read_named_env(name="PRETEND_METADATA", dist_name=config.dist_name)
50+
51+
if pretended:
52+
try:
53+
metadata_overrides = load_toml_or_inline_map(pretended)
54+
# Validate that only known ScmVersion fields are provided
55+
valid_fields = {
56+
"tag",
57+
"distance",
58+
"node",
59+
"dirty",
60+
"preformatted",
61+
"branch",
62+
"node_date",
63+
"time",
64+
}
65+
invalid_fields = set(metadata_overrides.keys()) - valid_fields
66+
if invalid_fields:
67+
log.warning(
68+
"Invalid metadata fields in pretend metadata: %s. "
69+
"Valid fields are: %s",
70+
invalid_fields,
71+
valid_fields,
72+
)
73+
# Remove invalid fields but continue processing
74+
for field in invalid_fields:
75+
metadata_overrides.pop(field)
76+
77+
return metadata_overrides or None
78+
except Exception as e:
79+
log.error("Failed to parse pretend metadata: %s", e)
80+
return None
81+
else:
82+
return None
83+
84+
85+
def _apply_metadata_overrides(
86+
scm_version: version.ScmVersion | None,
87+
config: _config.Configuration,
88+
) -> version.ScmVersion | None:
89+
"""Apply metadata overrides to a ScmVersion object.
90+
91+
This function reads pretend metadata from environment variables and applies
92+
the overrides to the given ScmVersion. TOML type coercion is used so values
93+
should be provided in their correct types (int, bool, datetime, etc.).
94+
95+
Args:
96+
scm_version: The ScmVersion to apply overrides to, or None
97+
config: Configuration object
98+
99+
Returns:
100+
Modified ScmVersion with overrides applied, or None
101+
"""
102+
metadata_overrides = _read_pretended_metadata_for(config)
103+
104+
if not metadata_overrides:
105+
return scm_version
106+
107+
if scm_version is None:
108+
log.warning(
109+
"PRETEND_METADATA specified but no base version found. "
110+
"Metadata overrides cannot be applied without a base version."
111+
)
112+
return None
113+
114+
log.info("Applying metadata overrides: %s", metadata_overrides)
115+
116+
# Define type checks and field mappings
117+
from datetime import date
118+
from datetime import datetime
119+
120+
field_specs: dict[str, tuple[type | tuple[type, type], str]] = {
121+
"distance": (int, "int"),
122+
"dirty": (bool, "bool"),
123+
"preformatted": (bool, "bool"),
124+
"node_date": (date, "date"),
125+
"time": (datetime, "datetime"),
126+
"node": ((str, type(None)), "str or None"),
127+
"branch": ((str, type(None)), "str or None"),
128+
# tag is special - can be multiple types, handled separately
129+
}
130+
131+
# Apply each override individually using dataclasses.replace for type safety
132+
result = scm_version
133+
134+
for field, value in metadata_overrides.items():
135+
if field in field_specs:
136+
expected_type, type_name = field_specs[field]
137+
assert isinstance(value, expected_type), (
138+
f"{field} must be {type_name}, got {type(value).__name__}: {value!r}"
139+
)
140+
result = dataclasses.replace(result, **{field: value})
141+
elif field == "tag":
142+
# tag can be Version, NonNormalizedVersion, or str - we'll let the assignment handle validation
143+
result = dataclasses.replace(result, tag=value)
144+
else:
145+
# This shouldn't happen due to validation in _read_pretended_metadata_for
146+
log.warning("Unknown field '%s' in metadata overrides", field)
147+
148+
# Ensure config is preserved (should not be overridden)
149+
assert result.config is config, "Config must be preserved during metadata overrides"
150+
151+
return result
152+
153+
33154
def _read_pretended_version_for(
34155
config: _config.Configuration,
35156
) -> version.ScmVersion | None:

0 commit comments

Comments
 (0)