Skip to content

Commit 5e0606d

Browse files
refactor: move overrides public API to vcs_versioning.overrides module
- Move GlobalOverrides class and all public accessor functions from _overrides.py to overrides.py - Keep only internal implementation details (_read_pretended_version_for, _apply_metadata_overrides, etc.) in _overrides.py - Add GlobalOverrides.from_active(**changes) method for creating modified copies - Add GlobalOverrides.export(target) method for exporting to env or monkeypatch - Make get_active_overrides() auto-create context from environment if none exists - Remove redundant test_log_levels_when_unset test - Update all internal imports to use public overrides module - Add comprehensive tests for from_active() and export() methods - Add integrator documentation at docs/integrators.md This establishes a clean separation between public API (vcs_versioning.overrides) and internal implementation (_overrides), making it easier for integrators to discover and use the overrides system.
1 parent 7f90733 commit 5e0606d

File tree

17 files changed

+1446
-162
lines changed

17 files changed

+1446
-162
lines changed

docs/integrators.md

Lines changed: 510 additions & 0 deletions
Large diffs are not rendered by default.

docs/overrides.md

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
# Overrides
22

3+
!!! info "For Integrators"
4+
5+
If you're building a tool that integrates vcs-versioning (like hatch-vcs), see the [Integrator Guide](integrators.md) for using the overrides API with custom prefixes and the `GlobalOverrides` context manager.
6+
37
## About Overrides
48

59
Environment variables provide runtime configuration overrides, primarily useful in CI/CD
610
environments where you need different behavior without modifying `pyproject.toml` or code.
711

12+
All environment variables support both `SETUPTOOLS_SCM_*` and `VCS_VERSIONING_*` prefixes. The `VCS_VERSIONING_*` prefix serves as a universal fallback that works across all tools using vcs-versioning.
13+
814
## Version Detection Overrides
915

1016
### Pretend Versions
@@ -13,15 +19,15 @@ Override the version number at build time.
1319

1420
**setuptools-scm usage:**
1521

16-
The environment variable `SETUPTOOLS_SCM_PRETEND_VERSION` is used
22+
The environment variable `SETUPTOOLS_SCM_PRETEND_VERSION` (or `VCS_VERSIONING_PRETEND_VERSION`) is used
1723
as the override source for the version number unparsed string.
1824

1925
!!! warning ""
2026

2127
it is strongly recommended to use distribution-specific pretend versions
2228
(see below).
2329

24-
`SETUPTOOLS_SCM_PRETEND_VERSION_FOR_${DIST_NAME}`
30+
`SETUPTOOLS_SCM_PRETEND_VERSION_FOR_${DIST_NAME}` or `VCS_VERSIONING_PRETEND_VERSION_FOR_${DIST_NAME}`
2531
: Used as the primary source for the version number,
2632
in which case it will be an unparsed string.
2733
Specifying distribution-specific pretend versions will
@@ -31,7 +37,7 @@ as the override source for the version number unparsed string.
3137
The dist name normalization follows adapted PEP 503 semantics, with one or
3238
more of ".-\_" being replaced by a single "\_", and the name being upper-cased.
3339

34-
This will take precedence over ``SETUPTOOLS_SCM_PRETEND_VERSION``.
40+
This will take precedence over the generic ``SETUPTOOLS_SCM_PRETEND_VERSION`` or ``VCS_VERSIONING_PRETEND_VERSION``.
3541

3642
### Pretend Metadata
3743

@@ -112,8 +118,13 @@ Enable debug output from vcs-versioning.
112118

113119
**setuptools-scm usage:**
114120

115-
`SETUPTOOLS_SCM_DEBUG`
121+
`SETUPTOOLS_SCM_DEBUG` or `VCS_VERSIONING_DEBUG`
116122
: Enable debug logging for version detection and processing.
123+
Can be set to:
124+
- `1` or any non-empty value to enable DEBUG level logging
125+
- A level name: `DEBUG`, `INFO`, `WARNING`, `ERROR`, or `CRITICAL` (case-insensitive)
126+
- A specific log level integer: `10` (DEBUG), `20` (INFO), `30` (WARNING), etc.
127+
- `0` to disable debug logging
117128

