Skip to content

Commit bd3d484

Browse files
Add EnvReader class and refactor override reading
Introduces a new EnvReader class that consolidates environment variable reading logic with tool prefix fallback and distribution-specific variant support. This replaces scattered env var reading code with a single, reusable implementation. Key features: - EnvReader class with tools_names, env, and optional dist_name parameters - read() method for string env vars with automatic tool fallback - read_toml() method for TOML-formatted env vars - Built-in diagnostics for typos and alternative normalizations Refactoring: - GlobalOverrides.from_env() now uses EnvReader - read_toml_overrides() uses EnvReader.read_toml() - Inlined read_named_env() by replacing all usages with EnvReader - Removed read_named_env from public API All 58 existing override tests pass. Added 25 new EnvReader tests.
1 parent 1295d41 commit bd3d484

File tree

4 files changed

+492
-113
lines changed

4 files changed

+492
-113
lines changed

setuptools-scm/testing_scm/test_overrides.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,24 @@
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 EnvReader
10+
11+
12+
# Helper function that matches the old read_named_env signature for tests
13+
def read_named_env(
14+
*,
15+
name: str,
16+
dist_name: str | None,
17+
env: dict[str, str],
18+
tool: str = "SETUPTOOLS_SCM",
19+
) -> str | None:
20+
"""Test helper that wraps EnvReader to match old read_named_env signature."""
21+
reader = EnvReader(
22+
tools_names=(tool, "VCS_VERSIONING"),
23+
env=env,
24+
dist_name=dist_name,
25+
)
26+
return reader.read(name)
1027

1128

1229
class TestSearchEnvVarsWithPrefix:

