hydr8 0.1.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.
@@ -0,0 +1,31 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python-version: ["3.8", "3.10", "3.12"]
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: astral-sh/setup-uv@v4
17
+ - run: uv run --python ${{ matrix.python-version }} pytest tests/ -v
18
+
19
+ publish:
20
+ needs: test
21
+ runs-on: ubuntu-latest
22
+ permissions:
23
+ id-token: write
24
+ environment:
25
+ name: pypi
26
+ url: https://pypi.org/p/hydr8
27
+ steps:
28
+ - uses: actions/checkout@v4
29
+ - uses: astral-sh/setup-uv@v4
30
+ - run: uv build
31
+ - uses: pypa/gh-action-pypi-publish@release/v1
hydr8-0.1.0/.gitignore ADDED
@@ -0,0 +1,3 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
hydr8-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,212 @@
1
+ Metadata-Version: 2.4
2
+ Name: hydr8
3
+ Version: 0.1.0
4
+ Summary: Decorator-based config injection for Hydra
5
+ Requires-Python: >=3.8
6
+ Requires-Dist: omegaconf>=2.1
7
+ Description-Content-Type: text/markdown
8
+
9
+ # hydr8
10
+
11
+ Decorator-based config injection for [Hydra](https://hydra.cc/).
12
+
13
+ hydr8 lets you push Hydra config (or any config as long as it's a dict) values into function parameters automatically, so your functions stay clean and testable without manually threading `cfg` everywhere.
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ pip install hydr8
19
+ # or
20
+ uv add hydr8
21
+ ```
22
+
23
+ ## Quick start
24
+
25
+ ```python
26
+ import hydra
27
+ from omegaconf import DictConfig
28
+ import hydr8
29
+
30
+ @hydr8.use("db")
31
+ def connect(host: str, port: int):
32
+ print(f"Connecting to {host}:{port}")
33
+
34
+ @hydra.main(config_path="conf", config_name="config", version_base=None)
35
+ def main(cfg: DictConfig):
36
+ hydr8.init(cfg)
37
+ connect() # host and port injected from cfg.db
38
+
39
+ if __name__ == "__main__":
40
+ main()
41
+ ```
42
+
43
+ With a config like:
44
+
45
+ ```yaml
46
+ db:
47
+ host: localhost
48
+ port: 5432
49
+ ```
50
+
51
+ ## Usage
52
+
53
+ `hydr8.use()` is the core function. It can be used as a **decorator** to inject config into function parameters, or called **directly** to access a config sub-tree as a dict.
54
+
55
+ ### As a decorator
56
+
57
+ #### Explicit path
58
+
59
+ Pass a dot-separated path to resolve a specific config node. Config keys are matched to function parameter names. Extra config keys that don't match any parameter are silently ignored.
60
+
61
+ ```python
62
+ @hydr8.use("db.postgres")
63
+ def connect(host: str, port: int, user: str):
64
+ ...
65
+
66
+ connect() # all three injected from cfg.db.postgres
67
+ connect(host="remote") # host overridden, port and user from config
68
+ ```
69
+
70
+ List indexing is supported:
71
+
72
+ ```python
73
+ @hydr8.use("db.replicas[0]")
74
+ def connect(host: str, port: int):
75
+ ...
76
+ ```
77
+
78
+ #### Implicit path (auto-resolve)
79
+
80
+ When no path is given, hydr8 derives it from the function's `__module__`. If the first segment of the module isn't a top-level config key, it's treated as the project name and stripped — so auto-resolve works whether you run with `python -m` or `python file.py`:
81
+
82
+ ```python
83
+ # In myproject/data/loaders.py
84
+ @hydr8.use()
85
+ def build_loader(batch_size: int, shuffle: bool):
86
+ ...
87
+ # Resolves to cfg.data.loaders
88
+ ```
89
+
90
+ ```yaml
91
+ # config.yaml
92
+ data:
93
+ loaders:
94
+ batch_size: 32
95
+ shuffle: true
96
+ ```
97
+
98
+ By default, `scope="module"` — the path resolves to the module's config node, and config keys are matched to function parameters. Multiple functions in the same module share the same config node.
99
+
100
+ With `scope="fn"`, the function's qualname is appended to the path:
101
+
102
+ ```python
103
+ # In myproject/data/loaders.py
104
+ @hydr8.use(scope="fn")
105
+ def build_loader(batch_size: int, shuffle: bool):
106
+ ...
107
+ # Resolves to cfg.data.loaders.build_loader
108
+ ```
109
+
110
+ ```yaml
111
+ # config.yaml
112
+ data:
113
+ loaders:
114
+ build_loader:
115
+ batch_size: 32
116
+ shuffle: true
117
+ ```
118
+
119
+ This works with methods too:
120
+
121
+ ```python
122
+ # In myproject/db/client.py
123
+ class Client:
124
+ @hydr8.use(scope="fn")
125
+ def __init__(self, host: str, port: int):
126
+ self.host = host
127
+ self.port = port
128
+ # Resolves to cfg.db.client.Client.__init__
129
+ ```
130
+
131
+ #### `as_dict` mode
132
+
133
+ Pass the entire resolved sub-config as a single dict argument instead of matching individual keys:
134
+
135
+ ```python
136
+ @hydr8.use("db.postgres", as_dict="config")
137
+ def connect(config: dict):
138
+ host = config["host"]
139
+ port = config["port"]
140
+ ...
141
+ ```
142
+
143
+ #### Caller overrides
144
+
145
+ Caller-provided arguments always take precedence over injected config. If every required parameter is supplied by the caller, config is never accessed at all:
146
+
147
+ ```python
148
+ @hydr8.use("db")
149
+ def connect(host: str, port: int):
150
+ ...
151
+
152
+ connect(host="remote") # port from config, host = "remote"
153
+ connect("localhost", 5432) # config not accessed
154
+ ```
155
+
156
+ ### As a direct call
157
+
158
+ `hydr8.use("path")` returns a lazy, dict-like proxy. The config is resolved on first access, not at call time, so you can call `use()` before `init()`.
159
+
160
+ ```python
161
+ import hydr8
162
+
163
+ db = hydr8.use("db")
164
+
165
+ hydr8.init(cfg)
166
+ db["host"] # "localhost"
167
+ db["port"] # 5432
168
+ ```
169
+
170
+ This is useful when you want to read config values without decorating a function:
171
+
172
+ ```python
173
+ def connect():
174
+ db = hydr8.use("db")
175
+ engine = create_engine(f"postgresql://{db['host']}:{db['port']}")
176
+ ...
177
+ ```
178
+
179
+ An explicit path is required when using `use()` as a direct call. Calling `use()` without a path and accessing it raises `TypeError`, since there is no function to derive the path from.
180
+
181
+ ## Testing
182
+
183
+ ### Option A: Supply all arguments directly
184
+
185
+ When every required parameter is provided by the caller, config injection is skipped entirely — no `init` needed:
186
+
187
+ ```python
188
+ def test_connect():
189
+ assert connect("localhost", 5432) == expected
190
+ ```
191
+
192
+ ### Option B: `override` context manager
193
+
194
+ Temporarily replace the global config for a test:
195
+
196
+ ```python
197
+ from hydr8 import override
198
+
199
+ def test_connect():
200
+ with override({"db": {"host": "test-host", "port": 9999}}):
201
+ result = connect()
202
+ assert result == expected
203
+ ```
204
+
205
+ ## API reference
206
+
207
+ | Function | Description |
208
+ |---|---|
209
+ | `init(cfg)` | Store the config globally (accepts any dict or OmegaConf DictConfig) |
210
+ | `get()` | Retrieve the stored config (raises `RuntimeError` if uninitialized) |
211
+ | `override(overrides)` | Context manager that temporarily replaces the config |
212
+ | `use(path, *, as_dict, scope)` | Decorator or direct config accessor for a config sub-tree |
hydr8-0.1.0/README.md ADDED
@@ -0,0 +1,204 @@
1
+ # hydr8
2
+
3
+ Decorator-based config injection for [Hydra](https://hydra.cc/).
4
+
5
+ hydr8 lets you push Hydra config (or any config as long as it's a dict) values into function parameters automatically, so your functions stay clean and testable without manually threading `cfg` everywhere.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install hydr8
11
+ # or
12
+ uv add hydr8
13
+ ```
14
+
15
+ ## Quick start
16
+
17
+ ```python
18
+ import hydra
19
+ from omegaconf import DictConfig
20
+ import hydr8
21
+
22
+ @hydr8.use("db")
23
+ def connect(host: str, port: int):
24
+ print(f"Connecting to {host}:{port}")
25
+
26
+ @hydra.main(config_path="conf", config_name="config", version_base=None)
27
+ def main(cfg: DictConfig):
28
+ hydr8.init(cfg)
29
+ connect() # host and port injected from cfg.db
30
+
31
+ if __name__ == "__main__":
32
+ main()
33
+ ```
34
+
35
+ With a config like:
36
+
37
+ ```yaml
38
+ db:
39
+ host: localhost
40
+ port: 5432
41
+ ```
42
+
43
+ ## Usage
44
+
45
+ `hydr8.use()` is the core function. It can be used as a **decorator** to inject config into function parameters, or called **directly** to access a config sub-tree as a dict.
46
+
47
+ ### As a decorator
48
+
49
+ #### Explicit path
50
+
51
+ Pass a dot-separated path to resolve a specific config node. Config keys are matched to function parameter names. Extra config keys that don't match any parameter are silently ignored.
52
+
53
+ ```python
54
+ @hydr8.use("db.postgres")
55
+ def connect(host: str, port: int, user: str):
56
+ ...
57
+
58
+ connect() # all three injected from cfg.db.postgres
59
+ connect(host="remote") # host overridden, port and user from config
60
+ ```
61
+
62
+ List indexing is supported:
63
+
64
+ ```python
65
+ @hydr8.use("db.replicas[0]")
66
+ def connect(host: str, port: int):
67
+ ...
68
+ ```
69
+
70
+ #### Implicit path (auto-resolve)
71
+
72
+ When no path is given, hydr8 derives it from the function's `__module__`. If the first segment of the module isn't a top-level config key, it's treated as the project name and stripped — so auto-resolve works whether you run with `python -m` or `python file.py`:
73
+
74
+ ```python
75
+ # In myproject/data/loaders.py
76
+ @hydr8.use()
77
+ def build_loader(batch_size: int, shuffle: bool):
78
+ ...
79
+ # Resolves to cfg.data.loaders
80
+ ```
81
+
82
+ ```yaml
83
+ # config.yaml
84
+ data:
85
+ loaders:
86
+ batch_size: 32
87
+ shuffle: true
88
+ ```
89
+
90
+ By default, `scope="module"` — the path resolves to the module's config node, and config keys are matched to function parameters. Multiple functions in the same module share the same config node.
91
+
92
+ With `scope="fn"`, the function's qualname is appended to the path:
93
+
94
+ ```python
95
+ # In myproject/data/loaders.py
96
+ @hydr8.use(scope="fn")
97
+ def build_loader(batch_size: int, shuffle: bool):
98
+ ...
99
+ # Resolves to cfg.data.loaders.build_loader
100
+ ```
101
+
102
+ ```yaml
103
+ # config.yaml
104
+ data:
105
+ loaders:
106
+ build_loader:
107
+ batch_size: 32
108
+ shuffle: true
109
+ ```
110
+
111
+ This works with methods too:
112
+
113
+ ```python
114
+ # In myproject/db/client.py
115
+ class Client:
116
+ @hydr8.use(scope="fn")
117
+ def __init__(self, host: str, port: int):
118
+ self.host = host
119
+ self.port = port
120
+ # Resolves to cfg.db.client.Client.__init__
121
+ ```
122
+
123
+ #### `as_dict` mode
124
+
125
+ Pass the entire resolved sub-config as a single dict argument instead of matching individual keys:
126
+
127
+ ```python
128
+ @hydr8.use("db.postgres", as_dict="config")
129
+ def connect(config: dict):
130
+ host = config["host"]
131
+ port = config["port"]
132
+ ...
133
+ ```
134
+
135
+ #### Caller overrides
136
+
137
+ Caller-provided arguments always take precedence over injected config. If every required parameter is supplied by the caller, config is never accessed at all:
138
+
139
+ ```python
140
+ @hydr8.use("db")
141
+ def connect(host: str, port: int):
142
+ ...
143
+
144
+ connect(host="remote") # port from config, host = "remote"
145
+ connect("localhost", 5432) # config not accessed
146
+ ```
147
+
148
+ ### As a direct call
149
+
150
+ `hydr8.use("path")` returns a lazy, dict-like proxy. The config is resolved on first access, not at call time, so you can call `use()` before `init()`.
151
+
152
+ ```python
153
+ import hydr8
154
+
155
+ db = hydr8.use("db")
156
+
157
+ hydr8.init(cfg)
158
+ db["host"] # "localhost"
159
+ db["port"] # 5432
160
+ ```
161
+
162
+ This is useful when you want to read config values without decorating a function:
163
+
164
+ ```python
165
+ def connect():
166
+ db = hydr8.use("db")
167
+ engine = create_engine(f"postgresql://{db['host']}:{db['port']}")
168
+ ...
169
+ ```
170
+
171
+ An explicit path is required when using `use()` as a direct call. Calling `use()` without a path and accessing it raises `TypeError`, since there is no function to derive the path from.
172
+
173
+ ## Testing
174
+
175
+ ### Option A: Supply all arguments directly
176
+
177
+ When every required parameter is provided by the caller, config injection is skipped entirely — no `init` needed:
178
+
179
+ ```python
180
+ def test_connect():
181
+ assert connect("localhost", 5432) == expected
182
+ ```
183
+
184
+ ### Option B: `override` context manager
185
+
186
+ Temporarily replace the global config for a test:
187
+
188
+ ```python
189
+ from hydr8 import override
190
+
191
+ def test_connect():
192
+ with override({"db": {"host": "test-host", "port": 9999}}):
193
+ result = connect()
194
+ assert result == expected
195
+ ```
196
+
197
+ ## API reference
198
+
199
+ | Function | Description |
200
+ |---|---|
201
+ | `init(cfg)` | Store the config globally (accepts any dict or OmegaConf DictConfig) |
202
+ | `get()` | Retrieve the stored config (raises `RuntimeError` if uninitialized) |
203
+ | `override(overrides)` | Context manager that temporarily replaces the config |
204
+ | `use(path, *, as_dict, scope)` | Decorator or direct config accessor for a config sub-tree |
@@ -0,0 +1,14 @@
1
+ [project]
2
+ name = "hydr8"
3
+ version = "0.1.0"
4
+ description = "Decorator-based config injection for Hydra"
5
+ readme = "README.md"
6
+ requires-python = ">=3.8"
7
+ dependencies = ["omegaconf>=2.1"]
8
+
9
+ [build-system]
10
+ requires = ["hatchling"]
11
+ build-backend = "hatchling.build"
12
+
13
+ [dependency-groups]
14
+ dev = ["pytest"]
@@ -0,0 +1,4 @@
1
+ from ._store import get, init, override
2
+ from ._decorator import use
3
+
4
+ __all__ = ["get", "init", "override", "use"]
@@ -0,0 +1,177 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ import inspect
5
+ from typing import Any, Callable, Iterator, TypeVar
6
+
7
+ from ._store import get
8
+ from ._resolver import resolve, resolve_auto
9
+
10
+ F = TypeVar("F", bound=Callable[..., Any])
11
+
12
+
13
+ class _ConfigProxy:
14
+ """Returned by ``use()``. Acts as both a decorator and a lazy config dict."""
15
+
16
+ def __init__(self, path: str | None, as_dict: str | None, scope: str) -> None:
17
+ self._path = path
18
+ self._as_dict = as_dict
19
+ self._scope = scope
20
+ self._resolved: dict[str, Any] | None = None
21
+
22
+ def _resolve(self) -> dict[str, Any]:
23
+ if self._resolved is None:
24
+ cfg = get()
25
+ if self._path is None:
26
+ raise TypeError(
27
+ "Cannot resolve config as a function without an explicit path. "
28
+ "Pass a path to use(), e.g. use('db')."
29
+ )
30
+ self._resolved = resolve(cfg, self._path)
31
+ return self._resolved
32
+
33
+ # -- decorator mode --
34
+
35
+ def __call__(self, fn: F) -> F:
36
+ path = self._path
37
+ as_dict = self._as_dict
38
+ scope = self._scope
39
+ sig = inspect.signature(fn)
40
+ param_names = set(sig.parameters)
41
+
42
+ @functools.wraps(fn)
43
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
44
+ bound = sig.bind_partial(*args, **kwargs)
45
+ supplied = set(bound.arguments)
46
+
47
+ required = {
48
+ name
49
+ for name, p in sig.parameters.items()
50
+ if p.default is inspect.Parameter.empty
51
+ and p.kind
52
+ not in (
53
+ inspect.Parameter.VAR_POSITIONAL,
54
+ inspect.Parameter.VAR_KEYWORD,
55
+ )
56
+ }
57
+ if required <= supplied:
58
+ return fn(*args, **kwargs)
59
+
60
+ cfg = get()
61
+ if path is None:
62
+ resolved = resolve_auto(cfg, fn, scope)
63
+ else:
64
+ resolved = resolve(cfg, path)
65
+
66
+ if as_dict is not None:
67
+ if as_dict not in supplied:
68
+ kwargs[as_dict] = resolved
69
+ else:
70
+ for key, value in resolved.items():
71
+ if key in param_names and key not in supplied:
72
+ kwargs[key] = value
73
+
74
+ return fn(*args, **kwargs)
75
+
76
+ return wrapper # type: ignore[return-value]
77
+
78
+ # -- dict-like mode --
79
+
80
+ def __getitem__(self, key: str) -> Any:
81
+ return self._resolve()[key]
82
+
83
+ def __contains__(self, key: object) -> bool:
84
+ return key in self._resolve()
85
+
86
+ def __iter__(self) -> Iterator[str]:
87
+ return iter(self._resolve())
88
+
89
+ def __len__(self) -> int:
90
+ return len(self._resolve())
91
+
92
+ def keys(self) -> Any:
93
+ return self._resolve().keys()
94
+
95
+ def values(self) -> Any:
96
+ return self._resolve().values()
97
+
98
+ def items(self) -> Any:
99
+ return self._resolve().items()
100
+
101
+ def __repr__(self) -> str:
102
+ try:
103
+ return repr(self._resolve())
104
+ except Exception:
105
+ return f"_ConfigProxy(path={self._path!r})"
106
+
107
+
108
+ def use(
109
+ path: str | None = None,
110
+ *,
111
+ as_dict: str | None = None,
112
+ scope: str = "module",
113
+ ) -> _ConfigProxy:
114
+ """Access a config sub-tree — as a decorator or a function.
115
+
116
+ Returns a proxy that can be used in two ways:
117
+
118
+ **As a decorator** — injects config values into function parameters::
119
+
120
+ @use("db")
121
+ def connect(host: str, port: int):
122
+ ...
123
+
124
+ connect() # host and port injected from cfg.db
125
+ connect(host="other") # caller args take precedence
126
+
127
+ When ``path`` is ``None`` (the default), the config path is derived
128
+ automatically from the function's module path. If the first segment
129
+ of ``__module__`` isn't a top-level config key it is treated as the
130
+ project name and stripped, so auto-resolve works whether you run with
131
+ ``python -m`` or ``python file.py``::
132
+
133
+ # In myproject/data/loaders.py
134
+ @use()
135
+ def build_loader(batch_size: int, shuffle: bool):
136
+ ...
137
+ # resolves to cfg.data.loaders (scope="module", the default)
138
+
139
+ With ``scope="fn"``, the function's ``__qualname__`` is appended::
140
+
141
+ @use(scope="fn")
142
+ def build_loader(batch_size: int, shuffle: bool):
143
+ ...
144
+ # resolves to cfg.data.loaders.build_loader
145
+
146
+ With ``as_dict``, the entire sub-config is passed as a single kwarg
147
+ instead of matching individual keys to parameters::
148
+
149
+ @use("db", as_dict="config")
150
+ def connect(config: dict):
151
+ host = config["host"]
152
+
153
+ **As a function** — returns a lazy, dict-like view of the config node.
154
+ Requires an explicit ``path``::
155
+
156
+ db = use("db")
157
+ db["host"] # "localhost"
158
+ dict(db) # {"host": "localhost", "port": 5432}
159
+ "host" in db # True
160
+
161
+ The config is resolved lazily on first access, so ``use("db")`` can be
162
+ called before ``init()``.
163
+
164
+ Calling ``use()`` without a path and accessing it as a function raises
165
+ ``TypeError``, since there is no function to derive the path from.
166
+
167
+ Args:
168
+ path: Dot-separated config path (e.g. ``"db.postgres"``). When
169
+ ``None``, the path is derived from the decorated function's
170
+ module (decorator mode only).
171
+ as_dict: When set, the resolved sub-config is passed as a single
172
+ kwarg with this name (decorator mode only).
173
+ scope: Controls auto-resolve granularity (decorator mode only).
174
+ ``"module"`` (default) resolves to the module's config node.
175
+ ``"fn"`` appends the function's qualname.
176
+ """
177
+ return _ConfigProxy(path, as_dict, scope)
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Callable
4
+
5
+ from omegaconf import DictConfig, OmegaConf
6
+
7
+
8
+ def resolve(cfg: DictConfig, path: str) -> dict[str, Any]:
9
+ """Traverse *cfg* along the dot-separated *path* and return a plain dict.
10
+
11
+ Raises ``KeyError`` if any segment is missing.
12
+ """
13
+ node = OmegaConf.select(cfg, path, throw_on_missing=True)
14
+ if node is None:
15
+ raise KeyError(f"Config path {path!r} not found")
16
+ if not OmegaConf.is_dict(node):
17
+ raise TypeError(
18
+ f"Config path {path!r} resolved to a leaf value, not a mapping"
19
+ )
20
+ return OmegaConf.to_container(node, resolve=True) # type: ignore[return-value]
21
+
22
+
23
+ def resolve_auto(
24
+ cfg: DictConfig, fn: Callable[..., Any], scope: str = "module"
25
+ ) -> dict[str, Any]:
26
+ """Derive the config path from *fn*'s module (and optionally qualname), then resolve.
27
+
28
+ If the first segment of ``__module__`` is not a top-level key in *cfg*,
29
+ it is assumed to be the project/package name and is stripped. This makes
30
+ auto-resolve work regardless of whether the code was invoked with
31
+ ``python -m`` (which includes the package prefix) or ``python file.py``
32
+ (which does not).
33
+
34
+ When *scope* is ``"module"`` (the default), only the module path is used::
35
+
36
+ myproject.data.loaders -> data.loaders
37
+
38
+ When *scope* is ``"fn"``, the function's ``__qualname__`` is appended::
39
+
40
+ myproject.data.loaders + build_loader -> data.loaders.build_loader
41
+ """
42
+ parts = fn.__module__.split(".")
43
+
44
+ # If the first segment isn't a top-level config key, it's the project
45
+ # name — strip it.
46
+ if len(parts) > 1 and parts[0] not in cfg:
47
+ parts = parts[1:]
48
+
49
+ if scope == "fn":
50
+ path = ".".join(parts) + "." + fn.__qualname__
51
+ else:
52
+ path = ".".join(parts)
53
+
54
+ return resolve(cfg, path)