118129
### Reproducible Builds
119130

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ nav:
88
- integrations.md
99
- extending.md
1010
- overrides.md
11+
- integrators.md
1112
- changelog.md
1213
theme:
1314
name: material

setuptools-scm/testing_scm/test_file_finder.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -255,8 +255,9 @@ def test_hg_command_from_env(
255255
request: pytest.FixtureRequest,
256256
hg_exe: str,
257257
) -> None:
258-
with monkeypatch.context() as m:
259-
m.setenv("SETUPTOOLS_SCM_HG_COMMAND", hg_exe)
260-
m.setenv("PATH", str(hg_wd.cwd / "not-existing"))
261-
# No module reloading needed - runtime configuration works immediately
258+
from vcs_versioning.overrides import GlobalOverrides
259+
260+
monkeypatch.setenv("PATH", str(hg_wd.cwd / "not-existing"))
261+
# Use from_active() to create modified overrides with custom hg command
262+
with GlobalOverrides.from_active(hg_command=hg_exe):
262263
assert set(find_files()) == {"file"}

setuptools-scm/testing_scm/test_overrides.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from vcs_versioning._overrides import _find_close_env_var_matches
88
from vcs_versioning._overrides import _search_env_vars_with_prefix
9-
from vcs_versioning._overrides import read_named_env
9+
from vcs_versioning.overrides import read_named_env
1010

1111

1212
class TestSearchEnvVarsWithPrefix:

vcs-versioning/src/vcs_versioning/_backends/_hg.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@
2323

2424

2525
def _get_hg_command() -> str:
26-
"""Get the hg command from environment, allowing runtime configuration."""
27-
return os.environ.get("SETUPTOOLS_SCM_HG_COMMAND", "hg")
26+
"""Get the hg command from override context or environment."""
27+
from ..overrides import get_hg_command
28+
29+
return get_hg_command()
2830

2931

3032
def run_hg(args: list[str], cwd: _t.PathT, **kwargs: Any) -> CompletedProcess:

vcs-versioning/src/vcs_versioning/_cli.py

Lines changed: 36 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -17,42 +17,46 @@
1717
def main(
1818
args: list[str] | None = None, *, _given_pyproject_data: PyProjectData | None = None
1919
) -> int:
20-
# Configure logging at CLI entry point
21-
_log.configure_logging()
20+
from .overrides import GlobalOverrides
2221

23-
opts = _get_cli_opts(args)
24-
inferred_root: str = opts.root or "."
22+
# Apply global overrides for the entire CLI execution
23+
with GlobalOverrides.from_env("SETUPTOOLS_SCM"):
24+
# Configure logging at CLI entry point (uses overrides for debug level)
25+
_log.configure_logging()
2526

26-
pyproject = opts.config or _find_pyproject(inferred_root)
27+
opts = _get_cli_opts(args)
28+
inferred_root: str = opts.root or "."
2729

28-
try:
29-
config = Configuration.from_file(
30-
pyproject,
31-
root=(os.path.abspath(opts.root) if opts.root is not None else None),
32-
pyproject_data=_given_pyproject_data,
33-
)
34-
except (LookupError, FileNotFoundError) as ex:
35-
# no pyproject.toml OR no [tool.setuptools_scm]
36-
print(
37-
f"Warning: could not use {os.path.relpath(pyproject)},"
38-
" using default configuration.\n"
39-
f" Reason: {ex}.",
40-
file=sys.stderr,
41-
)
42-
config = Configuration(root=inferred_root)
43-
version: str | None
44-
if opts.no_version:
45-
version = "0.0.0+no-version-was-requested.fake-version"
46-
else:
47-
version = _get_version(
48-
config, force_write_version_files=opts.force_write_version_files
49-
)
50-
if version is None:
51-
raise SystemExit("ERROR: no version found for", opts)
52-
if opts.strip_dev:
53-
version = version.partition(".dev")[0]
30+
pyproject = opts.config or _find_pyproject(inferred_root)
5431

