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 ADDED
@@ -0,0 +1 @@
1
+ from . import context, dependencies, plugins, config, log, lifecycle
@@ -0,0 +1,10 @@
1
+ from typing import Callable
2
+
3
+
4
+ class classproperty[T](property):
5
+ def __init__(self, cb: Callable[..., T]) -> None:
6
+ self.cb = cb
7
+ super().__init__()
8
+
9
+ def __get__(self, _, owner) -> T:
10
+ return self.cb.__func__(owner) # type: ignore
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