configgle 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.
- configgle/__init__.py +35 -0
- configgle/copy_on_write.py +343 -0
- configgle/custom_types.py +77 -0
- configgle/decorator.py +122 -0
- configgle/fig.py +482 -0
- configgle/inline.py +193 -0
- configgle/pprinting.py +615 -0
- configgle/traverse.py +235 -0
- configgle-0.1.0.dist-info/METADATA +27 -0
- configgle-0.1.0.dist-info/RECORD +12 -0
- configgle-0.1.0.dist-info/WHEEL +4 -0
- configgle-0.1.0.dist-info/licenses/LICENSE +201 -0
configgle/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Configgle: Tools for making configurable Python classes for A/B experiments."""
|
|
2
|
+
|
|
3
|
+
from configgle.copy_on_write import CopyOnWrite
|
|
4
|
+
from configgle.custom_types import (
|
|
5
|
+
Configurable,
|
|
6
|
+
DataclassLike,
|
|
7
|
+
HasConfig,
|
|
8
|
+
HasRelaxedConfig,
|
|
9
|
+
RelaxedConfigurable,
|
|
10
|
+
)
|
|
11
|
+
from configgle.decorator import autofig
|
|
12
|
+
from configgle.fig import (
|
|
13
|
+
Fig,
|
|
14
|
+
InlineConfig,
|
|
15
|
+
PartialConfig,
|
|
16
|
+
Setupable,
|
|
17
|
+
)
|
|
18
|
+
from configgle.pprinting import pformat, pprint
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"Configurable",
|
|
23
|
+
"CopyOnWrite",
|
|
24
|
+
"DataclassLike",
|
|
25
|
+
"Fig",
|
|
26
|
+
"HasConfig",
|
|
27
|
+
"HasRelaxedConfig",
|
|
28
|
+
"InlineConfig",
|
|
29
|
+
"PartialConfig",
|
|
30
|
+
"RelaxedConfigurable",
|
|
31
|
+
"Setupable",
|
|
32
|
+
"autofig",
|
|
33
|
+
"pformat",
|
|
34
|
+
"pprint",
|
|
35
|
+
]
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""Copy-on-write proxy for safe, efficient counterfactual mutation.
|
|
2
|
+
|
|
3
|
+
This module provides a CopyOnWrite wrapper that allows mutations to an object
|
|
4
|
+
tree while preserving the original. Copies are made lazily only when mutations
|
|
5
|
+
actually occur, and propagate up to parent objects automatically.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
```python
|
|
9
|
+
original = MyConfig()
|
|
10
|
+
|
|
11
|
+
with CopyOnWrite(original) as cow:
|
|
12
|
+
cow.nested.field = 42 # Only copies 'nested' and root
|
|
13
|
+
cow.items.append(1) # Copies 'items' list
|
|
14
|
+
result = cow.unwrap # Get the modified copy
|
|
15
|
+
|
|
16
|
+
# original is unchanged
|
|
17
|
+
# result has the modifications
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import TYPE_CHECKING, Any, Generic, Self, TypeVar, cast
|
|
25
|
+
|
|
26
|
+
import copy
|
|
27
|
+
|
|
28
|
+
from typing_extensions import override
|
|
29
|
+
|
|
30
|
+
import wrapt
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
__all__ = ["CopyOnWrite"]
|
|
34
|
+
|
|
35
|
+
_T = TypeVar("_T")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# We use Any for parent/children because:
|
|
39
|
+
# 1. They hold CopyOnWrite wrappers of heterogeneous types (Config, int, list, etc.)
|
|
40
|
+
# 2. We need to call methods like ._copy() on them (object doesn't have these)
|
|
41
|
+
# 3. Due to invariance, CopyOnWrite[int] is not assignable to CopyOnWrite[object]
|
|
42
|
+
if TYPE_CHECKING:
|
|
43
|
+
_ParentSet = set[tuple[Any, str]]
|
|
44
|
+
_ChildrenDict = dict[str, Any]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# Note: wrapt.ObjectProxy is generic in type stubs but not subscriptable at runtime.
|
|
48
|
+
# We use Generic[_T] to provide type parameters and declare __wrapped__: _T.
|
|
49
|
+
class CopyOnWrite(wrapt.ObjectProxy, Generic[_T]): # pyright: ignore[reportMissingTypeArgument]
|
|
50
|
+
"""A proxy that copies objects lazily on first mutation.
|
|
51
|
+
|
|
52
|
+
Wraps an object and intercepts all attribute/item mutations. When a mutation
|
|
53
|
+
occurs, the object (and all its parents) are shallow-copied first, ensuring
|
|
54
|
+
the original object tree remains unchanged.
|
|
55
|
+
|
|
56
|
+
Attributes are accessed through child CopyOnWrite wrappers, enabling
|
|
57
|
+
copy-on-write semantics for deeply nested mutations like `obj.a.b.c = 1`.
|
|
58
|
+
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
# Declare __wrapped__ with proper type to help pyright
|
|
62
|
+
__wrapped__: _T
|
|
63
|
+
|
|
64
|
+
# Use _self_ prefix to avoid conflicts with wrapped object attributes
|
|
65
|
+
# (this is the wrapt convention)
|
|
66
|
+
# Note: These use Any because parent/children can wrap any type
|
|
67
|
+
_self_parents: _ParentSet
|
|
68
|
+
_self_children: _ChildrenDict
|
|
69
|
+
_self_is_copy: bool
|
|
70
|
+
_self_is_finalized: bool
|
|
71
|
+
_self_debug: bool
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
wrapped: _T,
|
|
76
|
+
parent: CopyOnWrite[Any] | None = None,
|
|
77
|
+
key: str = "",
|
|
78
|
+
debug: bool = False,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Initialize a copy-on-write proxy.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
wrapped: The object to wrap.
|
|
84
|
+
parent: Parent CopyOnWrite proxy (for internal use in nested access).
|
|
85
|
+
key: Attribute/item key used to access this from parent.
|
|
86
|
+
debug: Enable debug printing of COW operations.
|
|
87
|
+
|
|
88
|
+
"""
|
|
89
|
+
# wrapt.ObjectProxy.__init__ type is partially unknown in stubs
|
|
90
|
+
super().__init__(wrapped) # pyright: ignore[reportUnknownMemberType]
|
|
91
|
+
if parent is None:
|
|
92
|
+
self._self_parents = set()
|
|
93
|
+
else:
|
|
94
|
+
self._self_parents = {(parent, key)}
|
|
95
|
+
self._self_children = {}
|
|
96
|
+
self._self_is_copy = False
|
|
97
|
+
self._self_is_finalized = False
|
|
98
|
+
self._self_debug = debug
|
|
99
|
+
|
|
100
|
+
# -------------------------------------------------------------------------
|
|
101
|
+
# Context manager
|
|
102
|
+
# -------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
def __enter__(self) -> Self:
|
|
105
|
+
"""Enter context manager."""
|
|
106
|
+
return self
|
|
107
|
+
|
|
108
|
+
def __exit__(
|
|
109
|
+
self,
|
|
110
|
+
exc_type: type[BaseException] | None,
|
|
111
|
+
exc_value: BaseException | None,
|
|
112
|
+
exc_traceback: object,
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Exit context manager, calling finalize on wrapped objects."""
|
|
115
|
+
# Exit children first (depth-first)
|
|
116
|
+
for child in self._self_children.values():
|
|
117
|
+
child.__exit__(exc_type, exc_value, exc_traceback)
|
|
118
|
+
|
|
119
|
+
# Call finalize if present and not already finalized
|
|
120
|
+
finalize_fn = getattr(self.__wrapped__, "finalize", None)
|
|
121
|
+
if not self._self_is_finalized and callable(finalize_fn):
|
|
122
|
+
finalized = finalize_fn()
|
|
123
|
+
# Update parent references to point to finalized value
|
|
124
|
+
for parent, key in self._self_parents:
|
|
125
|
+
setattr(parent.__wrapped__, key, finalized)
|
|
126
|
+
|
|
127
|
+
if self._self_debug:
|
|
128
|
+
print(
|
|
129
|
+
f" exit: {type(self.__wrapped__).__name__} "
|
|
130
|
+
f"is_copy={self._self_is_copy} "
|
|
131
|
+
f"is_finalized={self._self_is_finalized}",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# -------------------------------------------------------------------------
|
|
135
|
+
# Copy-on-write core
|
|
136
|
+
# -------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def _copy(self) -> Self:
|
|
139
|
+
"""Lazily copy this object and propagate copies to parents."""
|
|
140
|
+
if self._self_is_copy:
|
|
141
|
+
if self._self_debug:
|
|
142
|
+
print(
|
|
143
|
+
f" copy: {type(self.__wrapped__).__name__} [SKIP - already copied]",
|
|
144
|
+
)
|
|
145
|
+
return self
|
|
146
|
+
|
|
147
|
+
if self._self_debug:
|
|
148
|
+
print(f" copy: {type(self.__wrapped__).__name__}")
|
|
149
|
+
|
|
150
|
+
# Copy parents first (propagate up)
|
|
151
|
+
for parent, _ in self._self_parents:
|
|
152
|
+
parent._copy() # noqa: SLF001
|
|
153
|
+
|
|
154
|
+
# Now copy this object
|
|
155
|
+
self.__wrapped__ = copy.copy(self.__wrapped__)
|
|
156
|
+
self._self_is_copy = True
|
|
157
|
+
|
|
158
|
+
# Update parent references to point to our new copy
|
|
159
|
+
for parent, key in self._self_parents:
|
|
160
|
+
setattr(parent.__wrapped__, key, self.__wrapped__)
|
|
161
|
+
|
|
162
|
+
return self
|
|
163
|
+
|
|
164
|
+
# -------------------------------------------------------------------------
|
|
165
|
+
# Attribute access - return wrapped children for nested COW
|
|
166
|
+
# -------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
def __getattr__(self, key: str) -> CopyOnWrite[Any]:
|
|
169
|
+
if key.startswith("_self_"):
|
|
170
|
+
# This shouldn't happen with wrapt, but just in case
|
|
171
|
+
raise AttributeError(key)
|
|
172
|
+
|
|
173
|
+
if self._self_debug:
|
|
174
|
+
print(f" get : {type(self.__wrapped__).__name__}.{key}")
|
|
175
|
+
|
|
176
|
+
# Return cached child wrapper or create new one
|
|
177
|
+
child: CopyOnWrite[Any] | None = self._self_children.get(key)
|
|
178
|
+
if child is None:
|
|
179
|
+
actual = getattr(self.__wrapped__, key)
|
|
180
|
+
child = CopyOnWrite(actual, parent=self, key=key, debug=self._self_debug)
|
|
181
|
+
self._self_children[key] = child
|
|
182
|
+
return child
|
|
183
|
+
|
|
184
|
+
@override
|
|
185
|
+
def __setattr__(self, key: str, value: object) -> None:
|
|
186
|
+
# Let wrapt handle its internal attributes
|
|
187
|
+
if key.startswith("_self_") or key == "__wrapped__":
|
|
188
|
+
super().__setattr__(key, value)
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
if self._self_debug:
|
|
192
|
+
print(f" set : {type(self.__wrapped__).__name__}.{key} = {value!r}")
|
|
193
|
+
|
|
194
|
+
# Unwrap CopyOnWrite values
|
|
195
|
+
actual_value: object
|
|
196
|
+
if isinstance(value, CopyOnWrite):
|
|
197
|
+
cow_value = cast("CopyOnWrite[Any]", value)
|
|
198
|
+
actual_value = cow_value.__wrapped__
|
|
199
|
+
# Track parent relationship
|
|
200
|
+
self._self_children[key] = cow_value
|
|
201
|
+
cow_value._self_parents.add((self, key)) # noqa: SLF001
|
|
202
|
+
else:
|
|
203
|
+
actual_value = value
|
|
204
|
+
# Remove from children cache since it's no longer wrapped
|
|
205
|
+
self._self_children.pop(key, None)
|
|
206
|
+
|
|
207
|
+
# Copy-on-write: copy before mutating
|
|
208
|
+
self._copy()
|
|
209
|
+
setattr(self.__wrapped__, key, actual_value)
|
|
210
|
+
|
|
211
|
+
@override
|
|
212
|
+
def __delattr__(self, key: str) -> None:
|
|
213
|
+
if key.startswith("_self_"):
|
|
214
|
+
super().__delattr__(key)
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
if self._self_debug:
|
|
218
|
+
print(f" del : {type(self.__wrapped__).__name__}.{key}")
|
|
219
|
+
|
|
220
|
+
# Remove from children and update parent tracking
|
|
221
|
+
child: CopyOnWrite[Any] | None = self._self_children.pop(key, None)
|
|
222
|
+
if child is not None:
|
|
223
|
+
child._self_parents.discard((self, key)) # noqa: SLF001
|
|
224
|
+
|
|
225
|
+
# Copy-on-write: copy before mutating
|
|
226
|
+
self._copy()
|
|
227
|
+
delattr(self.__wrapped__, key)
|
|
228
|
+
|
|
229
|
+
# -------------------------------------------------------------------------
|
|
230
|
+
# Item access (for sequences, mappings)
|
|
231
|
+
# -------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
def __getitem__(self, key: object) -> CopyOnWrite[Any]:
|
|
234
|
+
if self._self_debug:
|
|
235
|
+
print(f" get : {type(self.__wrapped__).__name__}[{key!r}]")
|
|
236
|
+
|
|
237
|
+
# Use string representation of key for children cache
|
|
238
|
+
cache_key = f"__item_{key!r}"
|
|
239
|
+
child: CopyOnWrite[Any] | None = self._self_children.get(cache_key)
|
|
240
|
+
if child is None:
|
|
241
|
+
actual = cast("object", self.__wrapped__[key]) # pyright: ignore[reportIndexIssue]
|
|
242
|
+
child = CopyOnWrite(
|
|
243
|
+
actual,
|
|
244
|
+
parent=self,
|
|
245
|
+
key=cache_key,
|
|
246
|
+
debug=self._self_debug,
|
|
247
|
+
)
|
|
248
|
+
self._self_children[cache_key] = child
|
|
249
|
+
return child
|
|
250
|
+
|
|
251
|
+
def __setitem__(self, key: object, value: object) -> None:
|
|
252
|
+
if self._self_debug:
|
|
253
|
+
print(f" set : {type(self.__wrapped__).__name__}[{key!r}] = {value!r}")
|
|
254
|
+
|
|
255
|
+
# Unwrap CopyOnWrite values
|
|
256
|
+
actual_value: object
|
|
257
|
+
if isinstance(value, CopyOnWrite):
|
|
258
|
+
actual_value = cast("CopyOnWrite[Any]", value).__wrapped__
|
|
259
|
+
else:
|
|
260
|
+
actual_value = value
|
|
261
|
+
|
|
262
|
+
# Invalidate cached child for this key
|
|
263
|
+
cache_key = f"__item_{key!r}"
|
|
264
|
+
self._self_children.pop(cache_key, None)
|
|
265
|
+
|
|
266
|
+
# Copy-on-write: copy before mutating
|
|
267
|
+
self._copy()
|
|
268
|
+
self.__wrapped__[key] = actual_value # pyright: ignore[reportIndexIssue]
|
|
269
|
+
|
|
270
|
+
def __delitem__(self, key: object) -> None:
|
|
271
|
+
if self._self_debug:
|
|
272
|
+
print(f" del : {type(self.__wrapped__).__name__}[{key!r}]")
|
|
273
|
+
|
|
274
|
+
# Remove from children cache
|
|
275
|
+
cache_key = f"__item_{key!r}"
|
|
276
|
+
child: CopyOnWrite[Any] | None = self._self_children.pop(cache_key, None)
|
|
277
|
+
if child is not None:
|
|
278
|
+
child._self_parents.discard((self, cache_key)) # noqa: SLF001
|
|
279
|
+
|
|
280
|
+
# Copy-on-write: copy before mutating
|
|
281
|
+
self._copy()
|
|
282
|
+
del self.__wrapped__[key] # pyright: ignore[reportIndexIssue]
|
|
283
|
+
|
|
284
|
+
# -------------------------------------------------------------------------
|
|
285
|
+
# Method calls - copy before calling mutating methods
|
|
286
|
+
# -------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
def __call__(self, *args: object, **kwargs: object) -> CopyOnWrite[Any]:
|
|
289
|
+
"""Invoke wrapped callable, copying parent first if needed."""
|
|
290
|
+
if self._self_debug:
|
|
291
|
+
print(f" call: {self.__wrapped__}")
|
|
292
|
+
|
|
293
|
+
# Mark parent as finalized if this is a finalize() call
|
|
294
|
+
for parent, key in self._self_parents:
|
|
295
|
+
if key == "finalize":
|
|
296
|
+
parent._self_is_finalized = True # noqa: SLF001
|
|
297
|
+
|
|
298
|
+
# Copy parents first (in case method mutates the parent object)
|
|
299
|
+
for parent, _ in self._self_parents:
|
|
300
|
+
parent._copy() # noqa: SLF001
|
|
301
|
+
|
|
302
|
+
# For bound methods, re-fetch from the copied parent to get the method
|
|
303
|
+
# bound to the copy, not the original
|
|
304
|
+
method: Any = self.__wrapped__
|
|
305
|
+
for parent, key in self._self_parents:
|
|
306
|
+
# Get the updated method from the copied parent
|
|
307
|
+
method = getattr(parent.__wrapped__, key)
|
|
308
|
+
break # Only need the first parent for bound methods
|
|
309
|
+
|
|
310
|
+
if not callable(method):
|
|
311
|
+
raise TypeError(f"{method!r} is not callable")
|
|
312
|
+
result = method(*args, **kwargs)
|
|
313
|
+
|
|
314
|
+
# Wrap the result
|
|
315
|
+
return CopyOnWrite(result, debug=self._self_debug)
|
|
316
|
+
|
|
317
|
+
# -------------------------------------------------------------------------
|
|
318
|
+
# Representation
|
|
319
|
+
# -------------------------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
@property
|
|
322
|
+
def unwrap(self) -> _T:
|
|
323
|
+
"""Return the underlying object.
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
wrapped: The underlying object, possibly a copy if mutations occurred.
|
|
327
|
+
|
|
328
|
+
"""
|
|
329
|
+
return self.__wrapped__
|
|
330
|
+
|
|
331
|
+
@override
|
|
332
|
+
def __repr__(self) -> str:
|
|
333
|
+
return repr(self.__wrapped__)
|
|
334
|
+
|
|
335
|
+
@override
|
|
336
|
+
def __dir__(self) -> list[str]:
|
|
337
|
+
return dir(self.__wrapped__)
|
|
338
|
+
|
|
339
|
+
@override
|
|
340
|
+
def __hash__(self) -> int:
|
|
341
|
+
# Use identity-based hash so CopyOnWrite instances can be stored in sets
|
|
342
|
+
# regardless of whether the wrapped object is hashable
|
|
343
|
+
return id(self)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Custom types for config module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import (
|
|
6
|
+
ClassVar,
|
|
7
|
+
Final,
|
|
8
|
+
Protocol,
|
|
9
|
+
Self,
|
|
10
|
+
TypeVar,
|
|
11
|
+
runtime_checkable,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
import dataclasses
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Configurable",
|
|
19
|
+
"DataclassLike",
|
|
20
|
+
"HasConfig",
|
|
21
|
+
"HasRelaxedConfig",
|
|
22
|
+
"RelaxedConfigurable",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
_T_co = TypeVar("_T_co", covariant=True)
|
|
26
|
+
_T = TypeVar("_T")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@runtime_checkable
|
|
30
|
+
@dataclasses.dataclass(init=False, repr=False, eq=False)
|
|
31
|
+
class DataclassLike(Protocol):
|
|
32
|
+
"""Protocol for objects that behave like dataclasses."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@runtime_checkable
|
|
36
|
+
class Configurable(Protocol[_T_co]):
|
|
37
|
+
"""Protocol for config objects with setup/finalize/update methods."""
|
|
38
|
+
|
|
39
|
+
_finalized: Final[bool]
|
|
40
|
+
|
|
41
|
+
def setup(self) -> _T_co: ...
|
|
42
|
+
def finalize(self) -> Self: ...
|
|
43
|
+
def update(
|
|
44
|
+
self,
|
|
45
|
+
source: DataclassLike | Configurable[object] | None = None,
|
|
46
|
+
*,
|
|
47
|
+
skip_missing: bool = False,
|
|
48
|
+
**kwargs: object,
|
|
49
|
+
) -> Self: ...
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@runtime_checkable
|
|
53
|
+
class HasConfig(Protocol[_T]):
|
|
54
|
+
"""Protocol for classes decorated with @auto_config."""
|
|
55
|
+
|
|
56
|
+
Config: ClassVar[type[Configurable[_T]]] # pyright: ignore[reportGeneralTypeIssues]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@runtime_checkable
|
|
60
|
+
class RelaxedConfigurable(Configurable[_T], Protocol):
|
|
61
|
+
"""Protocol for auto-generated Config classes.
|
|
62
|
+
|
|
63
|
+
Extends Configurable with __init__ and __getattr__ to support
|
|
64
|
+
dynamic field access without requiring suppressions in user code.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
parent_class: ClassVar[type[_T] | None] # pyright: ignore[reportGeneralTypeIssues]
|
|
68
|
+
|
|
69
|
+
def __init__(self, *args: object, **kwargs: object) -> None: ...
|
|
70
|
+
def __getattr__(self, name: str) -> object: ...
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@runtime_checkable
|
|
74
|
+
class HasRelaxedConfig(Protocol[_T]):
|
|
75
|
+
"""Protocol for classes decorated with @auto_config."""
|
|
76
|
+
|
|
77
|
+
Config: ClassVar[type[RelaxedConfigurable[_T]]] # pyright: ignore[reportGeneralTypeIssues]
|
configgle/decorator.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Decorator to auto-generate a Config dataclass from __init__ parameters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, TypeVar, get_type_hints, overload
|
|
6
|
+
|
|
7
|
+
import inspect
|
|
8
|
+
|
|
9
|
+
from configgle.custom_types import HasRelaxedConfig
|
|
10
|
+
from configgle.fig import Fig, FigMeta
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
__all__ = ["autofig"]
|
|
18
|
+
|
|
19
|
+
_T = TypeVar("_T")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@overload
|
|
23
|
+
def autofig(
|
|
24
|
+
cls: type[_T],
|
|
25
|
+
/,
|
|
26
|
+
) -> type[HasRelaxedConfig[_T]]: ...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@overload
|
|
30
|
+
def autofig(
|
|
31
|
+
cls: None = None,
|
|
32
|
+
/,
|
|
33
|
+
*,
|
|
34
|
+
require_defaults: bool = True,
|
|
35
|
+
kw_only: bool = True,
|
|
36
|
+
) -> Callable[[type[_T]], type[HasRelaxedConfig[_T]]]: ...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def autofig(
|
|
40
|
+
cls: type[_T] | None = None,
|
|
41
|
+
/,
|
|
42
|
+
*,
|
|
43
|
+
require_defaults: bool = True,
|
|
44
|
+
kw_only: bool = True,
|
|
45
|
+
) -> type[HasRelaxedConfig[_T]] | Callable[[type[_T]], type[HasRelaxedConfig[_T]]]:
|
|
46
|
+
"""Decorator that inspects a class's __init__ method and creates a nested
|
|
47
|
+
Config dataclass (subclassing Fig) with the same parameters.
|
|
48
|
+
|
|
49
|
+
The Config class gets:
|
|
50
|
+
- parent_class property (via SetupMeta)
|
|
51
|
+
- setup() method to instantiate parent class via kwargs unpacking
|
|
52
|
+
- finalize() for derived defaults
|
|
53
|
+
- update() for config merging
|
|
54
|
+
|
|
55
|
+
The original __init__ signature is preserved. Config.setup() unpacks
|
|
56
|
+
the config fields as kwargs.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
cls: The class to decorate (when used without arguments).
|
|
60
|
+
require_defaults: If True, all Config fields must have defaults.
|
|
61
|
+
kw_only: If True, all Config fields are keyword-only.
|
|
62
|
+
|
|
63
|
+
Example:
|
|
64
|
+
@autofig
|
|
65
|
+
class Foo:
|
|
66
|
+
def __init__(self, x: int, y: str = "default"):
|
|
67
|
+
self.x = x
|
|
68
|
+
self.y = y
|
|
69
|
+
|
|
70
|
+
# Now you can use:
|
|
71
|
+
config = Foo.Config(x=10, y="hello")
|
|
72
|
+
foo = config.setup() # Creates Foo(x=10, y="hello")
|
|
73
|
+
|
|
74
|
+
# Or with arguments:
|
|
75
|
+
@autofig(require_defaults=True, kw_only=True)
|
|
76
|
+
class Bar:
|
|
77
|
+
def __init__(self, x: int = 0):
|
|
78
|
+
self.x = x
|
|
79
|
+
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def decorator(cls_: type[_T]) -> type[HasRelaxedConfig[_T]]:
|
|
83
|
+
sig = inspect.signature(cls_.__init__)
|
|
84
|
+
try:
|
|
85
|
+
type_hints = get_type_hints(cls_.__init__)
|
|
86
|
+
except Exception: # noqa: BLE001
|
|
87
|
+
# get_type_hints can fail for various reasons (e.g., forward refs, missing imports)
|
|
88
|
+
type_hints = {}
|
|
89
|
+
|
|
90
|
+
annotations: dict[str, type] = {}
|
|
91
|
+
defaults_: dict[str, object] = {}
|
|
92
|
+
|
|
93
|
+
for i, (param_name, param) in enumerate(sig.parameters.items()):
|
|
94
|
+
if i == 0:
|
|
95
|
+
continue
|
|
96
|
+
param_type = type_hints.get(param_name, object)
|
|
97
|
+
annotations[param_name] = param_type
|
|
98
|
+
if param.default is not inspect.Parameter.empty:
|
|
99
|
+
defaults_[param_name] = param.default
|
|
100
|
+
|
|
101
|
+
Config = FigMeta(
|
|
102
|
+
"Config",
|
|
103
|
+
(Fig,),
|
|
104
|
+
{
|
|
105
|
+
"__annotations__": annotations,
|
|
106
|
+
"setup_with_kwargs": True,
|
|
107
|
+
**defaults_,
|
|
108
|
+
},
|
|
109
|
+
require_defaults=require_defaults,
|
|
110
|
+
kw_only=kw_only,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
Config.__set_name__(cls_, "Config")
|
|
114
|
+
cls_.Config = Config # pyright: ignore[reportAttributeAccessIssue]
|
|
115
|
+
|
|
116
|
+
return cls_ # pyright: ignore[reportReturnType]
|
|
117
|
+
|
|
118
|
+
if cls is None:
|
|
119
|
+
# Called with arguments: @autofig(require_defaults=True)
|
|
120
|
+
return decorator
|
|
121
|
+
# Called without arguments: @autofig
|
|
122
|
+
return decorator(cls)
|