vcs-versioning/src/vcs_versioning/_overrides.py

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -99,11 +99,18 @@ def _read_pretended_metadata_for(
9999
Returns a dictionary with metadata field overrides like:
100100
{"node": "g1337beef", "distance": 4}
101101
"""
102-
from .overrides import read_named_env
102+
import os
103+
104+
from .overrides import EnvReader
103105

104106
log.debug("dist name: %s", config.dist_name)
105107

106-
pretended = read_named_env(name="PRETEND_METADATA", dist_name=config.dist_name)
108+
reader = EnvReader(
109+
tools_names=("SETUPTOOLS_SCM", "VCS_VERSIONING"),
110+
env=os.environ,
111+
dist_name=config.dist_name,
112+
)
113+
pretended = reader.read("PRETEND_METADATA")
107114

108115
if pretended:
109116
try:
@@ -215,11 +222,18 @@ def _read_pretended_version_for(
215222
tries ``SETUPTOOLS_SCM_PRETEND_VERSION``
216223
and ``SETUPTOOLS_SCM_PRETEND_VERSION_FOR_$UPPERCASE_DIST_NAME``
217224
"""
218-
from .overrides import read_named_env
225+
import os
226+
227+
from .overrides import EnvReader
219228

220229
log.debug("dist name: %s", config.dist_name)
221230

222-
pretended = read_named_env(name="PRETEND_VERSION", dist_name=config.dist_name)
231+
reader = EnvReader(
232+
tools_names=("SETUPTOOLS_SCM", "VCS_VERSIONING"),
233+
env=os.environ,
234+
dist_name=config.dist_name,
235+
)
236+
pretended = reader.read("PRETEND_VERSION")
223237

224238
if pretended:
225239
return version.meta(tag=pretended, preformatted=True, config=config)
@@ -229,7 +243,13 @@ def _read_pretended_version_for(
229243

230244
def read_toml_overrides(dist_name: str | None) -> dict[str, Any]:
231245
"""Read TOML overrides from environment."""
232-
from .overrides import read_named_env
246+
import os
247+
248+
from .overrides import EnvReader
233249

234-
data = read_named_env(name="OVERRIDES", dist_name=dist_name)
235-
return load_toml_or_inline_map(data)
250+
reader = EnvReader(
251+
tools_names=("SETUPTOOLS_SCM", "VCS_VERSIONING"),
252+
env=os.environ,
253+
dist_name=dist_name,
254+
)
255+
return reader.read_toml("OVERRIDES")

vcs-versioning/src/vcs_versioning/overrides.py

Lines changed: 157 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,164 @@
3232
_find_close_env_var_matches,
3333
_search_env_vars_with_prefix,
3434
)
35+
from ._toml import load_toml_or_inline_map
3536

3637
if TYPE_CHECKING:
3738
from pytest import MonkeyPatch
3839

3940
log = logging.getLogger(__name__)
4041

4142

43+
class EnvReader:
44+
"""Helper class to read environment variables with tool prefix fallback.
45+
46+
This class provides a structured way to read environment variables by trying
47+
multiple tool prefixes in order, with support for distribution-specific variants.
48+
49+
Attributes:
50+
tools_names: Tuple of tool prefixes to try in order (e.g., ("HATCH_VCS", "VCS_VERSIONING"))
51+
env: Environment mapping to read from
52+
dist_name: Optional distribution name for dist-specific env vars
53+
54+
Example:
55+
>>> reader = EnvReader(
56+
... tools_names=("HATCH_VCS", "VCS_VERSIONING"),
57+
... env=os.environ,
58+
... dist_name="my-package"
59+
... )
60+
>>> debug_val = reader.read("DEBUG") # tries HATCH_VCS_DEBUG, then VCS_VERSIONING_DEBUG
61+
>>> pretend = reader.read("PRETEND_VERSION") # tries dist-specific first, then generic
62+
"""
63+
64+
tools_names: tuple[str, ...]
65+
env: Mapping[str, str]
66+
dist_name: str | None
67+
68+
def __init__(
69+
self,
70+
tools_names: tuple[str, ...],
71+
env: Mapping[str, str],
72+
dist_name: str | None = None,
73+
):
74+
"""Initialize the EnvReader.
75+
76+
Args:
77+
tools_names: Tuple of tool prefixes to try in order (e.g., ("HATCH_VCS", "VCS_VERSIONING"))
78+
env: Environment mapping to read from
79+
dist_name: Optional distribution name for dist-specific variables
80+
"""
81+
if not tools_names:
82+
raise TypeError("tools_names must be a non-empty tuple")
83+
self.tools_names = tools_names
84+
self.env = env
85+
self.dist_name = dist_name
86+
87+
def read(self, name: str) -> str | None:
88+
"""Read a named environment variable, trying each tool in tools_names order.
89+
90+
If dist_name is provided, tries distribution-specific variants first
91+
(e.g., TOOL_NAME_FOR_DIST), then falls back to generic variants (e.g., TOOL_NAME).
92+
93+
Also provides helpful diagnostics when similar environment variables are found
94+
but don't match exactly (e.g., typos or incorrect normalizations in distribution names).
95+
96+
Args:
97+
name: The environment variable name component (e.g., "DEBUG", "PRETEND_VERSION")
98+
99+
Returns:
100+
The first matching environment variable value, or None if not found
101+
"""
102+
# If dist_name is provided, try dist-specific variants first
103+
if self.dist_name is not None:
104+
canonical_dist_name = canonicalize_name(self.dist_name)
105+
env_var_dist_name = canonical_dist_name.replace("-", "_").upper()
106+
107+
# Try each tool's dist-specific variant
108+
for tool in self.tools_names:
109+
expected_env_var = f"{tool}_{name}_FOR_{env_var_dist_name}"
110+
val = self.env.get(expected_env_var)
111+
if val is not None:
112+
return val
113+
114+
# Try generic versions for each tool
115+
for tool in self.tools_names:
116+
val = self.env.get(f"{tool}_{name}")
117+
if val is not None:
118+
return val
119+
120+
# Not found - if dist_name is provided, check for common mistakes
121+
if self.dist_name is not None:
122+
canonical_dist_name = canonicalize_name(self.dist_name)
123+
env_var_dist_name = canonical_dist_name.replace("-", "_").upper()
124+
125+
# Try each tool prefix for fuzzy matching
126+
for tool in self.tools_names:
127+
expected_env_var = f"{tool}_{name}_FOR_{env_var_dist_name}"
128+
prefix = f"{tool}_{name}_FOR_"
129+
130+
# Search for alternative normalizations
131+
matches = _search_env_vars_with_prefix(prefix, self.dist_name, self.env)
132+
if matches:
133+
env_var_name, value = matches[0]
134+
log.warning(
135+
"Found environment variable '%s' for dist name '%s', "
136+
"but expected '%s'. Consider using the standard normalized name.",
137+
env_var_name,
138+
self.dist_name,
139+
expected_env_var,
140+
)
141+
if len(matches) > 1:
142+
other_vars = [var for var, _ in matches[1:]]
143+
log.warning(
144+
"Multiple alternative environment variables found: %s. Using '%s'.",
145+
other_vars,
146+
env_var_name,
147+
)
148+
return value
149+
150+
# Search for close matches (potential typos)
151+
close_matches = _find_close_env_var_matches(
152+
prefix, env_var_dist_name, self.env
153+
)
154+
if close_matches:
155+
log.warning(
156+
"Environment variable '%s' not found for dist name '%s' "
157+
"(canonicalized as '%s'). Did you mean one of these? %s",
158+
expected_env_var,
159+
self.dist_name,
160+
canonical_dist_name,
161+
close_matches,
162+
)
163+
164+
return None
165+
166+
def read_toml(self, name: str) -> dict[str, Any]:
167+
"""Read and parse a TOML-formatted environment variable.
168+
169+
This method is useful for reading structured configuration like:
170+
- Config overrides (e.g., TOOL_OVERRIDES_FOR_DIST)
171+
- ScmVersion metadata (e.g., TOOL_PRETEND_METADATA_FOR_DIST)
172+
173+
Supports both full TOML documents and inline TOML maps (starting with '{').
174+
175+
Args:
176+
name: The environment variable name component (e.g., "OVERRIDES", "PRETEND_METADATA")
177+
178+
Returns:
179+
Parsed TOML data as a dictionary, or an empty dict if not found or empty.
180+
Raises InvalidTomlError if the TOML content is malformed.
181+
182+
Example:
183+
>>> reader = EnvReader(tools_names=("TOOL",), env={
184+
... "TOOL_OVERRIDES": '{"local_scheme": "no-local-version"}',
185+
... })
186+
>>> reader.read_toml("OVERRIDES")
187+
{'local_scheme': 'no-local-version'}
188+
"""
189+
data = self.read(name)
190+
return load_toml_or_inline_map(data)
191+
192+
42193
@dataclass(frozen=True)
43194
class GlobalOverrides:
44195
"""Global environment variable overrides for VCS versioning.
@@ -82,17 +233,11 @@ def from_env(
82233
GlobalOverrides instance ready to use as context manager
83234
"""
84235

85-
# Helper to read with fallback to VCS_VERSIONING prefix
86-
def read_with_fallback(name: str) -> str | None:
87-
# Try tool-specific prefix first
88-
val = env.get(f"{tool}_{name}")
89-
if val is not None:
90-
return val
91-
# Fallback to VCS_VERSIONING prefix
92-
return env.get(f"VCS_VERSIONING_{name}")
236+
# Use EnvReader to read all environment variables with fallback
237+
reader = EnvReader(tools_names=(tool, "VCS_VERSIONING"), env=env)
93238

94239
# Read debug flag - support multiple formats
95-
debug_val = read_with_fallback("DEBUG")
240+
debug_val = reader.read("DEBUG")
96241
if debug_val is None:
97242
debug: int | Literal[False] = False
98243
else:
@@ -117,7 +262,7 @@ def read_with_fallback(name: str) -> str | None:
117262
debug = logging.DEBUG
118263

119264
# Read subprocess timeout
120-
timeout_val = read_with_fallback("SUBPROCESS_TIMEOUT")
265+
timeout_val = reader.read("SUBPROCESS_TIMEOUT")
121266
subprocess_timeout = 40 # default
122267
if timeout_val is not None:
123268
try:
@@ -130,7 +275,7 @@ def read_with_fallback(name: str) -> str | None:
130275
)
131276

