tunablex 0.0.0__tar.gz
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.
- tunablex-0.0.0/.gitignore +11 -0
- tunablex-0.0.0/LICENSE +21 -0
- tunablex-0.0.0/PKG-INFO +41 -0
- tunablex-0.0.0/README.md +16 -0
- tunablex-0.0.0/pyproject.toml +34 -0
- tunablex-0.0.0/src/tunablex/__init__.py +17 -0
- tunablex-0.0.0/src/tunablex/cli.py +56 -0
- tunablex-0.0.0/src/tunablex/cli_helpers.py +74 -0
- tunablex-0.0.0/src/tunablex/context.py +27 -0
- tunablex-0.0.0/src/tunablex/decorators.py +73 -0
- tunablex-0.0.0/src/tunablex/io.py +42 -0
- tunablex-0.0.0/src/tunablex/naming.py +3 -0
- tunablex-0.0.0/src/tunablex/registry.py +45 -0
- tunablex-0.0.0/src/tunablex/runtime.py +60 -0
tunablex-0.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Jacques PAPPER - ICON Technology & Process Consulting SAS
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
tunablex-0.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tunablex
|
|
3
|
+
Version: 0.0.0
|
|
4
|
+
Summary: Function-first tunables with auto JSON/YAML/TOML config & schema, per-executable composition (tags or trace), and runtime auto-injection.
|
|
5
|
+
Project-URL: Homepage, https://github.com/jackpap/tunableX
|
|
6
|
+
Project-URL: Issues, https://github.com/jackpap/tunableX/issues
|
|
7
|
+
Author: Jacques Papper
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: pydantic>=2.4
|
|
20
|
+
Provides-Extra: jsonargparse
|
|
21
|
+
Requires-Dist: jsonargparse>=4.26; extra == 'jsonargparse'
|
|
22
|
+
Provides-Extra: yaml
|
|
23
|
+
Requires-Dist: pyyaml>=6.0.1; extra == 'yaml'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# tunablex
|
|
27
|
+
|
|
28
|
+
Function-first **tunable parameters** for Python apps — with **automatic JSON & JSON Schema**, **per-executable** composition (via tags) or **dynamic** composition (via call-graph **tracing**), and **runtime auto-injection**.
|
|
29
|
+
|
|
30
|
+
## Key API
|
|
31
|
+
- `@tunable(...)` — declare which parameters are user-tunable (per function).
|
|
32
|
+
- `schema_for_apps(*apps)` / `defaults_for_apps(*apps)` — by app tags.
|
|
33
|
+
- `schema_by_trace(entrypoint)` / `make_app_config_for_entry(entrypoint)` — by tracing (no tags needed).
|
|
34
|
+
- `use_config(cfg)` — injects sections into all decorated functions during the run.
|
|
35
|
+
- `load_app_config(app, path)` / `load_config_for_entry(entry, path)` — validate JSON against the composed model.
|
|
36
|
+
|
|
37
|
+
See `examples/` for:
|
|
38
|
+
- argparse + app tags
|
|
39
|
+
- argparse + tracing (no app)
|
|
40
|
+
- jsonargparse + app tags
|
|
41
|
+
- jsonargparse + tracing (no app)
|
tunablex-0.0.0/README.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# tunablex
|
|
2
|
+
|
|
3
|
+
Function-first **tunable parameters** for Python apps — with **automatic JSON & JSON Schema**, **per-executable** composition (via tags) or **dynamic** composition (via call-graph **tracing**), and **runtime auto-injection**.
|
|
4
|
+
|
|
5
|
+
## Key API
|
|
6
|
+
- `@tunable(...)` — declare which parameters are user-tunable (per function).
|
|
7
|
+
- `schema_for_apps(*apps)` / `defaults_for_apps(*apps)` — by app tags.
|
|
8
|
+
- `schema_by_trace(entrypoint)` / `make_app_config_for_entry(entrypoint)` — by tracing (no tags needed).
|
|
9
|
+
- `use_config(cfg)` — injects sections into all decorated functions during the run.
|
|
10
|
+
- `load_app_config(app, path)` / `load_config_for_entry(entry, path)` — validate JSON against the composed model.
|
|
11
|
+
|
|
12
|
+
See `examples/` for:
|
|
13
|
+
- argparse + app tags
|
|
14
|
+
- argparse + tracing (no app)
|
|
15
|
+
- jsonargparse + app tags
|
|
16
|
+
- jsonargparse + tracing (no app)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.18"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tunablex" # PyPI name (normalized)
|
|
7
|
+
version = "0.0.0" # bump before each release
|
|
8
|
+
description = "Function-first tunables with auto JSON/YAML/TOML config & schema, per-executable composition (tags or trace), and runtime auto-injection."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Jacques Papper" }]
|
|
13
|
+
dependencies = ["pydantic>=2.4"]
|
|
14
|
+
optional-dependencies = { jsonargparse = ["jsonargparse>=4.26"], yaml = ["PyYAML>=6.0.1"] }
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/jackpap/tunableX"
|
|
27
|
+
Issues = "https://github.com/jackpap/tunableX/issues"
|
|
28
|
+
|
|
29
|
+
# Tell Hatch where your package lives (src/ layout)
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["src/tunablex"]
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.sdist]
|
|
34
|
+
include = ["src", "README.md", "LICENSE", "pyproject.toml"]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .decorators import tunable
|
|
2
|
+
from .runtime import (
|
|
3
|
+
schema_for_apps, defaults_for_apps, schema_by_trace,
|
|
4
|
+
make_app_config_for, make_app_config_for_entry,
|
|
5
|
+
load_app_config, load_config_for_entry,
|
|
6
|
+
use_config, write_schema,
|
|
7
|
+
)
|
|
8
|
+
from .registry import REGISTRY
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"tunable",
|
|
12
|
+
"schema_for_apps", "defaults_for_apps", "schema_by_trace",
|
|
13
|
+
"make_app_config_for", "make_app_config_for_entry",
|
|
14
|
+
"load_app_config", "load_config_for_entry",
|
|
15
|
+
"use_config", "write_schema",
|
|
16
|
+
"REGISTRY",
|
|
17
|
+
]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import argparse
|
|
3
|
+
import importlib
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from .runtime import schema_for_apps, defaults_for_apps, schema_by_trace, write_schema
|
|
7
|
+
|
|
8
|
+
def _import_modules(mods):
|
|
9
|
+
for m in mods: importlib.import_module(m)
|
|
10
|
+
|
|
11
|
+
def _add_sys_paths(paths):
|
|
12
|
+
for p in paths or []:
|
|
13
|
+
if p not in sys.path:
|
|
14
|
+
sys.path.insert(0, p)
|
|
15
|
+
|
|
16
|
+
def main(argv=None):
|
|
17
|
+
p = argparse.ArgumentParser(prog="tunablex", description="Generate JSON Schema/defaults from @tunable functions.")
|
|
18
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
19
|
+
|
|
20
|
+
s = sub.add_parser("schema", help="Emit schema/defaults for one or more apps (by tags).")
|
|
21
|
+
s.add_argument("--apps", nargs="+", required=True)
|
|
22
|
+
s.add_argument("--import", dest="imports", nargs="+", required=True)
|
|
23
|
+
s.add_argument("--sys-path", dest="sys_paths", nargs="+", default=[],
|
|
24
|
+
help="Paths to insert into sys.path before imports.")
|
|
25
|
+
s.add_argument("--out", default=None)
|
|
26
|
+
|
|
27
|
+
t = sub.add_parser("trace", help="Emit schema/defaults by tracing a module:function entrypoint.")
|
|
28
|
+
t.add_argument("--entry", required=True)
|
|
29
|
+
t.add_argument("--import", dest="imports", nargs="+", required=True)
|
|
30
|
+
t.add_argument("--sys-path", dest="sys_paths", nargs="+", default=[],
|
|
31
|
+
help="Paths to insert into sys.path before imports.")
|
|
32
|
+
t.add_argument("--out", default=None)
|
|
33
|
+
|
|
34
|
+
args = p.parse_args(argv)
|
|
35
|
+
_add_sys_paths(args.sys_paths)
|
|
36
|
+
_import_modules(args.imports)
|
|
37
|
+
|
|
38
|
+
if args.cmd == "schema":
|
|
39
|
+
schema = schema_for_apps(*args.apps)
|
|
40
|
+
defaults = defaults_for_apps(*args.apps)
|
|
41
|
+
if args.out: write_schema(args.out, schema, defaults)
|
|
42
|
+
else: print(json.dumps({"schema": schema, "defaults": defaults}, indent=2))
|
|
43
|
+
return 0
|
|
44
|
+
|
|
45
|
+
if args.cmd == "trace":
|
|
46
|
+
modname, funcname = args.entry.split(":")
|
|
47
|
+
fn = getattr(importlib.import_module(modname), funcname)
|
|
48
|
+
schema, defaults, touched = schema_by_trace(fn)
|
|
49
|
+
if args.out: write_schema(args.out, schema, defaults)
|
|
50
|
+
else: print(json.dumps({"schema": schema, "defaults": defaults, "touched": sorted(touched)}, indent=2))
|
|
51
|
+
return 0
|
|
52
|
+
|
|
53
|
+
return 1
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__":
|
|
56
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import get_origin, get_args, Literal
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
from jsonargparse import ArgumentParser # works with jsonargparse or argparse-compatible
|
|
5
|
+
from .runtime import make_app_config_for, make_app_config_for_entry
|
|
6
|
+
from .io import load_structured_config
|
|
7
|
+
|
|
8
|
+
# Accepts yes/no/true/false/1/0/on/off/y/n (case-insensitive)
|
|
9
|
+
def _parse_bool(value: str) -> bool:
|
|
10
|
+
v = str(value).strip().lower()
|
|
11
|
+
if v in {"1","true","t","yes","y","on"}:
|
|
12
|
+
return True
|
|
13
|
+
if v in {"0","false","f","no","n","off"}:
|
|
14
|
+
return False
|
|
15
|
+
raise ValueError(f"invalid boolean: {value!r} (expected yes/no/true/false/1/0)")
|
|
16
|
+
|
|
17
|
+
def add_flags_from_model(parser: ArgumentParser, AppConfig) -> None:
|
|
18
|
+
"""Create flags like --section.field for each tunable in the AppConfig model."""
|
|
19
|
+
for section_name, section_field in AppConfig.model_fields.items():
|
|
20
|
+
section_model = section_field.annotation
|
|
21
|
+
if not (isinstance(section_model, type) and issubclass(section_model, BaseModel)):
|
|
22
|
+
continue
|
|
23
|
+
grp = parser.add_argument_group(section_name)
|
|
24
|
+
for name, fld in section_model.model_fields.items():
|
|
25
|
+
ann = fld.annotation
|
|
26
|
+
flag = f"--{section_name}.{name}"
|
|
27
|
+
dest = f"TX__{section_name}__{name}"
|
|
28
|
+
if get_origin(ann) is Literal:
|
|
29
|
+
grp.add_argument(flag, choices=[*get_args(ann)], dest=dest)
|
|
30
|
+
elif ann is bool:
|
|
31
|
+
grp.add_argument(flag, type=_parse_bool, metavar="{yes|no}", dest=dest)
|
|
32
|
+
elif ann in (int, float, str):
|
|
33
|
+
grp.add_argument(flag, type=ann, dest=dest)
|
|
34
|
+
else:
|
|
35
|
+
grp.add_argument(flag, type=str, dest=dest) # fallback; validated later
|
|
36
|
+
|
|
37
|
+
def add_flags_by_app(parser: ArgumentParser, app: str) -> None:
|
|
38
|
+
AppConfig = make_app_config_for(app)
|
|
39
|
+
add_flags_from_model(parser, AppConfig)
|
|
40
|
+
|
|
41
|
+
def add_flags_by_entry(parser: ArgumentParser, entrypoint, *args, **kwargs) -> None:
|
|
42
|
+
AppConfig = make_app_config_for_entry(entrypoint, *args, **kwargs)
|
|
43
|
+
add_flags_from_model(parser, AppConfig)
|
|
44
|
+
|
|
45
|
+
def deep_update(base: dict, extra: dict) -> dict:
|
|
46
|
+
for k, v in (extra or {}).items():
|
|
47
|
+
if isinstance(v, dict) and isinstance(base.get(k), dict):
|
|
48
|
+
deep_update(base[k], v)
|
|
49
|
+
else:
|
|
50
|
+
base[k] = v
|
|
51
|
+
return base
|
|
52
|
+
|
|
53
|
+
def collect_overrides(args, AppConfig) -> dict:
|
|
54
|
+
out = {}
|
|
55
|
+
for section_name, section_field in AppConfig.model_fields.items():
|
|
56
|
+
section_model = section_field.annotation
|
|
57
|
+
if not (isinstance(section_model, type) and issubclass(section_model, BaseModel)):
|
|
58
|
+
continue
|
|
59
|
+
for name in section_model.model_fields.keys():
|
|
60
|
+
dest = f"TX__{section_name}__{name}"
|
|
61
|
+
if hasattr(args, dest):
|
|
62
|
+
val = getattr(args, dest)
|
|
63
|
+
if val is not None:
|
|
64
|
+
out.setdefault(section_name, {})[name] = val
|
|
65
|
+
return out
|
|
66
|
+
|
|
67
|
+
def build_cfg_from_file_and_args(AppConfig, args, config_attr: str = "config") -> dict:
|
|
68
|
+
cfg = AppConfig().model_dump()
|
|
69
|
+
path = getattr(args, config_attr, None)
|
|
70
|
+
if path:
|
|
71
|
+
cfg = deep_update(cfg, load_structured_config(path))
|
|
72
|
+
overrides = collect_overrides(args, AppConfig)
|
|
73
|
+
cfg = deep_update(cfg, overrides)
|
|
74
|
+
return cfg
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import contextvars
|
|
3
|
+
|
|
4
|
+
# Trace context to collect namespaces touched during a dry-run
|
|
5
|
+
_active_trace = contextvars.ContextVar("tunablex_trace", default=None)
|
|
6
|
+
|
|
7
|
+
class trace_tunables:
|
|
8
|
+
def __init__(self, noop: bool = True):
|
|
9
|
+
self.namespaces = set()
|
|
10
|
+
self.noop = noop
|
|
11
|
+
def __enter__(self):
|
|
12
|
+
self._tok = _active_trace.set(self)
|
|
13
|
+
return self
|
|
14
|
+
def __exit__(self, et, e, tb):
|
|
15
|
+
_active_trace.reset(self._tok)
|
|
16
|
+
|
|
17
|
+
# Active config context used at runtime for auto-injection
|
|
18
|
+
_active_cfg = contextvars.ContextVar("tunablex_active_cfg", default=None)
|
|
19
|
+
|
|
20
|
+
class use_config:
|
|
21
|
+
def __init__(self, cfg):
|
|
22
|
+
self.cfg = cfg
|
|
23
|
+
def __enter__(self):
|
|
24
|
+
self._tok = _active_cfg.set(self.cfg)
|
|
25
|
+
return self.cfg
|
|
26
|
+
def __exit__(self, et, e, tb):
|
|
27
|
+
_active_cfg.reset(self._tok)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Any, Iterable, Literal, get_type_hints
|
|
3
|
+
import inspect
|
|
4
|
+
import functools
|
|
5
|
+
from pydantic import BaseModel, create_model
|
|
6
|
+
from .registry import REGISTRY, TunableEntry
|
|
7
|
+
from .context import _active_trace, _active_cfg
|
|
8
|
+
from .naming import ns_to_field
|
|
9
|
+
|
|
10
|
+
def tunable(*include: str, namespace: str|None=None, mode: Literal["include","exclude"]="include",
|
|
11
|
+
exclude: Iterable[str]|None=None, apps: Iterable[str]=()):
|
|
12
|
+
"""Mark a function's selected parameters as user-tunable.
|
|
13
|
+
- include: names to include. If empty, include all params that have defaults
|
|
14
|
+
(unless mode='exclude' with an explicit exclude list).
|
|
15
|
+
- namespace: JSON section name; defaults to 'module.function'.
|
|
16
|
+
- apps: optional tags to group functions per executable/app.
|
|
17
|
+
"""
|
|
18
|
+
include_set = set(include) if include else None
|
|
19
|
+
exclude_set = set(exclude or ())
|
|
20
|
+
|
|
21
|
+
def decorator(fn):
|
|
22
|
+
sig = inspect.signature(fn)
|
|
23
|
+
hints = get_type_hints(fn)
|
|
24
|
+
fields: dict[str, tuple[type[Any], Any]] = {}
|
|
25
|
+
for name, p in sig.parameters.items():
|
|
26
|
+
if p.kind in (p.VAR_POSITIONAL, p.VAR_KEYWORD):
|
|
27
|
+
continue
|
|
28
|
+
if include_set is not None:
|
|
29
|
+
selected = name in include_set
|
|
30
|
+
elif mode == "exclude" and exclude_set:
|
|
31
|
+
selected = (p.default is not inspect._empty) and (name not in exclude_set)
|
|
32
|
+
else:
|
|
33
|
+
selected = (p.default is not inspect._empty)
|
|
34
|
+
if not selected:
|
|
35
|
+
continue
|
|
36
|
+
ann = hints.get(name, Any)
|
|
37
|
+
default = p.default if p.default is not inspect._empty else ...
|
|
38
|
+
fields[name] = (ann, default)
|
|
39
|
+
|
|
40
|
+
ns = namespace or f"{fn.__module__}.{fn.__name__}"
|
|
41
|
+
model_name = f"{ns.title().replace('.','').replace('_','')}Config"
|
|
42
|
+
Model = create_model(model_name, **fields) # type: ignore
|
|
43
|
+
|
|
44
|
+
REGISTRY.register(TunableEntry(fn=fn, model=Model, sig=sig, namespace=ns, apps=set(apps)))
|
|
45
|
+
|
|
46
|
+
@functools.wraps(fn)
|
|
47
|
+
def wrapper(*args, cfg: BaseModel | dict | None = None, **kwargs):
|
|
48
|
+
tracer = _active_trace.get()
|
|
49
|
+
if tracer is not None:
|
|
50
|
+
tracer.namespaces.add(ns)
|
|
51
|
+
if tracer.noop:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
if cfg is not None:
|
|
55
|
+
data = cfg if isinstance(cfg, dict) else cfg.model_dump()
|
|
56
|
+
filtered = {k: v for k, v in data.items() if k in sig.parameters}
|
|
57
|
+
return fn(*args, **filtered, **kwargs)
|
|
58
|
+
|
|
59
|
+
app_cfg = _active_cfg.get()
|
|
60
|
+
if app_cfg is not None:
|
|
61
|
+
section_attr = ns_to_field(ns)
|
|
62
|
+
if hasattr(app_cfg, section_attr):
|
|
63
|
+
section = getattr(app_cfg, section_attr)
|
|
64
|
+
if section is not None:
|
|
65
|
+
data = section if isinstance(section, dict) else section.model_dump()
|
|
66
|
+
filtered = {k: v for k, v in data.items() if k in sig.parameters}
|
|
67
|
+
return fn(*args, **filtered, **kwargs)
|
|
68
|
+
|
|
69
|
+
return fn(*args, **kwargs)
|
|
70
|
+
|
|
71
|
+
return wrapper
|
|
72
|
+
|
|
73
|
+
return decorator
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Dict
|
|
5
|
+
|
|
6
|
+
def load_structured_config(path: str | Path) -> Dict[str, Any]:
|
|
7
|
+
"""Load JSON or YAML (and TOML when '.toml') into a dict.
|
|
8
|
+
Tries by extension first; falls back to JSON then YAML.
|
|
9
|
+
"""
|
|
10
|
+
p = Path(path)
|
|
11
|
+
text = p.read_text()
|
|
12
|
+
ext = p.suffix.lower()
|
|
13
|
+
|
|
14
|
+
if ext in {".yml", ".yaml"}:
|
|
15
|
+
try:
|
|
16
|
+
import yaml # PyYAML
|
|
17
|
+
except ModuleNotFoundError as e:
|
|
18
|
+
raise RuntimeError(
|
|
19
|
+
"YAML file provided but PyYAML is not installed. "
|
|
20
|
+
"Install with: uv pip install '.[yaml]'"
|
|
21
|
+
) from e
|
|
22
|
+
return yaml.safe_load(text) or {}
|
|
23
|
+
|
|
24
|
+
if ext == ".json":
|
|
25
|
+
return json.loads(text)
|
|
26
|
+
|
|
27
|
+
if ext == ".toml":
|
|
28
|
+
try:
|
|
29
|
+
import tomllib # Python 3.11+
|
|
30
|
+
except ModuleNotFoundError:
|
|
31
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
32
|
+
return tomllib.loads(text) # type: ignore[attr-defined]
|
|
33
|
+
|
|
34
|
+
# Fallback: try JSON then YAML
|
|
35
|
+
try:
|
|
36
|
+
return json.loads(text)
|
|
37
|
+
except json.JSONDecodeError:
|
|
38
|
+
try:
|
|
39
|
+
import yaml # PyYAML
|
|
40
|
+
return yaml.safe_load(text) or {}
|
|
41
|
+
except Exception as e:
|
|
42
|
+
raise RuntimeError(f"Could not parse config file: {p}") from e
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from typing import Any, Iterable
|
|
4
|
+
import inspect
|
|
5
|
+
from pydantic import BaseModel, Field, create_model
|
|
6
|
+
from .naming import ns_to_field
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class TunableEntry:
|
|
10
|
+
fn: Any
|
|
11
|
+
model: type[BaseModel]
|
|
12
|
+
sig: inspect.Signature
|
|
13
|
+
namespace: str
|
|
14
|
+
apps: set[str] = field(default_factory=set)
|
|
15
|
+
|
|
16
|
+
class TunableRegistry:
|
|
17
|
+
def __init__(self):
|
|
18
|
+
self.by_namespace: dict[str, TunableEntry] = {}
|
|
19
|
+
|
|
20
|
+
def register(self, entry: TunableEntry):
|
|
21
|
+
self.by_namespace[entry.namespace] = entry
|
|
22
|
+
|
|
23
|
+
def namespaces_for_apps(self, apps: Iterable[str]) -> list[str]:
|
|
24
|
+
want = set(apps)
|
|
25
|
+
return [ns for ns, e in self.by_namespace.items() if e.apps & want]
|
|
26
|
+
|
|
27
|
+
def build_config(self, namespaces: Iterable[str]):
|
|
28
|
+
from typing import Optional as TypingOptional
|
|
29
|
+
fields: dict[str, tuple[type[BaseModel] | Any, Any]] = {}
|
|
30
|
+
for ns in sorted(set(list(namespaces))):
|
|
31
|
+
if ns not in self.by_namespace:
|
|
32
|
+
continue
|
|
33
|
+
entry = self.by_namespace[ns]
|
|
34
|
+
Model = entry.model
|
|
35
|
+
field_name = ns_to_field(ns)
|
|
36
|
+
try:
|
|
37
|
+
Model() # no required fields
|
|
38
|
+
fields[field_name] = (Model, Field(default_factory=Model))
|
|
39
|
+
except Exception:
|
|
40
|
+
fields[field_name] = (TypingOptional[Model], None)
|
|
41
|
+
if not fields:
|
|
42
|
+
return create_model("AppConfigEmpty") # type: ignore
|
|
43
|
+
return create_model("AppConfig", **fields) # type: ignore
|
|
44
|
+
|
|
45
|
+
REGISTRY = TunableRegistry()
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Callable
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from pydantic import ValidationError
|
|
6
|
+
from .io import load_structured_config
|
|
7
|
+
from .registry import REGISTRY
|
|
8
|
+
from .context import use_config, trace_tunables # noqa: F401
|
|
9
|
+
|
|
10
|
+
def schema_for_apps(*apps: str) -> dict:
|
|
11
|
+
AppConfig = REGISTRY.build_config(REGISTRY.namespaces_for_apps(apps))
|
|
12
|
+
return AppConfig.model_json_schema()
|
|
13
|
+
|
|
14
|
+
def defaults_for_apps(*apps: str) -> dict:
|
|
15
|
+
AppConfig = REGISTRY.build_config(REGISTRY.namespaces_for_apps(apps))
|
|
16
|
+
try:
|
|
17
|
+
return AppConfig().model_dump()
|
|
18
|
+
except Exception:
|
|
19
|
+
return {}
|
|
20
|
+
|
|
21
|
+
def schema_by_trace(run_callable: Callable, *args, **kwargs):
|
|
22
|
+
with trace_tunables(noop=True) as t:
|
|
23
|
+
run_callable(*args, **kwargs)
|
|
24
|
+
AppConfig = REGISTRY.build_config(t.namespaces)
|
|
25
|
+
try:
|
|
26
|
+
defaults = AppConfig().model_dump()
|
|
27
|
+
except Exception:
|
|
28
|
+
defaults = {}
|
|
29
|
+
return AppConfig.model_json_schema(), defaults, t.namespaces
|
|
30
|
+
|
|
31
|
+
def write_schema(prefix: str, schema: dict, defaults: dict|None=None):
|
|
32
|
+
Path(f"{prefix}.schema.json").write_text(json.dumps(schema, indent=2))
|
|
33
|
+
if defaults is not None:
|
|
34
|
+
Path(f"{prefix}.json").write_text(json.dumps(defaults, indent=2))
|
|
35
|
+
|
|
36
|
+
def make_app_config_for(app: str):
|
|
37
|
+
namespaces = REGISTRY.namespaces_for_apps([app])
|
|
38
|
+
return REGISTRY.build_config(namespaces)
|
|
39
|
+
|
|
40
|
+
def load_app_config(app: str, json_path: str | Path):
|
|
41
|
+
AppConfig = make_app_config_for(app)
|
|
42
|
+
data = load_structured_config(json_path)
|
|
43
|
+
try:
|
|
44
|
+
return AppConfig.model_validate(data)
|
|
45
|
+
except ValidationError as e:
|
|
46
|
+
raise SystemExit(f"Invalid config for app '{app}':\n{e}") from None
|
|
47
|
+
|
|
48
|
+
# Tracing-based (no app)
|
|
49
|
+
def make_app_config_for_entry(entrypoint: Callable, *args, **kwargs):
|
|
50
|
+
with trace_tunables(noop=True) as t:
|
|
51
|
+
entrypoint(*args, **kwargs)
|
|
52
|
+
return REGISTRY.build_config(t.namespaces)
|
|
53
|
+
|
|
54
|
+
def load_config_for_entry(entrypoint: Callable, json_path: str | Path, *args, **kwargs):
|
|
55
|
+
AppConfig = make_app_config_for_entry(entrypoint, *args, **kwargs)
|
|
56
|
+
data = load_structured_config(json_path)
|
|
57
|
+
try:
|
|
58
|
+
return AppConfig.model_validate(data)
|
|
59
|
+
except ValidationError as e:
|
|
60
|
+
raise SystemExit(f"Invalid config for traced entrypoint:\n{e}") from None
|