|
1 | 1 | import logging |
2 | 2 | import os |
3 | 3 | from pathlib import Path |
| 4 | + |
4 | 5 | from beartype import beartype |
5 | 6 | from beartype.typing import Any, Dict, List, Optional |
6 | | -from .utils.logging import setup_logging |
| 7 | + |
7 | 8 | from .utils.exceptions import MCPStackConfigError |
| 9 | +from .utils.logging import setup_logging |
8 | 10 |
|
9 | 11 | logger = logging.getLogger(__name__) |
10 | 12 |
|
| 13 | + |
11 | 14 | @beartype |
12 | 15 | class StackConfig: |
13 | | - def __init__(self, log_level: str = "INFO", env_vars: Optional[Dict[str, str]] = None) -> None: |
| 16 | + """Configuration container for MCPStack. |
| 17 | +
|
| 18 | + Holds logging configuration, environment variables, and computed paths used |
| 19 | + by tools and the MCP server. |
| 20 | +
|
| 21 | + !!! note "Scope" |
| 22 | + Stores env vars and I/O paths used by tools and the MCP server. |
| 23 | +
|
| 24 | + Args: |
| 25 | + log_level: Logging level name (e.g., `"INFO"`, `"DEBUG"`). |
| 26 | + env_vars: Mapping of environment variables to set/merge. |
| 27 | +
|
| 28 | + Attributes: |
| 29 | + log_level (str): Active logging level. |
| 30 | + env_vars (dict[str, str]): Environment variables tracked by the stack. |
| 31 | + project_root (Path): Detected project root (see `_get_project_root()`). |
| 32 | + data_dir (Path): Base data directory (see `_get_data_dir()`). |
| 33 | + databases_dir (Path): `data_dir / "databases"`. |
| 34 | + raw_files_dir (Path): `data_dir / "raw_files"`. |
| 35 | +
|
| 36 | + !!! tip "When is logging applied?" |
| 37 | + Logging is initialized and `env_vars` exported to `os.environ` during |
| 38 | + construction via :meth:`_apply_config`. |
| 39 | + """ |
| 40 | + |
| 41 | + def __init__( |
| 42 | + self, log_level: str = "INFO", env_vars: Optional[Dict[str, str]] = None |
| 43 | + ) -> None: |
14 | 44 | self.log_level = log_level |
15 | 45 | self.env_vars = env_vars or {} |
16 | 46 | self._set_paths() |
17 | 47 | self._apply_config() |
18 | 48 |
|
19 | 49 | def to_dict(self) -> Dict[str, Any]: |
| 50 | + """Serialize the configuration to a plain dictionary. |
| 51 | +
|
| 52 | + Returns: |
| 53 | + dict: A shallow copy with `log_level` and `env_vars`. |
| 54 | +
|
| 55 | + !!! example |
| 56 | + ```python |
| 57 | + cfg = StackConfig() |
| 58 | + payload = cfg.to_dict() |
| 59 | + ``` |
| 60 | + """ |
20 | 61 | return {"log_level": self.log_level, "env_vars": self.env_vars.copy()} |
21 | 62 |
|
22 | 63 | @classmethod |
23 | 64 | def from_dict(cls, data: Dict[str, Any]) -> "StackConfig": |
24 | | - return cls(log_level=data.get("log_level", "INFO"), env_vars=data.get("env_vars", {})) |
| 65 | + """Construct a :class:`StackConfig` from a mapping. |
| 66 | +
|
| 67 | + Args: |
| 68 | + data: A mapping containing optional keys `log_level` and `env_vars`. |
| 69 | +
|
| 70 | + Returns: |
| 71 | + StackConfig: New instance populated from `data`. |
| 72 | +
|
| 73 | + !!! tip |
| 74 | + Missing keys default to `log_level="INFO"` and an empty `env_vars`. |
| 75 | + """ |
| 76 | + return cls( |
| 77 | + log_level=data.get("log_level", "INFO"), env_vars=data.get("env_vars", {}) |
| 78 | + ) |
| 79 | + |
| 80 | + def get_env_var( |
| 81 | + self, key: str, default: Optional[Any] = None, raise_if_missing: bool = False |
| 82 | + ) -> Any: |
| 83 | + """Retrieve an environment variable with fallback and validation. |
| 84 | +
|
| 85 | + Lookup order: `self.env_vars[key]` → `os.getenv(key)` → `default`. |
| 86 | +
|
| 87 | + Args: |
| 88 | + key: Environment variable name. |
| 89 | + default: Value to return if not found in config or process env. |
| 90 | + raise_if_missing: If `True`, raise when the final value is `None`. |
25 | 91 |
|
26 | | - def get_env_var(self, key: str, default: Optional[Any] = None, raise_if_missing: bool = False) -> Any: |
| 92 | + Returns: |
| 93 | + Any: The resolved value, or `""` if the resolved value is falsy. |
| 94 | +
|
| 95 | + Raises: |
| 96 | + MCPStackConfigError: If `raise_if_missing=True` and no value found. |
| 97 | +
|
| 98 | + !!! note |
| 99 | + A debug log is emitted indicating whether the key was set or unset. |
| 100 | + """ |
27 | 101 | value = self.env_vars.get(key, os.getenv(key, default)) |
28 | 102 | if value is None and raise_if_missing: |
29 | 103 | raise MCPStackConfigError(f"Missing required env var: {key}") |
30 | 104 | logger.debug(f"Accessed env var '{key}': {'[set]' if value else '[unset]'}") |
31 | 105 | return value or "" |
32 | 106 |
|
33 | 107 | def validate_for_tools(self, tools: List) -> None: |
| 108 | + """Ensure all tools' required environment variables are present. |
| 109 | +
|
| 110 | + Inspects each tool's `required_env_vars` (a mapping of `name -> default`) |
| 111 | + and verifies that values are available via :meth:`get_env_var`. When a |
| 112 | + default is `None`, the key is considered **required**. |
| 113 | +
|
| 114 | + Args: |
| 115 | + tools: Iterable of tool instances to validate against this config. |
| 116 | +
|
| 117 | + Raises: |
| 118 | + MCPStackConfigError: Aggregated errors if any requirement is missing. |
| 119 | +
|
| 120 | + !!! failure "Common pitfalls" |
| 121 | + * No value provided for a required key (`default=None`). |
| 122 | + * Typos in environment variable names. |
| 123 | + * Forgot to merge preset/tool-provided env. |
| 124 | + """ |
34 | 125 | errors = [] |
35 | 126 | for tool in tools: |
36 | 127 | for req_key, req_default in getattr(tool, "required_env_vars", {}).items(): |
37 | 128 | try: |
38 | | - self.get_env_var(req_key, default=req_default, raise_if_missing=req_default is None) |
| 129 | + self.get_env_var( |
| 130 | + req_key, |
| 131 | + default=req_default, |
| 132 | + raise_if_missing=req_default is None, |
| 133 | + ) |
39 | 134 | except Exception as e: |
40 | 135 | errors.append(f"{tool.__class__.__name__}: {e}") |
41 | 136 | if errors: |
42 | 137 | raise MCPStackConfigError("\n".join(errors)) |
43 | 138 | logger.info(f"Validated config for {len(tools)} tools.") |
44 | 139 |
|
45 | 140 | def merge_env(self, new_env: Dict[str, str], prefix: str = "") -> None: |
| 141 | + """Merge environment variables with optional key prefix and conflict checks. |
| 142 | +
|
| 143 | + Args: |
| 144 | + new_env: Mapping to merge into `env_vars`. |
| 145 | + prefix: String to preprend to each key (namespacing). |
| 146 | +
|
| 147 | + Raises: |
| 148 | + MCPStackConfigError: If a key exists with a **different** value. |
| 149 | +
|
| 150 | + !!! tip "Namespacing" |
| 151 | + Use `prefix` (e.g., `"MYTOOL_"`) to avoid collisions between tools. |
| 152 | + """ |
46 | 153 | for key, value in new_env.items(): |
47 | 154 | prefixed_key = f"{prefix}{key}" if prefix else key |
48 | 155 | if prefixed_key in self.env_vars and self.env_vars[prefixed_key] != value: |
49 | | - raise MCPStackConfigError(f"Env conflict: {prefixed_key} ({self.env_vars[prefixed_key]} vs {value})") |
| 156 | + raise MCPStackConfigError( |
| 157 | + f"Env conflict: {prefixed_key} ({self.env_vars[prefixed_key]} vs {value})" |
| 158 | + ) |
50 | 159 | self.env_vars[prefixed_key] = value |
51 | 160 |
|
52 | 161 | def _set_paths(self) -> None: |
| 162 | + """Compute and cache commonly used directories on disk. |
| 163 | +
|
| 164 | + Side effects: |
| 165 | + Sets `project_root`, `data_dir`, `databases_dir`, and `raw_files_dir`. |
| 166 | +
|
| 167 | + !!! note |
| 168 | + Paths are derived once at initialization; adjust env and rebuild the |
| 169 | + config if your directory layout changes at runtime. |
| 170 | + """ |
53 | 171 | self.project_root = self._get_project_root() |
54 | 172 | self.data_dir = self._get_data_dir() |
55 | 173 | self.databases_dir = self.data_dir / "databases" |
56 | 174 | self.raw_files_dir = self.data_dir / "raw_files" |
57 | 175 |
|
58 | 176 | def _get_project_root(self) -> Path: |
| 177 | + """Infer the project root. |
| 178 | +
|
| 179 | + Returns: |
| 180 | + Path: Directory containing `pyproject.toml` if found by traversing |
| 181 | + up from this file; otherwise the user's home directory. |
| 182 | +
|
| 183 | + !!! tip |
| 184 | + Useful for resolving default data directories during local dev. |
| 185 | + """ |
59 | 186 | package_root = Path(__file__).resolve().parents[3] |
60 | | - return package_root if (package_root / "pyproject.toml").exists() else Path.home() |
| 187 | + return ( |
| 188 | + package_root if (package_root / "pyproject.toml").exists() else Path.home() |
| 189 | + ) |
61 | 190 |
|
62 | 191 | def _get_data_dir(self) -> Path: |
| 192 | + """Resolve the base data directory. |
| 193 | +
|
| 194 | + Resolution order: |
| 195 | + 1. `MCPSTACK_DATA_DIR` from config/env (if set) |
| 196 | + 2. `project_root / "mcpstack_data"` |
| 197 | +
|
| 198 | + Returns: |
| 199 | + Path: The resolved data directory. |
| 200 | + """ |
63 | 201 | data_dir_str = self.get_env_var("MCPSTACK_DATA_DIR") |
64 | | - return Path(data_dir_str) if data_dir_str else self.project_root / "mcpstack_data" |
| 202 | + return ( |
| 203 | + Path(data_dir_str) if data_dir_str else self.project_root / "mcpstack_data" |
| 204 | + ) |
65 | 205 |
|
66 | 206 | def _apply_config(self) -> None: |
| 207 | + """Apply logging configuration and export env vars to the process. |
| 208 | +
|
| 209 | + Side effects: |
| 210 | + * Initializes logging via :func:`setup_logging` using `log_level`. |
| 211 | + * Writes keys from `env_vars` into `os.environ`. |
| 212 | +
|
| 213 | + Raises: |
| 214 | + MCPStackConfigError: If the logging level is invalid. |
| 215 | +
|
| 216 | + !!! warning "Global process state" |
| 217 | + Exporting `env_vars` updates `os.environ` for the current process |
| 218 | + and its children. Avoid unintentional overrides by using distinct |
| 219 | + prefixes when merging from multiple sources. |
| 220 | + """ |
67 | 221 | try: |
68 | 222 | setup_logging(level=self.log_level) |
69 | 223 | except Exception as e: |
70 | | - raise MCPStackConfigError("Invalid log level", details=str(e)) |
| 224 | + raise MCPStackConfigError("Invalid log level", details=str(e)) from e |
71 | 225 | for k, v in self.env_vars.items(): |
72 | 226 | os.environ[k] = v |
0 commit comments