55-
return command(opts, version, config)
32+
try:
33+
config = Configuration.from_file(
34+
pyproject,
35+
root=(os.path.abspath(opts.root) if opts.root is not None else None),
36+
pyproject_data=_given_pyproject_data,
37+
)
38+
except (LookupError, FileNotFoundError) as ex:
39+
# no pyproject.toml OR no [tool.setuptools_scm]
40+
print(
41+
f"Warning: could not use {os.path.relpath(pyproject)},"
42+
" using default configuration.\n"
43+
f" Reason: {ex}.",
44+
file=sys.stderr,
45+
)
46+
config = Configuration(root=inferred_root)
47+
version: str | None
48+
if opts.no_version:
49+
version = "0.0.0+no-version-was-requested.fake-version"
50+
else:
51+
version = _get_version(
52+
config, force_write_version_files=opts.force_write_version_files
53+
)
54+
if version is None:
55+
raise SystemExit("ERROR: no version found for", opts)
56+
if opts.strip_dev:
57+
version = version.partition(".dev")[0]
58+
59+
return command(opts, version, config)
5660

5761

5862
def _get_cli_opts(args: list[str] | None) -> argparse.Namespace:

vcs-versioning/src/vcs_versioning/_log.py

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@
66

77
import contextlib
88
import logging
9-
import os
10-
from collections.abc import Iterator, Mapping
9+
from collections.abc import Iterator
1110

