2020
2121from apcore .errors import (
2222 ConfigBindError ,
23+ ConfigEnvMapConflictError ,
2324 ConfigEnvPrefixConflictError ,
2425 ConfigError ,
2526 ConfigMountError ,
155156_RESERVED_NAMESPACES : frozenset [str ] = frozenset ({"apcore" , "_config" })
156157
157158
159+ _DEFAULT_MAX_DEPTH : int = 5
160+ _VALID_ENV_STYLES : frozenset [str ] = frozenset ({"nested" , "flat" , "auto" })
161+
162+
158163@dataclasses .dataclass
159164class _NamespaceRegistration :
160165 name : str
161166 schema : dict [str , Any ] | str | None
162- env_prefix : str | None
167+ env_prefix : str # auto-derived or explicit (never None after registration)
163168 defaults : dict [str , Any ] | None
169+ env_style : str # "auto" (default), "nested", or "flat"
170+ max_depth : int # max nesting depth for env conversion (default 5)
171+ env_map : dict [str , str ] | None # bare env var → config key mapping
164172
165173
166174_GLOBAL_NS_REGISTRY : dict [str , _NamespaceRegistration ] = {}
167175_GLOBAL_NS_REGISTRY_LOCK : threading .Lock = threading .Lock ()
176+ _GLOBAL_ENV_MAP : dict [str , str ] = {} # bare env var → top-level config key
177+ _GLOBAL_ENV_MAP_CLAIMED : dict [str , str ] = {} # env var → owner (for conflict detection)
168178
169179T = TypeVar ("T" )
170180
@@ -252,9 +262,106 @@ def _coerce_env_value(value: str) -> Any:
252262 return value
253263
254264
255- def _env_suffix_to_dot_path (suffix : str ) -> str :
256- """Convert env var suffix to dot-path (single _ → ., double __ → _)."""
257- return suffix .lower ().replace ("__" , "\x00 " ).replace ("_" , "." ).replace ("\x00 " , "_" )
265+ def _env_suffix_to_dot_path_with_depth (suffix : str , max_depth : int ) -> str :
266+ """Convert env var suffix to dot-path, stopping at *max_depth* segments.
267+
268+ After producing ``max_depth - 1`` dots (i.e. *max_depth* segments),
269+ remaining ``_`` characters are preserved as literal underscores.
270+ Double ``__`` always means literal ``_`` regardless of depth.
271+ """
272+ lower = suffix .lower ()
273+ result : list [str ] = []
274+ dot_count = 0
275+ i = 0
276+ while i < len (lower ):
277+ ch = lower [i ]
278+ if ch == "_" :
279+ if i + 1 < len (lower ) and lower [i + 1 ] == "_" :
280+ result .append ("_" ) # double __ → literal _
281+ i += 2
282+ elif dot_count < max_depth - 1 :
283+ result .append ("." )
284+ dot_count += 1
285+ i += 1
286+ else :
287+ result .append ("_" ) # depth limit reached
288+ i += 1
289+ else :
290+ result .append (ch )
291+ i += 1
292+ return "" .join (result )
293+
294+
295+ def _auto_resolve_suffix (
296+ suffix : str ,
297+ defaults : dict [str , Any ] | None ,
298+ max_depth : int ,
299+ ) -> str :
300+ """Resolve env var suffix using the *defaults* tree structure.
301+
302+ Tries the full suffix as a flat key first, then recursively splits at
303+ underscore positions to match nested dict keys. Falls back to
304+ ``_env_suffix_to_dot_path_with_depth`` when no match is found.
305+ """
306+ lower = suffix .lower ()
307+ if defaults is None :
308+ return _env_suffix_to_dot_path_with_depth (lower , max_depth )
309+
310+ result = _match_suffix_to_tree (lower , defaults , 0 , max_depth )
311+ if result is not None :
312+ return result
313+ # Fallback: nested conversion with depth limit.
314+ return _env_suffix_to_dot_path_with_depth (lower , max_depth )
315+
316+
317+ def _match_suffix_to_tree (
318+ suffix : str ,
319+ tree : dict [str , Any ],
320+ depth : int ,
321+ max_depth : int ,
322+ ) -> str | None :
323+ """Try to match *suffix* against keys in *tree* (recursive)."""
324+ # 1. Try full suffix as a flat key.
325+ if suffix in tree :
326+ return suffix
327+
328+ # 2. Depth limit reached — cannot split further.
329+ if depth >= max_depth - 1 :
330+ return None
331+
332+ # 3. Try splitting at each underscore position (left to right).
333+ for i , ch in enumerate (suffix ):
334+ if ch != "_" or i == 0 or i == len (suffix ) - 1 :
335+ continue
336+ prefix_part = suffix [:i ]
337+ remainder = suffix [i + 1 :]
338+ subtree = tree .get (prefix_part )
339+ if isinstance (subtree , dict ):
340+ sub = _match_suffix_to_tree (remainder , subtree , depth + 1 , max_depth )
341+ if sub is not None :
342+ return prefix_part + "." + sub
343+
344+ return None
345+
346+
347+ def _resolve_env_suffix (
348+ suffix : str ,
349+ registration : _NamespaceRegistration ,
350+ ) -> tuple [str , bool ]:
351+ """Resolve an env var suffix to a config key path.
352+
353+ Returns ``(key, is_nested)`` where *is_nested* indicates whether the key
354+ contains dots (i.e. should be stored via ``_set_nested``).
355+ """
356+ if registration .env_style == "flat" :
357+ key = suffix .lower ()
358+ return key , False
359+ if registration .env_style == "auto" :
360+ key = _auto_resolve_suffix (suffix , registration .defaults , registration .max_depth )
361+ return key , "." in key
362+ # "nested" (default)
363+ key = _env_suffix_to_dot_path_with_depth (suffix , registration .max_depth )
364+ return key , "." in key
258365
259366
260367def _apply_namespace_env_overrides (
@@ -263,35 +370,62 @@ def _apply_namespace_env_overrides(
263370) -> dict [str , Any ]:
264371 """Apply per-namespace env overrides using longest-prefix-match dispatch (§9.8.3).
265372
266- Sorted by env_prefix length descending so longer prefixes win.
373+ Handles three sources in order:
374+ 1. Global env_map (bare env var → top-level key)
375+ 2. Namespace env_map (bare env var → namespace key)
376+ 3. Prefix-based dispatch (MYAPP_FOO → myapp.foo)
267377 """
268378 result = copy .deepcopy (data )
269379
270- # Only namespaces with an env_prefix are eligible.
380+ # Build namespace env_map lookup.
381+ ns_env_maps : dict [str , tuple [str , str ]] = {} # env_var → (ns_name, config_key)
382+ for reg in registrations :
383+ if reg .env_map :
384+ for env_var , config_key in reg .env_map .items ():
385+ ns_env_maps [env_var ] = (reg .name , config_key )
386+
387+ # Prefix table: sorted by length descending for longest-prefix-match.
271388 prefixed = sorted (
272389 [r for r in registrations if r .env_prefix ],
273- key = lambda r : len (r .env_prefix or "" ),
390+ key = lambda r : len (r .env_prefix ),
274391 reverse = True ,
275392 )
276- if not prefixed :
277- return result
278393
279394 for env_key , env_value in os .environ .items ():
395+ coerced = _coerce_env_value (env_value )
396+
397+ # 1. Global env_map (bare env var → top-level key).
398+ if env_key in _GLOBAL_ENV_MAP :
399+ result [_GLOBAL_ENV_MAP [env_key ]] = coerced
400+ continue
401+
402+ # 2. Namespace env_map (bare env var → namespace key).
403+ if env_key in ns_env_maps :
404+ ns_name , config_key = ns_env_maps [env_key ]
405+ ns_data = result .setdefault (ns_name , {})
406+ ns_data [config_key ] = coerced
407+ continue
408+
409+ # 3. Prefix-based dispatch.
410+ if not prefixed :
411+ continue
280412 matched = _find_matching_ns_registration (env_key , prefixed )
281413 if matched is None :
282414 continue
283- prefix = matched .env_prefix or ""
415+ prefix = matched .env_prefix
284416 suffix = env_key [len (prefix ) :]
285417 if not suffix :
286418 continue
287- # Strip leading separator (single _ between prefix and key body)
288419 if suffix .startswith ("_" ):
289420 suffix = suffix [1 :]
290421 if not suffix :
291422 continue
292- dot_path = _env_suffix_to_dot_path (suffix )
423+ key , is_nested = _resolve_env_suffix (suffix , matched )
293424 ns_data = result .setdefault (matched .name , {})
294- _set_nested (ns_data , dot_path , _coerce_env_value (env_value ))
425+ if is_nested :
426+ _set_nested (ns_data , key , coerced )
427+ else :
428+ ns_data [key ] = coerced
295429
296430 return result
297431
@@ -418,45 +552,98 @@ def register_namespace(
418552 schema : dict [str , Any ] | str | None = None ,
419553 env_prefix : str | None = None ,
420554 defaults : dict [str , Any ] | None = None ,
555+ env_style : str | None = None ,
556+ max_depth : int | None = None ,
557+ env_map : dict [str , str ] | None = None ,
421558 ) -> None :
422559 """Register a namespace globally.
423560
424561 Args:
425562 name: Namespace name (must not be reserved or already registered).
426563 schema: Optional JSON Schema dict or path to a JSON Schema file.
427- env_prefix: Optional env var prefix (e.g. ``"APCORE_OBSERVABILITY"``).
564+ env_prefix: Env var prefix. When ``None``, auto-derived from
565+ ``name`` via ``name.upper().replace("-", "_")``. When an
566+ explicit string, used as-is.
428567 defaults: Optional default values for this namespace.
568+ env_style: Env var key conversion strategy (default ``"auto"``).
569+ max_depth: Max nesting depth for env key conversion (default 5).
570+ env_map: Explicit mapping of bare env var names to config keys
571+ within this namespace (e.g. ``{"REDIS_URL": "cache_url"}``).
429572
430573 Raises:
431574 ConfigNamespaceReservedError: If ``name`` is a reserved namespace.
432575 ConfigNamespaceDuplicateError: If ``name`` is already registered.
433- ConfigEnvPrefixConflictError: If ``env_prefix`` conflicts with an
434- existing prefix .
576+ ConfigEnvPrefixConflictError: If ``env_prefix`` conflicts.
577+ ConfigEnvMapConflictError: If an ``env_map`` key is already claimed .
435578 """
579+ resolved_style = env_style or "auto"
580+ if resolved_style not in _VALID_ENV_STYLES :
581+ msg = f"env_style must be one of { sorted (_VALID_ENV_STYLES )} , got { resolved_style !r} "
582+ raise ValueError (msg )
583+ resolved_depth = max_depth if max_depth is not None else _DEFAULT_MAX_DEPTH
584+ resolved_prefix = env_prefix if env_prefix is not None else name .upper ().replace ("-" , "_" )
585+
436586 if name in _RESERVED_NAMESPACES :
437587 raise ConfigNamespaceReservedError (name = name )
438588
439589 with _GLOBAL_NS_REGISTRY_LOCK :
440590 if name in _GLOBAL_NS_REGISTRY :
441591 raise ConfigNamespaceDuplicateError (name = name )
442592
443- if env_prefix is not None :
444- cls ._validate_env_prefix (env_prefix )
593+ cls ._validate_env_prefix (resolved_prefix )
594+
595+ if env_map :
596+ cls ._validate_env_map (env_map , owner = name )
445597
446598 _GLOBAL_NS_REGISTRY [name ] = _NamespaceRegistration (
447599 name = name ,
448600 schema = schema ,
449- env_prefix = env_prefix ,
601+ env_prefix = resolved_prefix ,
450602 defaults = defaults ,
603+ env_style = resolved_style ,
604+ max_depth = resolved_depth ,
605+ env_map = env_map ,
451606 )
452607
608+ @classmethod
609+ def env_map (cls , mapping : dict [str , str ]) -> None :
610+ """Register global bare env var → top-level config key mappings.
611+
612+ Args:
613+ mapping: Dict of env var names to config keys
614+ (e.g. ``{"PORT": "port", "DATABASE_URL": "db_url"}``).
615+
616+ Raises:
617+ ConfigEnvMapConflictError: If an env var is already claimed.
618+ """
619+ with _GLOBAL_NS_REGISTRY_LOCK :
620+ for env_var in mapping :
621+ if env_var in _GLOBAL_ENV_MAP_CLAIMED :
622+ owner = _GLOBAL_ENV_MAP_CLAIMED [env_var ]
623+ raise ConfigEnvMapConflictError (env_var = env_var , owner = owner )
624+ # All clean — register.
625+ for env_var , config_key in mapping .items ():
626+ _GLOBAL_ENV_MAP [env_var ] = config_key
627+ _GLOBAL_ENV_MAP_CLAIMED [env_var ] = "__global__"
628+
453629 @classmethod
454630 def _validate_env_prefix (cls , env_prefix : str ) -> None :
455631 """Raise ConfigEnvPrefixConflictError if env_prefix is already in use."""
456632 for reg in _GLOBAL_NS_REGISTRY .values ():
457- if reg .env_prefix and reg . env_prefix == env_prefix :
633+ if reg .env_prefix == env_prefix :
458634 raise ConfigEnvPrefixConflictError (env_prefix = env_prefix )
459635
636+ @classmethod
637+ def _validate_env_map (cls , env_map : dict [str , str ], owner : str ) -> None :
638+ """Raise ConfigEnvMapConflictError if any env var is already claimed."""
639+ for env_var in env_map :
640+ if env_var in _GLOBAL_ENV_MAP_CLAIMED :
641+ existing_owner = _GLOBAL_ENV_MAP_CLAIMED [env_var ]
642+ raise ConfigEnvMapConflictError (env_var = env_var , owner = existing_owner )
643+ # All clean — claim them.
644+ for env_var in env_map :
645+ _GLOBAL_ENV_MAP_CLAIMED [env_var ] = owner
646+
460647 @classmethod
461648 def registered_namespaces (cls ) -> list [dict [str , Any ]]:
462649 """Return a list of dicts describing all registered namespaces."""
0 commit comments