-
Notifications
You must be signed in to change notification settings - Fork 1
feat: add env var substitution and config file auto-discovery #77
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -2,6 +2,8 @@ | |||||||||||||
|
|
||||||||||||||
| import copy | ||||||||||||||
| import logging | ||||||||||||||
| import os | ||||||||||||||
| import re | ||||||||||||||
| from pathlib import Path | ||||||||||||||
| from typing import Any | ||||||||||||||
|
|
||||||||||||||
|
|
@@ -19,6 +21,15 @@ | |||||||||||||
|
|
||||||||||||||
| logger = logging.getLogger(__name__) | ||||||||||||||
|
|
||||||||||||||
| _ENV_VAR_PATTERN = re.compile(r"\$\{([^}:]+?)(?::-([^}]*))?\}") | ||||||||||||||
|
|
||||||||||||||
| _CWD_CONFIG_LOCATIONS: tuple[Path, ...] = ( | ||||||||||||||
| Path("ai-company.yaml"), | ||||||||||||||
| Path("config/ai-company.yaml"), | ||||||||||||||
| ) | ||||||||||||||
|
|
||||||||||||||
| _HOME_CONFIG_RELATIVE = Path(".ai-company") / "config.yaml" | ||||||||||||||
|
|
||||||||||||||
| # --------------------------------------------------------------------------- | ||||||||||||||
| # Private helpers | ||||||||||||||
| # --------------------------------------------------------------------------- | ||||||||||||||
|
|
@@ -263,13 +274,97 @@ def _validate_config_dict( | |||||||||||||
| ) from exc | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| def _substitute_env_vars( | ||||||||||||||
| data: dict[str, Any], | ||||||||||||||
| *, | ||||||||||||||
| source_file: str | None = None, | ||||||||||||||
| ) -> dict[str, Any]: | ||||||||||||||
| """Substitute ``${VAR}`` and ``${VAR:-default}`` in string values. | ||||||||||||||
|
|
||||||||||||||
| Walks the dict recursively, replacing environment variable | ||||||||||||||
| placeholders in string values. Non-string values (int, float, | ||||||||||||||
| bool, None) are passed through unchanged. Returns a new dict; | ||||||||||||||
| the input is never mutated. | ||||||||||||||
|
|
||||||||||||||
| Args: | ||||||||||||||
| data: Configuration dict to process. | ||||||||||||||
| source_file: File path label for error messages. | ||||||||||||||
|
|
||||||||||||||
| Returns: | ||||||||||||||
| A new dict with all env var placeholders resolved. | ||||||||||||||
|
|
||||||||||||||
| Raises: | ||||||||||||||
| ConfigValidationError: If a referenced env var is not set | ||||||||||||||
| and no default is provided. | ||||||||||||||
| """ | ||||||||||||||
|
|
||||||||||||||
| def _resolve_match(match: re.Match[str]) -> str: | ||||||||||||||
| var_name = match.group(1) | ||||||||||||||
| default = match.group(2) | ||||||||||||||
| value = os.environ.get(var_name) | ||||||||||||||
| if value is not None: | ||||||||||||||
| return value | ||||||||||||||
| if default is not None: | ||||||||||||||
| return default | ||||||||||||||
| msg = ( | ||||||||||||||
| f"Environment variable '{var_name}' is not set and no default was provided" | ||||||||||||||
| ) | ||||||||||||||
| raise ConfigValidationError( | ||||||||||||||
| msg, | ||||||||||||||
| locations=(ConfigLocation(file_path=source_file),), | ||||||||||||||
| ) | ||||||||||||||
|
|
||||||||||||||
| def _walk(node: Any) -> Any: | ||||||||||||||
| if isinstance(node, str): | ||||||||||||||
| return _ENV_VAR_PATTERN.sub(_resolve_match, node) | ||||||||||||||
| if isinstance(node, dict): | ||||||||||||||
| return {key: _walk(value) for key, value in node.items()} | ||||||||||||||
| if isinstance(node, list): | ||||||||||||||
| return [_walk(item) for item in node] | ||||||||||||||
| return node | ||||||||||||||
|
|
||||||||||||||
| result: dict[str, Any] = _walk(data) | ||||||||||||||
| return result | ||||||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
|
||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| # --------------------------------------------------------------------------- | ||||||||||||||
| # Public API | ||||||||||||||
| # --------------------------------------------------------------------------- | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| def discover_config() -> Path: | ||||||||||||||
| """Auto-discover a configuration file from well-known locations. | ||||||||||||||
|
|
||||||||||||||
| Search order: | ||||||||||||||
|
|
||||||||||||||
| 1. ``./ai-company.yaml`` | ||||||||||||||
| 2. ``./config/ai-company.yaml`` | ||||||||||||||
| 3. ``~/.ai-company/config.yaml`` | ||||||||||||||
|
|
||||||||||||||
| Returns: | ||||||||||||||
| Resolved absolute :class:`~pathlib.Path` to the first file found. | ||||||||||||||
|
|
||||||||||||||
| Raises: | ||||||||||||||
| ConfigFileNotFoundError: If no configuration file is found | ||||||||||||||
| at any searched location. | ||||||||||||||
| """ | ||||||||||||||
| candidates = [*_CWD_CONFIG_LOCATIONS, Path.home() / _HOME_CONFIG_RELATIVE] | ||||||||||||||
| for candidate in candidates: | ||||||||||||||
| if candidate.is_file(): | ||||||||||||||
| return candidate.resolve() | ||||||||||||||
|
|
||||||||||||||
| searched = [str(c) for c in candidates] | ||||||||||||||
| msg = "No configuration file found. Searched:\n" + "\n".join( | ||||||||||||||
| f" - {p}" for p in searched | ||||||||||||||
| ) | ||||||||||||||
| raise ConfigFileNotFoundError( | ||||||||||||||
| msg, | ||||||||||||||
| locations=tuple(ConfigLocation(file_path=p) for p in searched), | ||||||||||||||
| ) | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| def load_config( | ||||||||||||||
| config_path: Path | str, | ||||||||||||||
| config_path: Path | str | None = None, | ||||||||||||||
| *, | ||||||||||||||
| override_paths: tuple[Path | str, ...] = (), | ||||||||||||||
| ) -> RootConfig: | ||||||||||||||
|
|
@@ -280,6 +375,11 @@ def load_config( | |||||||||||||
| 1. Built-in defaults (from :func:`default_config_dict`). | ||||||||||||||
| 2. Primary config file at *config_path*. | ||||||||||||||
| 3. Override files in order. | ||||||||||||||
| 4. Environment variable substitution (``${VAR}`` / | ||||||||||||||
| ``${VAR:-default}``). | ||||||||||||||
|
|
||||||||||||||
| When *config_path* is ``None``, :func:`discover_config` is called | ||||||||||||||
| to auto-discover the configuration file. | ||||||||||||||
|
|
||||||||||||||
| .. note:: | ||||||||||||||
|
|
||||||||||||||
|
|
@@ -289,18 +389,23 @@ def load_config( | |||||||||||||
| file's line numbers or lack location information entirely. | ||||||||||||||
|
|
||||||||||||||
| Args: | ||||||||||||||
| config_path: Path to the primary config file. | ||||||||||||||
| config_path: Path to the primary config file, or ``None`` | ||||||||||||||
| to auto-discover. | ||||||||||||||
| override_paths: Additional config files layered on top. | ||||||||||||||
|
|
||||||||||||||
| Returns: | ||||||||||||||
| Validated, frozen :class:`RootConfig`. | ||||||||||||||
|
|
||||||||||||||
| Raises: | ||||||||||||||
| ConfigFileNotFoundError: If any config file does not exist. | ||||||||||||||
| ConfigFileNotFoundError: If any config file does not exist | ||||||||||||||
| (or discovery finds nothing). | ||||||||||||||
| ConfigParseError: If any file contains invalid YAML or cannot | ||||||||||||||
| be read. | ||||||||||||||
| ConfigValidationError: If the merged config fails validation. | ||||||||||||||
| ConfigValidationError: If the merged config fails validation | ||||||||||||||
| or an env var is missing. | ||||||||||||||
| """ | ||||||||||||||
| if config_path is None: | ||||||||||||||
| config_path = discover_config() | ||||||||||||||
| config_path = Path(config_path) | ||||||||||||||
|
|
||||||||||||||
| # 1. Start with built-in defaults | ||||||||||||||
|
|
@@ -317,6 +422,9 @@ def load_config( | |||||||||||||
| override = _parse_yaml_file(Path(override_path)) | ||||||||||||||
| merged = _deep_merge(merged, override) | ||||||||||||||
|
|
||||||||||||||
| # 4. Substitute environment variables | ||||||||||||||
| merged = _substitute_env_vars(merged, source_file=str(config_path)) | ||||||||||||||
|
||||||||||||||
| # 4. Substitute environment variables | |
| merged = _substitute_env_vars(merged, source_file=str(config_path)) | |
| # 4. Substitute environment variables on the fully merged config. | |
| # Use a neutral label so env-var errors aren't misattributed solely | |
| # to the primary config file when they may originate from overrides. | |
| merged = _substitute_env_vars(merged, source_file="<merged config>") |
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -108,6 +108,31 @@ class RootConfigFactory(ModelFactory): | |||||||
| total_monthly: -100.0 | ||||||||
| """ | ||||||||
|
|
||||||||
| ENV_VAR_SIMPLE_YAML = """\ | ||||||||
| company_name: ${COMPANY_NAME} | ||||||||
| """ | ||||||||
|
|
||||||||
| ENV_VAR_DEFAULT_YAML = """\ | ||||||||
| company_name: ${COMPANY_NAME:-Fallback Corp} | ||||||||
| """ | ||||||||
|
|
||||||||
|
||||||||
| ENV_VAR_DEFAULT_YAML = """\ | |
| company_name: ${COMPANY_NAME:-Fallback Corp} | |
| """ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
_substitute_env_varsfunction allows unrestricted access to environment variables, posing a significant security risk. If configuration is provided by an untrusted source (e.g., via an API endpoint), an attacker could leak sensitive environment variables (e.g.,DB_PASSWORD,API_KEYS,SECRET_KEY) if the resolved configuration is subsequently exposed. To mitigate this, consider implementing an allow-list of permitted environment variable names or providing an option to disable environment variable substitution when processing configuration from untrusted sources. Additionally, the current error reporting for missing environment variables only includes thesource_file. Improving debuggability by tracking and including the key path in theConfigLocationof the error would be beneficial to pinpoint the exact configuration key causing the issue.