Skip to content
This repository was archived by the owner on Mar 31, 2025. It is now read-only.

Commit e11b67f

Browse files
Major rewrite of clrenv
1 parent 6af4817 commit e11b67f

18 files changed

+985
-348
lines changed

.coveragerc

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[run]
2+
include = clrenv/*
3+
omit =
4+
clrenv/tests/*
5+
setup.py
6+
[report]
7+
exclude_lines = DEBUG_MODE

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,7 @@
33
*.swp
44
build
55
ve
6+
.eggs
7+
*.egg-info
8+
.coverage
9+
htmlcov

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,25 @@ $ export CLRENV_OVERLAY_PATH=/path/to/environment.overlay.yaml
8282
```
8383

8484
`CLRENV_OVERLAY_PATH` may have multiple files separated by `:`, e.g. `/path/foo.overlay.yaml:/path/bar.overlay.yaml`.
85+
86+
## Development
87+
* Create a virtualenv and activate it
88+
```
89+
python3 -m venv_clrenv <location>
90+
source <location>/bin/activate
91+
```
92+
* Install this package as editable (symlinked to source files)
93+
```
94+
pip install -e .
95+
pip install black isort
96+
```
97+
* Run the tests
98+
```
99+
$ pytest --cov
100+
$ mypy clrenv
101+
$ pytest --cov-report=term --cov-report=html --cov
102+
$ mypy --no-implicit-optional --warn-redundant-casts --warn-return-any --warn-unreachable --warn-unused-ignores --pretty --txt-report /tmp --html-report /tmp clrenv/*py
103+
$ cat /tmp/index.txt
104+
$ black .
105+
$ isort .
106+
```

clrenv/__init__.py

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,8 @@
1-
import yaml
1+
"""To use simply import the env variable:
22
3-
from .lazy_env import get_env, LazyEnv
4-
from .load import safe_load
5-
from .path import find_environment_path
3+
from clrenv import env
4+
celery_backend = env.clinical.celery.backend
5+
"""
6+
from .evaluate import RootClrEnv
67

7-
8-
def mapping():
9-
with open(find_environment_path()) as f:
10-
return safe_load(f.read())["mapping"]
11-
12-
13-
env = LazyEnv()
14-
get_env = get_env
8+
env = RootClrEnv()

clrenv/deepmerge.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from collections import abc, deque
2+
from typing import Any, Mapping, MutableMapping
3+
4+
5+
def deepmerge(dst: MutableMapping[str, Any], src: Mapping[str, Any]):
6+
"""Merges src into dst.
7+
8+
If both source and dest values are Mappings merge them as well.
9+
"""
10+
# Queue of (dict, dict) tuples to merge.
11+
to_merge = deque([(dst, src)])
12+
13+
while to_merge:
14+
_dst, _src = to_merge.pop()
15+
for key, src_value in _src.items():
16+
dst_value = _dst.get(key)
17+
if isinstance(dst_value, abc.Mapping) and isinstance(
18+
src_value, abc.Mapping
19+
):
20+
to_merge.append((dst_value, src_value)) # type: ignore
21+
else:
22+
_dst[key] = src_value

clrenv/evaluate.py

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
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

Comments
 (0)