1211
# Logger names that need configuration
1312
LOGGER_NAMES = [
@@ -30,12 +29,18 @@ def make_default_handler() -> logging.Handler:
3029
return last_resort
3130

3231

33-
def _default_log_level(_env: Mapping[str, str] = os.environ) -> int:
34-
# Check both env vars for backward compatibility
35-
val: str | None = _env.get("VCS_VERSIONING_DEBUG") or _env.get(
36-
"SETUPTOOLS_SCM_DEBUG"
37-
)
38-
return logging.WARNING if val is None else logging.DEBUG
32+
def _default_log_level() -> int:
33+
"""Get default log level from active GlobalOverrides context.
34+
35+
Returns:
36+
logging level constant (DEBUG, WARNING, etc.)
37+
"""
38+
# Import here to avoid circular imports
39+
from .overrides import get_active_overrides
40+
41+
# Get log level from active override context
42+
overrides = get_active_overrides()
43+
return overrides.log_level()
3944

4045

4146
def _get_all_scm_loggers() -> list[logging.Logger]:
@@ -47,11 +52,12 @@ def _get_all_scm_loggers() -> list[logging.Logger]:
4752
_default_handler: logging.Handler | None = None
4853

4954

50-
def configure_logging(_env: Mapping[str, str] = os.environ) -> None:
55+
def configure_logging() -> None:
5156
"""Configure logging for all SCM-related loggers.
5257
5358
This should be called once at entry point (CLI, setuptools integration, etc.)
54-
before any actual logging occurs.
59+
before any actual logging occurs. Uses the active GlobalOverrides context
60+
to determine the log level.
5561
"""
5662
global _configured, _default_handler
5763
if _configured:
@@ -60,7 +66,7 @@ def configure_logging(_env: Mapping[str, str] = os.environ) -> None:
6066
if _default_handler is None:
6167
_default_handler = make_default_handler()
6268

63-
level = _default_log_level(_env)
69+
level = _default_log_level()
6470

6571
for logger in _get_all_scm_loggers():
6672
if not logger.handlers:

vcs-versioning/src/vcs_versioning/_overrides.py

Lines changed: 17 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1+
"""Internal implementation details for the overrides module.
2+
3+
This module contains private helpers and functions used internally
4+
by vcs_versioning. Public API is exposed via the overrides module.
5+
"""
6+
17
from __future__ import annotations
28

39
import dataclasses
410
import logging
5-
import os
611
from collections.abc import Mapping
712
from difflib import get_close_matches
813
from typing import Any
@@ -74,86 +79,13 @@ def _find_close_env_var_matches(
7479
candidates.append(suffix)
7580

7681
# Use difflib to find close matches
77-
close_matches = get_close_matches(
82+
close_matches_list = get_close_matches(
7883
expected_suffix, candidates, n=3, cutoff=threshold
7984
)
8085

81-
return [f"{prefix}{match}" for match in close_matches if match != expected_suffix]
82-
83-
84-
def read_named_env(
85-
*,
86-
tool: str = "SETUPTOOLS_SCM",
87-
name: str,
88-
dist_name: str | None,
89-
env: Mapping[str, str] = os.environ,
90-
) -> str | None:
91-
"""Read a named environment variable, with fallback search for dist-specific variants.
92-
93-
This function first tries the standard normalized environment variable name.
94-
If that's not found and a dist_name is provided, it searches for alternative
95-
normalizations and warns about potential issues.
96-
97-
Args:
98-
tool: The tool prefix (default: "SETUPTOOLS_SCM")
99-
name: The environment variable name component
100-
dist_name: The distribution name for dist-specific variables
101-
env: Environment dictionary to search in (defaults to os.environ)
102-
103-
Returns:
104-
The environment variable value if found, None otherwise
105-
"""
106-
107-
# First try the generic version
108-
generic_val = env.get(f"{tool}_{name}")
109-
110-
if dist_name is not None:
111-
# Normalize the dist name using packaging.utils.canonicalize_name
112-
canonical_dist_name = canonicalize_name(dist_name)
113-
env_var_dist_name = canonical_dist_name.replace("-", "_").upper()
114-
expected_env_var = f"{tool}_{name}_FOR_{env_var_dist_name}"
115-
116-
# Try the standard normalized name first
117-
val = env.get(expected_env_var)
118-
if val is not None:
119-
return val
120-
121-
# If not found, search for alternative normalizations
122-
prefix = f"{tool}_{name}_FOR_"
123-
alternative_matches = _search_env_vars_with_prefix(prefix, dist_name, env)
124-
125-
if alternative_matches:
126-
# Found alternative matches - use the first one but warn
127-
env_var, value = alternative_matches[0]
128-
log.warning(
129-
"Found environment variable '%s' for dist name '%s', "
130-
"but expected '%s'. Consider using the standard normalized name.",
131-
env_var,
132-
dist_name,
133-
expected_env_var,
134-
)
135-
if len(alternative_matches) > 1:
136-
other_vars = [var for var, _ in alternative_matches[1:]]
137-
log.warning(
138-
"Multiple alternative environment variables found: %s. Using '%s'.",
139-
other_vars,
140-
env_var,
141-
)
142-
return value
143-
144-
# No exact or alternative matches found - look for potential typos
145-
close_matches = _find_close_env_var_matches(prefix, env_var_dist_name, env)
146-
if close_matches:
147-
log.warning(
148-
"Environment variable '%s' not found for dist name '%s' "
149-
"(canonicalized as '%s'). Did you mean one of these? %s",
150-
expected_env_var,
151-
dist_name,
152-
canonical_dist_name,
153-
close_matches,
154-
)
155-
156-
return generic_val
86+
return [
87+
f"{prefix}{match}" for match in close_matches_list if match != expected_suffix
88+
]
15789

15890

15991
def _read_pretended_metadata_for(
@@ -167,6 +99,8 @@ def _read_pretended_metadata_for(
16799
Returns a dictionary with metadata field overrides like:
168100
{"node": "g1337beef", "distance": 4}
169101
"""
102+
from .overrides import read_named_env
103+
170104
log.debug("dist name: %s", config.dist_name)
171105

172106
pretended = read_named_env(name="PRETEND_METADATA", dist_name=config.dist_name)
@@ -281,6 +215,8 @@ def _read_pretended_version_for(
281215
tries ``SETUPTOOLS_SCM_PRETEND_VERSION``
282216
and ``SETUPTOOLS_SCM_PRETEND_VERSION_FOR_$UPPERCASE_DIST_NAME``
283217
"""
218+
from .overrides import read_named_env
219+
284220
log.debug("dist name: %s", config.dist_name)
285221

286222
pretended = read_named_env(name="PRETEND_VERSION", dist_name=config.dist_name)
@@ -292,5 +228,8 @@ def _read_pretended_version_for(
292228

293229

294230
def read_toml_overrides(dist_name: str | None) -> dict[str, Any]:
231+
"""Read TOML overrides from environment."""
232+
from .overrides import read_named_env
233+
295234
data = read_named_env(name="OVERRIDES", dist_name=dist_name)
296235
return load_toml_or_inline_map(data)

0 commit comments

Comments
 (0)