tunablex 0.0.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.
tunablex/__init__.py ADDED
@@ -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
+ ]
tunablex/cli.py ADDED
@@ -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
tunablex/context.py ADDED
@@ -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)
tunablex/decorators.py ADDED
@@ -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
tunablex/io.py ADDED
@@ -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
tunablex/naming.py ADDED
@@ -0,0 +1,3 @@
1
+ def ns_to_field(ns: str) -> str:
2
+ """Convert a dotted namespace to a valid attribute name on the AppConfig."""
3
+ return ns.replace('.', '__').replace('-', '_')
tunablex/registry.py ADDED
@@ -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()
tunablex/runtime.py ADDED
@@ -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
@@ -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)
@@ -0,0 +1,13 @@
1
+ tunablex/__init__.py,sha256=_AImXeMwvpDx3_bnv8faCR48WKjX-O4tBOR5QEbRSI4,518
2
+ tunablex/cli.py,sha256=-E9iO8DmT6vjBbE5eyuoebJU9sEAWc79NZg1mKtxn1Q,2236
3
+ tunablex/cli_helpers.py,sha256=TIPrIZr5deqn9TP100bqsedq3IGG2m_t-4jMj_YkN4Y,3276
4
+ tunablex/context.py,sha256=pTaovEQp5SOL1BB9HhoOuCF4PEavVKXenaExQgPyayQ,847
5
+ tunablex/decorators.py,sha256=IIvJdz1r_hu_CxS2Zk0xoVRMlVhAqpSQfhOMFkV_PBE,3155
6
+ tunablex/io.py,sha256=Q4XIGQ--7Mt6yfE-onP4Em6zOzI6cgUyaDv62aqTdbQ,1316
7
+ tunablex/naming.py,sha256=Q6X_IMNlmYetf8aUuBeIfRV75qmiyQMmc_8jy9uHS7c,165
8
+ tunablex/registry.py,sha256=snITLxohUoPBu_ws9gBHKHNdz2Il58Xrw7XYV5SEosk,1575
9
+ tunablex/runtime.py,sha256=GhjQxbrAXJrmf9w8jI0eUSaCGbr8SPp8YKltMWuibwo,2274
10
+ tunablex-0.0.0.dist-info/METADATA,sha256=Rsl1HyTtSFDbQKlbGNLxckhnzLtBn7iqUzPe-yfuPDE,1857
11
+ tunablex-0.0.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
12
+ tunablex-0.0.0.dist-info/licenses/LICENSE,sha256=G_hrSt7MgOIacDRTFMmzBywplcTcj8mhJ5qAdt0URrk,1114
13
+ tunablex-0.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.