|
| 1 | +""" |
| 2 | +Defines classes that allow environment to be evaluated as a series of attributes. |
| 3 | +
|
| 4 | +Exposes a nested MutableMapping with values that can be accessed via itemgetter |
| 5 | +or attribute syntax. |
| 6 | +
|
| 7 | +The environment is built from three sources (in order of priority): |
| 8 | +1) Runtime overrides. |
| 9 | +2) Environmental variables. Variables in the form of CLRENV__FOO__BAR=baz will cause |
| 10 | + env.foo.bar==baz. These are evaluated at access time. |
| 11 | + TODO(michael.cusack): Should these also be fixed on the state at first env usage? |
| 12 | + Should we monitor and warn changes? |
| 13 | +3) By reading a set of yaml files from disk as described in path.py. Files are read |
| 14 | + lazily when the first attribute is referenced and never reloaded. |
| 15 | +
|
| 16 | +# Runtime Overrides |
| 17 | +RootClrEnv.set_runtime_override(key_path, value) allows you to override values at |
| 18 | +runtime. Using this is encouraged over setting a value using attribute setters, but |
| 19 | +discouraged in preference of only doing so in tests and using unittest.mock.patch |
| 20 | +or monkeypath.setattr instead. |
| 21 | +Runtime overrides can be cleared with env.clear_runtime_overrides()- for instance |
| 22 | +in test teardown. |
| 23 | +""" |
| 24 | +import logging |
| 25 | +import os |
| 26 | +import traceback |
| 27 | +from collections import abc |
| 28 | +from pathlib import Path |
| 29 | +from typing import ( |
| 30 | + Any, |
| 31 | + Iterable, |
| 32 | + Iterator, |
| 33 | + List, |
| 34 | + Mapping, |
| 35 | + MutableMapping, |
| 36 | + Optional, |
| 37 | + Sequence, |
| 38 | + Set, |
| 39 | + Tuple, |
| 40 | + Union, |
| 41 | +) |
| 42 | + |
| 43 | +from .path import environment_paths |
| 44 | +from .read import EnvReader, NestedMapping, PrimitiveValue |
| 45 | + |
| 46 | +logger = logging.getLogger(__name__) |
| 47 | + |
| 48 | +DEBUG_MODE = os.environ.get("CLRENV_DEBUG", "").lower() in ("true", "1") |
| 49 | + |
| 50 | +# Access to an attribute might return a primitive or if it is not a leaf node |
| 51 | +# another SubClrEnv. |
| 52 | +Value = Union[PrimitiveValue, "SubClrEnv"] |
| 53 | + |
| 54 | + |
| 55 | +class SubClrEnv(abc.MutableMapping): |
| 56 | + def __init__(self, parent: "SubClrEnv", next_key: str): |
| 57 | + # The RootClrEnv class omits these, but SubClrEnv needs them. |
| 58 | + assert parent and next_key |
| 59 | + |
| 60 | + self._cached_env: Optional[NestedMapping] = None |
| 61 | + self._parent: SubClrEnv = parent |
| 62 | + self._key_path: Tuple[str, ...] = parent._sub_key_path(next_key) |
| 63 | + self._root: RootClrEnv = parent._root |
| 64 | + |
| 65 | + def __getitem__(self, key: str) -> Value: |
| 66 | + """Allows access with item getter, like a Mapping.""" |
| 67 | + if key.startswith("_"): |
| 68 | + raise KeyError("Keys can not start with _") |
| 69 | + |
| 70 | + value = self._evaluate_key(key) |
| 71 | + |
| 72 | + if value is None: |
| 73 | + # There will never be explicit Nones. |
| 74 | + raise KeyError(f"Unknown key in {self}: {key}") |
| 75 | + |
| 76 | + if isinstance(value, abc.Mapping): |
| 77 | + # Nest to allow deeper lookups. |
| 78 | + return SubClrEnv(self, key) |
| 79 | + |
| 80 | + # Return the actual value. |
| 81 | + return value |
| 82 | + |
| 83 | + def __getattr__(self, key: str) -> Value: |
| 84 | + """Allows access as attributes.""" |
| 85 | + if key.startswith("_"): |
| 86 | + # This is raise an exception. |
| 87 | + object.__getattribute__(self, key) |
| 88 | + try: |
| 89 | + return self[key] |
| 90 | + except KeyError as e: |
| 91 | + raise AttributeError(str(e)) |
| 92 | + |
| 93 | + def __setitem__(self, key: str, value: PrimitiveValue): |
| 94 | + self._root.set_runtime_override(self._sub_key_path(key), value) |
| 95 | + |
| 96 | + def __setattr__(self, key: str, value: PrimitiveValue): |
| 97 | + """Sets a runtime override as an attribute.""" |
| 98 | + # Internal fields are prefixed with a _ and should be treated normally. |
| 99 | + if key.startswith("_"): |
| 100 | + return object.__setattr__(self, key, value) |
| 101 | + self[key] = value |
| 102 | + |
| 103 | + def __delitem__(self, key: str): |
| 104 | + """Only support deleting runtime overrides.""" |
| 105 | + del self._root._runtime_overrides[self._key_path][key] |
| 106 | + |
| 107 | + def __iter__(self) -> Iterator[str]: |
| 108 | + return iter(self._sub_keys) |
| 109 | + |
| 110 | + def __len__(self): |
| 111 | + return len(self._sub_keys) |
| 112 | + |
| 113 | + def __repr__(self): |
| 114 | + return f"ClrEnv[{'.'.join(self._key_path)}]={self._env}" |
| 115 | + |
| 116 | + def _make_env(self) -> NestedMapping: |
| 117 | + """Creates an env map relative to this path.""" |
| 118 | + # Get subtree of parent env. |
| 119 | + return self._parent._env.get(self._key_path[-1], {}) # type: ignore |
| 120 | + |
| 121 | + @property |
| 122 | + def _env(self) -> NestedMapping: |
| 123 | + """Returns the env map relative to this path. |
| 124 | +
|
| 125 | + Using this function allows lazy evaluation.""" |
| 126 | + if not self._cached_env: |
| 127 | + self._cached_env = self._make_env() |
| 128 | + return self._cached_env |
| 129 | + |
| 130 | + @property |
| 131 | + def _sub_keys(self) -> Set[str]: |
| 132 | + """Returns the set of all valid keys under this node.""" |
| 133 | + # Keys in the merged env. |
| 134 | + subkeys = set(self._env.keys()) |
| 135 | + |
| 136 | + # Keys in runtime overrides |
| 137 | + if self._key_path in self._root._runtime_overrides: |
| 138 | + subkeys.update(self._root._runtime_overrides[self._key_path]) |
| 139 | + |
| 140 | + # Keys defined in environmental vars. |
| 141 | + env_var_prefix = self._make_env_var_name(as_prefix=True) |
| 142 | + for env_var in os.environ: |
| 143 | + if env_var.startswith(env_var_prefix): |
| 144 | + env_var = env_var[len(env_var_prefix) :] |
| 145 | + subkeys.add(env_var.split("__")[0].lower()) |
| 146 | + return subkeys |
| 147 | + |
| 148 | + def _evaluate_key(self, key: str) -> Union[PrimitiveValue, Mapping, None]: |
| 149 | + """Returns the stored value for the given key. |
| 150 | +
|
| 151 | + There are three potential sources of data (in order of priority): |
| 152 | + 1) Runtime overrides |
| 153 | + 2) Environment variables |
| 154 | + 3) Merged yaml files |
| 155 | +
|
| 156 | + If the returned value is a mapping it indicates this is not a leaf node and a |
| 157 | + SubClrEnv should be returned to the user. |
| 158 | +
|
| 159 | + If the returned value is None it indicates a KeyError/AttributeError should be |
| 160 | + raised. This can be assumed because ClrEnv does not support explicit null/None |
| 161 | + values. Any nulls in the yaml files are coerced to empty strings when read. |
| 162 | + Runtime overrides are not allowed to set None. |
| 163 | + """ |
| 164 | + key_path = self._sub_key_path(key) |
| 165 | + |
| 166 | + # Check for runtime overrides. |
| 167 | + if self._key_path in self._root._runtime_overrides: |
| 168 | + if key in self._root._runtime_overrides[self._key_path]: |
| 169 | + return self._root._runtime_overrides[self._key_path][key] |
| 170 | + |
| 171 | + # Check for env var override. |
| 172 | + env_var_name = self._make_env_var_name(key_path=key_path) |
| 173 | + if env_var_name in os.environ: |
| 174 | + env_var_value = os.environ[env_var_name] |
| 175 | + # TODO(michael.cusack) cast type? |
| 176 | + return env_var_value |
| 177 | + |
| 178 | + # Get value from the merged env. |
| 179 | + value = self._env.get(key) |
| 180 | + |
| 181 | + # If the value is absent from all three sources but the key does exist in |
| 182 | + # subkeys it means this is an intermediate node of a value set via env vars. |
| 183 | + # Return a Mapping which will cause a SubClrEnv to be returned to the user. |
| 184 | + # The content of the returned mapping does not matter. |
| 185 | + if value is None and key in self._sub_keys: |
| 186 | + return {} |
| 187 | + |
| 188 | + return value |
| 189 | + |
| 190 | + def _sub_key_path(self, key: str) -> Tuple[str, ...]: |
| 191 | + """Returns an attribute path with the given key appended.""" |
| 192 | + return self._key_path + (key,) |
| 193 | + |
| 194 | + def _make_env_var_name( |
| 195 | + self, key_path: Optional[Iterable[str]] = None, as_prefix: bool = False |
| 196 | + ) -> str: |
| 197 | + """Returns the env var name that can be used to set the given attribute path.""" |
| 198 | + if key_path is None: |
| 199 | + key_path = self._key_path |
| 200 | + key_path = list(key_path) |
| 201 | + key_path.insert(0, "CLRENV") |
| 202 | + if as_prefix: |
| 203 | + key_path.append("") |
| 204 | + return "__".join(key_path).upper() |
| 205 | + |
| 206 | + |
| 207 | +class RootClrEnv(SubClrEnv): |
| 208 | + """Special case of SubClrEnv for the root node.""" |
| 209 | + |
| 210 | + def __init__(self, paths: Optional[List[Path]] = None): |
| 211 | + self._environment_paths = paths |
| 212 | + self._cached_env: Optional[NestedMapping] = None |
| 213 | + self._root: RootClrEnv = self |
| 214 | + self._parent: RootClrEnv = self |
| 215 | + self._key_path: Tuple[str, ...] = tuple() |
| 216 | + |
| 217 | + # Runtime overrides for all key paths are stored in the root node. The first |
| 218 | + # key is the parent key path and the second key is the leaf key. This allows |
| 219 | + # efficent lookup for subkeys. |
| 220 | + # env.a.b.c = 'd' ==> _runtime_overrides = {('a', 'b'): {'c': 'd'}} |
| 221 | + self._runtime_overrides: MutableMapping[ |
| 222 | + Tuple[str, ...], MutableMapping[str, PrimitiveValue] |
| 223 | + ] = {} |
| 224 | + |
| 225 | + def _make_env(self) -> NestedMapping: |
| 226 | + # Lazily read the environment from disk. |
| 227 | + return EnvReader(self._environment_paths or environment_paths()).read() |
| 228 | + |
| 229 | + def clear_runtime_overrides(self): |
| 230 | + """Clear all runtime overrides.""" |
| 231 | + self._runtime_overrides.clear() |
| 232 | + |
| 233 | + def set_runtime_override(self, key_path: Sequence[str], value: PrimitiveValue): |
| 234 | + """Sets a runtime override. |
| 235 | +
|
| 236 | + Only do this in tests and ideally use unittest.mock.patch or monkeypath.setattr |
| 237 | + instead. |
| 238 | +
|
| 239 | + Notice that this method is only on the root node.""" |
| 240 | + if not key_path: |
| 241 | + raise ValueError("key_path can not be empty.") |
| 242 | + if isinstance(key_path, str): |
| 243 | + key_path = key_path.split(".") |
| 244 | + |
| 245 | + # Check that the key already exists. |
| 246 | + parent: Union[SubClrEnv, PrimitiveValue] = self |
| 247 | + for name in key_path: |
| 248 | + assert isinstance(parent, Mapping) |
| 249 | + assert name in parent, f"{name, parent}" |
| 250 | + parent = parent[name] |
| 251 | + |
| 252 | + # No support for nested runtime overrides. Only allow primitives. |
| 253 | + if not isinstance(value, PrimitiveValue.__args__): # type: ignore |
| 254 | + raise ValueError("Env values must be one of {str, int, float, boolean}.") |
| 255 | + |
| 256 | + # Ideally we wouldn't be overriding global state like this at all, but at least |
| 257 | + # make it loud. |
| 258 | + logger.warning(f"Manually overriding env.{'.'.join(key_path)} to {value}.") |
| 259 | + if DEBUG_MODE: |
| 260 | + # Get stack and remove this frame. |
| 261 | + tb = traceback.extract_stack()[:-1] |
| 262 | + logger.warning("".join(traceback.format_list(tb))) |
| 263 | + |
| 264 | + parents = tuple(key_path[:-1]) |
| 265 | + if parents not in self._root._runtime_overrides: |
| 266 | + self._root._runtime_overrides[parents] = {} |
| 267 | + self._root._runtime_overrides[parents][key_path[-1]] = value |
0 commit comments