Skip to content

Commit c83947a

Browse files
committed
feat: add support for custom environment variable mapping, flat/auto env styles, and configurable nesting depth in Config namespaces
Signed-off-by: tercel <tercel.yi@gmail.com>
1 parent b636035 commit c83947a

5 files changed

Lines changed: 410 additions & 20 deletions

File tree

src/apcore/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
CircularCallError,
6767
CircularDependencyError,
6868
ConfigBindError,
69+
ConfigEnvMapConflictError,
6970
ConfigEnvPrefixConflictError,
7071
ConfigError,
7172
ConfigMountError,
@@ -393,6 +394,7 @@ def enable(module_id: str, reason: str = "Enabled via APCore client") -> dict[st
393394
"CircularCallError",
394395
"CircularDependencyError",
395396
"ConfigBindError",
397+
"ConfigEnvMapConflictError",
396398
"ConfigEnvPrefixConflictError",
397399
"ConfigError",
398400
"ConfigMountError",

src/apcore/config.py

Lines changed: 207 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
from apcore.errors import (
2222
ConfigBindError,
23+
ConfigEnvMapConflictError,
2324
ConfigEnvPrefixConflictError,
2425
ConfigError,
2526
ConfigMountError,
@@ -155,16 +156,25 @@
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
159164
class _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

169179
T = 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

260367
def _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."""

src/apcore/errors.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,20 @@ def __init__(self, env_prefix: str, **kwargs: Any) -> None:
182182
)
183183

184184

185+
class ConfigEnvMapConflictError(ModuleError):
186+
"""Raised when an env_map key is already claimed by another mapping."""
187+
188+
_default_retryable: bool | None = False
189+
190+
def __init__(self, env_var: str, owner: str, **kwargs: Any) -> None:
191+
super().__init__(
192+
code="CONFIG_ENV_MAP_CONFLICT",
193+
message=f"Environment variable {env_var!r} is already mapped by {owner!r}",
194+
details={"env_var": env_var, "owner": owner},
195+
**kwargs,
196+
)
197+
198+
185199
class ConfigMountError(ModuleError):
186200
"""Raised when a namespace mount operation is invalid."""
187201

0 commit comments

Comments
 (0)