rewire 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rewire/__init__.py +1 -0
- rewire/classproperty.py +10 -0
- rewire/config.py +417 -0
- rewire/context.py +79 -0
- rewire/dependencies.py +363 -0
- rewire/lifecycle.py +165 -0
- rewire/loader.py +42 -0
- rewire/log.py +175 -0
- rewire/plugins.py +187 -0
- rewire/space.py +115 -0
- rewire/store.py +31 -0
- rewire-0.1.0.dist-info/LICENSE +21 -0
- rewire-0.1.0.dist-info/METADATA +76 -0
- rewire-0.1.0.dist-info/RECORD +16 -0
- rewire-0.1.0.dist-info/WHEEL +5 -0
- rewire-0.1.0.dist-info/top_level.txt +1 -0
rewire/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from . import context, dependencies, plugins, config, log, lifecycle
|
rewire/classproperty.py
ADDED
rewire/config.py
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
from contextvars import ContextVar
|
|
2
|
+
from functools import lru_cache
|
|
3
|
+
import json
|
|
4
|
+
from os import getenv
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import (
|
|
8
|
+
Annotated,
|
|
9
|
+
Any,
|
|
10
|
+
Callable,
|
|
11
|
+
ClassVar,
|
|
12
|
+
Dict,
|
|
13
|
+
Literal,
|
|
14
|
+
Optional,
|
|
15
|
+
Self,
|
|
16
|
+
Type,
|
|
17
|
+
cast,
|
|
18
|
+
overload,
|
|
19
|
+
)
|
|
20
|
+
from loguru import logger
|
|
21
|
+
from pydantic import BaseModel, Field, TypeAdapter
|
|
22
|
+
import yaml
|
|
23
|
+
from rewire.classproperty import classproperty
|
|
24
|
+
from rewire.context import use_context_value
|
|
25
|
+
import inspect
|
|
26
|
+
from rewire.dependencies import Dependency, TypeRef
|
|
27
|
+
from rewire.space import Module
|
|
28
|
+
|
|
29
|
+
from rewire.store import SimpleStore
|
|
30
|
+
|
|
31
|
+
CONFIG_FILE = getenv("CONFIG_FILE", "./config.yaml")
|
|
32
|
+
|
|
33
|
+
UNSET = object()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
cwdContext = ContextVar("rewire.config.cwdConfig", default="./")
|
|
37
|
+
fileContext = ContextVar("rewire.config.fileContext", default=CONFIG_FILE)
|
|
38
|
+
rootContext = ContextVar("rewire.config.rootContext")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ConfigLoader(yaml.SafeLoader):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class EnvRequired(Exception):
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class PyCode(BaseModel):
|
|
50
|
+
code: str
|
|
51
|
+
cwd: str = Field(default_factory=cwdContext.get)
|
|
52
|
+
file: str = Field(default_factory=fileContext.get)
|
|
53
|
+
|
|
54
|
+
def execute(self):
|
|
55
|
+
return eval(
|
|
56
|
+
self.code,
|
|
57
|
+
{"this": rootContext.get(), "self": rootContext.get()},
|
|
58
|
+
self.functions(),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def functions(self):
|
|
62
|
+
return {"include": self.include, "getenv": getenv}
|
|
63
|
+
|
|
64
|
+
def include(self, file: str):
|
|
65
|
+
with (
|
|
66
|
+
use_context_value(cwdContext, self.cwd),
|
|
67
|
+
use_context_value(fileContext, self.file),
|
|
68
|
+
):
|
|
69
|
+
return load_yaml(file)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class PyExecCode(PyCode):
|
|
73
|
+
def execute(self):
|
|
74
|
+
container = SimpleStore[Callable]()
|
|
75
|
+
|
|
76
|
+
exec(
|
|
77
|
+
"def __pyexec():\n"
|
|
78
|
+
+ "\n".join(f" {x}" for x in self.code.splitlines())
|
|
79
|
+
+ "\n__container.set(__pyexec)",
|
|
80
|
+
{
|
|
81
|
+
"this": rootContext.get(),
|
|
82
|
+
"self": rootContext.get(),
|
|
83
|
+
"__container": container,
|
|
84
|
+
},
|
|
85
|
+
self.functions(),
|
|
86
|
+
)
|
|
87
|
+
fn = container.get()
|
|
88
|
+
assert fn
|
|
89
|
+
return fn()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class EvalDict(dict):
|
|
93
|
+
__pydantic_validator__ = None
|
|
94
|
+
|
|
95
|
+
def __getitem__(self, __k):
|
|
96
|
+
if __k not in self:
|
|
97
|
+
raise AttributeError(__k)
|
|
98
|
+
value = super().__getitem__(__k)
|
|
99
|
+
|
|
100
|
+
if isinstance(value, PyCode):
|
|
101
|
+
return value.execute()
|
|
102
|
+
|
|
103
|
+
if isinstance(value, dict):
|
|
104
|
+
return type(self)(value)
|
|
105
|
+
|
|
106
|
+
return value
|
|
107
|
+
|
|
108
|
+
def __getattribute__(self, __name: str) -> Any:
|
|
109
|
+
try:
|
|
110
|
+
return super().__getattribute__(__name)
|
|
111
|
+
except AttributeError:
|
|
112
|
+
return self.__getitem__(__name) # type: ignore
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def load_yaml_env(loader, node: yaml.ScalarNode):
|
|
116
|
+
value = getenv(*node.value.split(":", 1))
|
|
117
|
+
if value is None:
|
|
118
|
+
raise EnvRequired(f'env variable {node.value.split(":", 1)[0]!r} required')
|
|
119
|
+
return value
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def load_yaml_include(loader, node: yaml.ScalarNode):
|
|
123
|
+
return load_yaml(node.value)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def load_yaml_py(load, node: yaml.ScalarNode):
|
|
127
|
+
return PyCode(code=node.value)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def load_yaml_pyexec(load, node: yaml.ScalarNode):
|
|
131
|
+
return PyExecCode(code=node.value)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def load_yaml_yaml(load, node: yaml.ScalarNode):
|
|
135
|
+
return prepare_yaml(yaml.load(node.value, ConfigLoader))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
ConfigLoader.add_constructor("!env", load_yaml_env)
|
|
139
|
+
ConfigLoader.add_constructor("!include", load_yaml_include)
|
|
140
|
+
ConfigLoader.add_constructor("!py", load_yaml_py)
|
|
141
|
+
ConfigLoader.add_constructor("!pyexec", load_yaml_pyexec)
|
|
142
|
+
ConfigLoader.add_constructor("!yaml", load_yaml_yaml)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@overload
|
|
146
|
+
def merge[K, T, KO, TO](
|
|
147
|
+
source: dict[K, T], overlay: dict[KO, TO]
|
|
148
|
+
) -> dict[K | KO, T | TO]:
|
|
149
|
+
...
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@overload
|
|
153
|
+
def merge[T](source: Any, overlay: T) -> T:
|
|
154
|
+
...
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def merge(source: dict | list | Any, overlay: dict | list | Any):
|
|
158
|
+
if isinstance(source, dict | list):
|
|
159
|
+
source = source.copy()
|
|
160
|
+
if isinstance(source, dict) and isinstance(overlay, dict):
|
|
161
|
+
for key, value in overlay.items():
|
|
162
|
+
source[key] = merge(source.get(key, None), value)
|
|
163
|
+
return source
|
|
164
|
+
return overlay
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def render_py(value, root=UNSET):
|
|
168
|
+
if root is UNSET:
|
|
169
|
+
root = value
|
|
170
|
+
if isinstance(value, dict):
|
|
171
|
+
new_value = {k: render_py(v, root=root) for k, v in value.items()}
|
|
172
|
+
value.update(new_value)
|
|
173
|
+
return EvalDict(value)
|
|
174
|
+
if isinstance(value, list):
|
|
175
|
+
new_value = [render_py(v, root=root) for v in value]
|
|
176
|
+
value.clear()
|
|
177
|
+
value.extend(new_value)
|
|
178
|
+
return value
|
|
179
|
+
if isinstance(value, PyCode):
|
|
180
|
+
return render_py(value.execute(), root=root)
|
|
181
|
+
return value
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def load_yaml(file_: str | Path):
|
|
185
|
+
cwd = cwdContext.get().removesuffix("/") + "/"
|
|
186
|
+
file_ = os.path.join(cwd, file_)
|
|
187
|
+
with (
|
|
188
|
+
open(file_) as f,
|
|
189
|
+
use_context_value(fileContext, file_),
|
|
190
|
+
use_context_value(cwdContext, os.path.dirname(file_)),
|
|
191
|
+
):
|
|
192
|
+
value = yaml.load(f, ConfigLoader)
|
|
193
|
+
return prepare_yaml(value)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def prepare_yaml(value):
|
|
197
|
+
with use_context_value(rootContext, None):
|
|
198
|
+
return EvalDict({"d": value}).d
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def set_by_key(key, value, data):
|
|
202
|
+
if key:
|
|
203
|
+
for k in key.split(".")[:-1]:
|
|
204
|
+
data = data.setdefault(k, {})
|
|
205
|
+
data[key.split(".")[-1]] = value
|
|
206
|
+
else:
|
|
207
|
+
data.update(value)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def get_by_key(key, data):
|
|
211
|
+
if key is None:
|
|
212
|
+
return data
|
|
213
|
+
if "." in key:
|
|
214
|
+
key, next_key = key.split(".", 1)
|
|
215
|
+
return get_by_key(next_key, data.get(key, {}))
|
|
216
|
+
return data.get(key, {})
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def merge_env(data: Dict): # /NOSONAR
|
|
220
|
+
remap_config = getenv("CONFIG_REWIRE_ENV_REMAP", {})
|
|
221
|
+
if isinstance(remap_config, str):
|
|
222
|
+
remap_config = json.loads(remap_config)
|
|
223
|
+
assert isinstance(remap_config, dict)
|
|
224
|
+
|
|
225
|
+
for key, value in os.environ.items():
|
|
226
|
+
remapped = key in remap_config
|
|
227
|
+
if remapped:
|
|
228
|
+
key = remap_config[key]
|
|
229
|
+
|
|
230
|
+
if not key.startswith("CONFIG_") and not remapped:
|
|
231
|
+
continue
|
|
232
|
+
|
|
233
|
+
if not remapped:
|
|
234
|
+
key = key.removeprefix("CONFIG_")
|
|
235
|
+
key = key.replace("_", ".").replace("..", "_")
|
|
236
|
+
|
|
237
|
+
if key.endswith("_.JSON") or remapped and key.endswith(":json"):
|
|
238
|
+
key = key.removesuffix("_.JSON").removesuffix(":json")
|
|
239
|
+
value = json.loads(value)
|
|
240
|
+
elif key.endswith("_.YAML") or remapped and key.endswith(":yaml"):
|
|
241
|
+
key = key.removesuffix("_.YAML").removesuffix(":yaml")
|
|
242
|
+
value = yaml.load(value, ConfigLoader)
|
|
243
|
+
value = prepare_yaml(value)
|
|
244
|
+
|
|
245
|
+
set_by_key(key, value, data)
|
|
246
|
+
return data
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@lru_cache()
|
|
250
|
+
def parse_file(file: str | Path, silent: bool = False):
|
|
251
|
+
try:
|
|
252
|
+
raw_config = TypeAdapter(Dict[str, Any]).validate_python(load_yaml(file) or {})
|
|
253
|
+
except FileNotFoundError as e:
|
|
254
|
+
if not silent:
|
|
255
|
+
logger.error(e)
|
|
256
|
+
raw_config = {}
|
|
257
|
+
|
|
258
|
+
raw_config = merge_env(raw_config)
|
|
259
|
+
|
|
260
|
+
with use_context_value(rootContext, raw_config):
|
|
261
|
+
raw_config = EvalDict({"d": raw_config}).d
|
|
262
|
+
|
|
263
|
+
with use_context_value(rootContext, raw_config):
|
|
264
|
+
return render_py(raw_config)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def extract_config_by_path(path: str, model: BaseModel):
|
|
268
|
+
config = model
|
|
269
|
+
|
|
270
|
+
for part in path.split("."):
|
|
271
|
+
config = getattr(config, part, {})
|
|
272
|
+
|
|
273
|
+
return config
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def gen_config_model[TBM: BaseModel](path: str, model: Type[TBM], name: str = "Config"):
|
|
277
|
+
Model = model # /NOSONAR
|
|
278
|
+
|
|
279
|
+
field, _, next_ = path.partition(".")
|
|
280
|
+
if next_:
|
|
281
|
+
Model = gen_config_model(next_, model, f"{field.capitalize()}{name}")
|
|
282
|
+
|
|
283
|
+
store: SimpleStore[TBM] = SimpleStore()
|
|
284
|
+
no_required_args = True
|
|
285
|
+
|
|
286
|
+
for param in inspect.signature(Model).parameters.values(): # type: ignore
|
|
287
|
+
if param.default is inspect._empty:
|
|
288
|
+
no_required_args = False
|
|
289
|
+
|
|
290
|
+
exec(
|
|
291
|
+
f"field = Field(default_factory=Model) if noRequiredArgs else Field(...)\n"
|
|
292
|
+
f"class {name}(BaseModel):\n"
|
|
293
|
+
f" {field}: Model = field\n"
|
|
294
|
+
f"store.set({name})\n",
|
|
295
|
+
{
|
|
296
|
+
"store": store,
|
|
297
|
+
"Model": Model,
|
|
298
|
+
"BaseModel": BaseModel,
|
|
299
|
+
"Field": Field,
|
|
300
|
+
"noRequiredArgs": no_required_args,
|
|
301
|
+
},
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
value = store.get()
|
|
305
|
+
if not value:
|
|
306
|
+
raise RuntimeError("evaluation failed")
|
|
307
|
+
|
|
308
|
+
return value
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
class ConfigModule(Module):
|
|
312
|
+
config: dict = {}
|
|
313
|
+
config_file: str = CONFIG_FILE
|
|
314
|
+
|
|
315
|
+
def init(self):
|
|
316
|
+
self.config = cast(dict, parse_file(self.config_file)) # type: ignore
|
|
317
|
+
|
|
318
|
+
def patch(self, override: dict):
|
|
319
|
+
self.config = merge(self.config, override)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class EnvironmentModule(Module):
|
|
323
|
+
env: Literal["production", "dev"] = "production"
|
|
324
|
+
|
|
325
|
+
def init(self):
|
|
326
|
+
config = ConfigModule.get(None)
|
|
327
|
+
if (
|
|
328
|
+
config is not None
|
|
329
|
+
and config.config.get("rewire", {}).get("env", getenv("ENV", None)) == "dev"
|
|
330
|
+
):
|
|
331
|
+
self.env = "dev"
|
|
332
|
+
|
|
333
|
+
@classmethod
|
|
334
|
+
def current_env(cls):
|
|
335
|
+
self = EnvironmentModule.get(None)
|
|
336
|
+
return "dev" if self is None else self.env
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
@overload
|
|
340
|
+
def config[TBM: BaseModel](
|
|
341
|
+
model: Type[TBM], *, path: Optional[str] = ".", fallback: Any = None
|
|
342
|
+
) -> TBM:
|
|
343
|
+
...
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@overload
|
|
347
|
+
def config[TBM: BaseModel](
|
|
348
|
+
*, path: Optional[str] = ".", fallback: Any = None
|
|
349
|
+
) -> Callable[[Type[TBM]], TBM]:
|
|
350
|
+
...
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def config(model: Any = UNSET, *, path: Optional[str] = ".", fallback: Any = None):
|
|
354
|
+
"""fills base model from file fields"""
|
|
355
|
+
|
|
356
|
+
def wrapper[TBM: BaseModel](config: Type[TBM]) -> TBM:
|
|
357
|
+
config_path = update_path(config.__module__, path or ".")
|
|
358
|
+
|
|
359
|
+
data = gen_config_model(config_path, config)
|
|
360
|
+
try:
|
|
361
|
+
raw_config = ConfigModule.get().config
|
|
362
|
+
|
|
363
|
+
return cast(
|
|
364
|
+
config,
|
|
365
|
+
extract_config_by_path(config_path, data.model_validate(raw_config)),
|
|
366
|
+
)
|
|
367
|
+
except LookupError:
|
|
368
|
+
if fallback is None:
|
|
369
|
+
raise
|
|
370
|
+
return config.model_validate(fallback)
|
|
371
|
+
|
|
372
|
+
if model is not UNSET:
|
|
373
|
+
return wrapper(model)
|
|
374
|
+
|
|
375
|
+
return wrapper
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def update_path(config_path: str, post_path: str):
|
|
379
|
+
level = 0
|
|
380
|
+
|
|
381
|
+
while post_path.startswith("."):
|
|
382
|
+
level -= 1
|
|
383
|
+
post_path = post_path[1:]
|
|
384
|
+
|
|
385
|
+
if level == 0:
|
|
386
|
+
return post_path
|
|
387
|
+
|
|
388
|
+
for _ in range(1, -level):
|
|
389
|
+
config_path = ".".join(config_path.split(".")[:-1])
|
|
390
|
+
|
|
391
|
+
if post_path:
|
|
392
|
+
config_path = f"{config_path}.{post_path}".strip(".")
|
|
393
|
+
return config_path
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
class ConfigDependency(BaseModel):
|
|
397
|
+
_dependency: ClassVar[Dependency[Self] | None] = None
|
|
398
|
+
__location__: ClassVar[str] = "."
|
|
399
|
+
__fallback__: ClassVar[dict | None] = None
|
|
400
|
+
|
|
401
|
+
@classproperty
|
|
402
|
+
@classmethod
|
|
403
|
+
def dependency(cls) -> Dependency[Self]:
|
|
404
|
+
if cls._dependency is None:
|
|
405
|
+
|
|
406
|
+
async def resolve():
|
|
407
|
+
return config(cls, path=cls.__location__, fallback=cls.__fallback__)
|
|
408
|
+
|
|
409
|
+
cls._dependency = Dependency(
|
|
410
|
+
cb=resolve, type=cls, label=f"Config ({cls.__module__}.{cls.__name__})"
|
|
411
|
+
)
|
|
412
|
+
return cls._dependency
|
|
413
|
+
|
|
414
|
+
@classproperty
|
|
415
|
+
@classmethod
|
|
416
|
+
def Value(cls) -> Type[Self]:
|
|
417
|
+
return Annotated[cls, TypeRef(type=cls)]
|
rewire/context.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from contextlib import contextmanager
|
|
2
|
+
from contextvars import ContextVar
|
|
3
|
+
from typing import Type, overload
|
|
4
|
+
from uuid import uuid4
|
|
5
|
+
from pydantic import BaseModel, PrivateAttr
|
|
6
|
+
|
|
7
|
+
UNSET = object()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Context[T](BaseModel):
|
|
11
|
+
_ctx: ContextVar[T] | None = PrivateAttr(None)
|
|
12
|
+
name: str | None = None
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def ctx(self):
|
|
16
|
+
if self._ctx is not None:
|
|
17
|
+
return self._ctx
|
|
18
|
+
self._ctx = ContextVar(self.name or str(uuid4()))
|
|
19
|
+
return self._ctx
|
|
20
|
+
|
|
21
|
+
@contextmanager
|
|
22
|
+
def use(self: "ContextVar[T] | Context[T]", value: T):
|
|
23
|
+
if isinstance(self, Context):
|
|
24
|
+
self = self.ctx
|
|
25
|
+
|
|
26
|
+
token = self.set(value)
|
|
27
|
+
try:
|
|
28
|
+
yield value
|
|
29
|
+
finally:
|
|
30
|
+
self.reset(token)
|
|
31
|
+
|
|
32
|
+
@overload
|
|
33
|
+
def get[D](self, default: D) -> T | D:
|
|
34
|
+
...
|
|
35
|
+
|
|
36
|
+
@overload
|
|
37
|
+
def get(self) -> T:
|
|
38
|
+
...
|
|
39
|
+
|
|
40
|
+
def get(self, default=UNSET):
|
|
41
|
+
if default is UNSET:
|
|
42
|
+
return self.ctx.get()
|
|
43
|
+
return self.ctx.get(default)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def use_context_value[T](self: ContextVar[T] | Context[T], value: T):
|
|
47
|
+
return Context.use(self, value)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class BoundCtx[T](Context[T]):
|
|
51
|
+
_value: T = PrivateAttr()
|
|
52
|
+
|
|
53
|
+
def use(self, value: T | None = None):
|
|
54
|
+
return super().use(value or self._value)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class CTX(property):
|
|
58
|
+
_context: Context | None = None
|
|
59
|
+
|
|
60
|
+
@overload
|
|
61
|
+
def __get__[T](self, __instance: None, __owner: Type[T]) -> Context[T]:
|
|
62
|
+
...
|
|
63
|
+
|
|
64
|
+
@overload
|
|
65
|
+
def __get__[T](self, __instance: T, __owner: Type[T]) -> BoundCtx[T]:
|
|
66
|
+
...
|
|
67
|
+
|
|
68
|
+
def __get__[T](
|
|
69
|
+
self, __instance: T | None, __owner: Type[T]
|
|
70
|
+
) -> Context[T] | BoundCtx[T]:
|
|
71
|
+
if self._context is None:
|
|
72
|
+
self._context = Context()
|
|
73
|
+
if __instance is not None:
|
|
74
|
+
ctx = BoundCtx()
|
|
75
|
+
ctx._ctx = self._context.ctx
|
|
76
|
+
ctx._value = __instance
|
|
77
|
+
ctx.name = self._context.name
|
|
78
|
+
return ctx
|
|
79
|
+
return self._context
|