132277
# Read hg command
133-
hg_command = read_with_fallback("HG_COMMAND") or "hg"
278+
hg_command = reader.read("HG_COMMAND") or "hg"
134279

135280
# Read SOURCE_DATE_EPOCH (standard env var, no prefix)
136281
source_date_epoch_val = env.get("SOURCE_DATE_EPOCH")
@@ -362,106 +507,13 @@ def source_epoch_or_utc_now() -> datetime:
362507
return get_active_overrides().source_epoch_or_utc_now()
363508

364509

365-
def read_named_env(
366-
*,
367-
tool: str = "SETUPTOOLS_SCM",
368-
name: str,
369-
dist_name: str | None,
370-
env: Mapping[str, str] = os.environ,
371-
) -> str | None:
372-
"""Read a named environment variable, with fallback search for dist-specific variants.
373-
374-
This function first tries the standard normalized environment variable name with the
375-
tool prefix, then falls back to VCS_VERSIONING prefix if not found.
376-
If that's not found and a dist_name is provided, it searches for alternative
377-
normalizations and warns about potential issues.
378-
379-
Args:
380-
tool: The tool prefix (default: "SETUPTOOLS_SCM")
381-
name: The environment variable name component
382-
dist_name: The distribution name for dist-specific variables
383-
env: Environment dictionary to search in (defaults to os.environ)
384-
385-
Returns:
386-
The environment variable value if found, None otherwise
387-
"""
388-
389-
# First try the generic version with tool prefix
390-
generic_val = env.get(f"{tool}_{name}")
391-
392-
# If not found, try VCS_VERSIONING prefix as fallback
393-
if generic_val is None:
394-
generic_val = env.get(f"VCS_VERSIONING_{name}")
395-
396-
if dist_name is not None:
397-
# Normalize the dist name using packaging.utils.canonicalize_name
398-
canonical_dist_name = canonicalize_name(dist_name)
399-
env_var_dist_name = canonical_dist_name.replace("-", "_").upper()
400-
expected_env_var = f"{tool}_{name}_FOR_{env_var_dist_name}"
401-
402-
# Try the standard normalized name with tool prefix first
403-
val = env.get(expected_env_var)
404-
if val is not None:
405-
return val
406-
407-
# Try VCS_VERSIONING prefix as fallback for dist-specific
408-
vcs_versioning_var = f"VCS_VERSIONING_{name}_FOR_{env_var_dist_name}"
409-
val = env.get(vcs_versioning_var)
410-
if val is not None:
411-
return val
412-
413-
# If not found, search for alternative normalizations with tool prefix
414-
prefix = f"{tool}_{name}_FOR_"
415-
alternative_matches = _search_env_vars_with_prefix(prefix, dist_name, env)
416-
417-
# Also search in VCS_VERSIONING prefix
418-
if not alternative_matches:
419-
vcs_prefix = f"VCS_VERSIONING_{name}_FOR_"
420-
alternative_matches = _search_env_vars_with_prefix(
421-
vcs_prefix, dist_name, env
422-
)
423-
424-
if alternative_matches:
425-
# Found alternative matches - use the first one but warn
426-
env_var, value = alternative_matches[0]
427-
log.warning(
428-
"Found environment variable '%s' for dist name '%s', "
429-
"but expected '%s'. Consider using the standard normalized name.",
430-
env_var,
431-
dist_name,
432-
expected_env_var,
433-
)
434-
if len(alternative_matches) > 1:
435-
other_vars = [var for var, _ in alternative_matches[1:]]
436-
log.warning(
437-
"Multiple alternative environment variables found: %s. Using '%s'.",
438-
other_vars,
439-
env_var,
440-
)
441-
return value
442-
443-
# No exact or alternative matches found - look for potential typos
444-
close_matches = _find_close_env_var_matches(prefix, env_var_dist_name, env)
445-
if close_matches:
446-
log.warning(
447-
"Environment variable '%s' not found for dist name '%s' "
448-
"(canonicalized as '%s'). Did you mean one of these? %s",
449-
expected_env_var,
450-
dist_name,
451-
canonical_dist_name,
452-
close_matches,
453-
)
454-
455-
return generic_val
456-
457-
458510
__all__ = [
511+
"EnvReader",
459512
"GlobalOverrides",
460513
"get_active_overrides",
461514
"get_debug_level",
462515
"get_hg_command",
463516
"get_source_date_epoch",
464517
"get_subprocess_timeout",
465-
"read_named_env",
466518
"source_epoch_or_utc_now",
467519
]

0 commit comments

Comments
 (0)