3232 _find_close_env_var_matches ,
3333 _search_env_vars_with_prefix ,
3434)
35+ from ._toml import load_toml_or_inline_map
3536
3637if TYPE_CHECKING :
3738 from pytest import MonkeyPatch
3839
3940log = 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 )
43194class 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