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/fig.py
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import (
|
|
4
|
+
Iterator,
|
|
5
|
+
Mapping,
|
|
6
|
+
Sequence,
|
|
7
|
+
Set as AbstractSet,
|
|
8
|
+
)
|
|
9
|
+
from types import CellType, MethodType
|
|
10
|
+
from typing import (
|
|
11
|
+
ClassVar,
|
|
12
|
+
Self,
|
|
13
|
+
TypeVar,
|
|
14
|
+
cast,
|
|
15
|
+
dataclass_transform,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
import copy
|
|
19
|
+
import dataclasses
|
|
20
|
+
|
|
21
|
+
from typing_extensions import override
|
|
22
|
+
|
|
23
|
+
from configgle.custom_types import Configurable, DataclassLike
|
|
24
|
+
from configgle.inline import InlineConfig, PartialConfig
|
|
25
|
+
from configgle.pprinting import pformat
|
|
26
|
+
from configgle.traverse import recursively_iterate_over_object_descendants
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"Dataclass",
|
|
31
|
+
"Fig",
|
|
32
|
+
"InlineConfig",
|
|
33
|
+
"PartialConfig",
|
|
34
|
+
"Setupable",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SetupableMeta(type):
|
|
39
|
+
"""Metaclass that tracks the nested parent class for the Config pattern.
|
|
40
|
+
|
|
41
|
+
Uses MethodType to bind the parent class reference, making parent_class
|
|
42
|
+
immutable while remaining compatible with cloudpickle. This is a standard
|
|
43
|
+
Python pattern for creating bound methods dynamically.
|
|
44
|
+
|
|
45
|
+
See: https://docs.python.org/3/library/types.html#types.MethodType
|
|
46
|
+
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def parent_class(cls) -> type | None:
|
|
51
|
+
return cls._parent_class()
|
|
52
|
+
|
|
53
|
+
def _parent_class(cls) -> type | None: ...
|
|
54
|
+
|
|
55
|
+
def __set_name__(cls, owner: type, name: str) -> None:
|
|
56
|
+
def _parent_class(cls: SetupableMeta) -> type:
|
|
57
|
+
del cls
|
|
58
|
+
return owner
|
|
59
|
+
|
|
60
|
+
cls._parent_class = MethodType(_parent_class, cls)
|
|
61
|
+
if owner_name := getattr(owner, "__name__", ""):
|
|
62
|
+
cls.__name__ = f"{owner_name}.{name}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Setupable(metaclass=SetupableMeta):
|
|
66
|
+
"""Base class providing setup/finalize/update capabilities for configs.
|
|
67
|
+
|
|
68
|
+
When nested inside a parent class, enables the pattern:
|
|
69
|
+
instance = ParentClass.Config(...).setup()
|
|
70
|
+
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
__slots__: ClassVar[tuple[str, ...]] = ("_finalized",)
|
|
74
|
+
setup_with_kwargs: ClassVar[bool] = False
|
|
75
|
+
parent_class: ClassVar[type | None]
|
|
76
|
+
|
|
77
|
+
def __init__(self):
|
|
78
|
+
self._finalized = False
|
|
79
|
+
|
|
80
|
+
def setup(self) -> object:
|
|
81
|
+
"""Finalize config and instantiate the parent class.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
instance: Instance of the parent class.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
ValueError: If not nested in a parent class.
|
|
88
|
+
|
|
89
|
+
"""
|
|
90
|
+
config = self.finalize()
|
|
91
|
+
cls = type(config).parent_class
|
|
92
|
+
if cls is None:
|
|
93
|
+
raise ValueError("Setupable class must be nested in a parent class")
|
|
94
|
+
if getattr(type(config), "setup_with_kwargs", False):
|
|
95
|
+
kwargs = {
|
|
96
|
+
f.name: getattr(config, f.name)
|
|
97
|
+
for f in dataclasses.fields(
|
|
98
|
+
cast("DataclassLike", cast("object", config)),
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
return cls(**kwargs)
|
|
102
|
+
return cls(config)
|
|
103
|
+
|
|
104
|
+
def finalize(self) -> Self:
|
|
105
|
+
"""Create a finalized copy with derived defaults applied.
|
|
106
|
+
|
|
107
|
+
Override this method to compute derived field values before instantiation.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
finalized: A shallow copy with _finalized=True.
|
|
111
|
+
|
|
112
|
+
"""
|
|
113
|
+
r = copy.copy(self)
|
|
114
|
+
|
|
115
|
+
for name in _get_object_attribute_names(r):
|
|
116
|
+
try:
|
|
117
|
+
value = getattr(r, name)
|
|
118
|
+
except AttributeError:
|
|
119
|
+
continue
|
|
120
|
+
finalized_value = _finalize_value(value)
|
|
121
|
+
if finalized_value is not value:
|
|
122
|
+
# Use object.__setattr__ to bypass frozen dataclass restrictions
|
|
123
|
+
object.__setattr__(r, name, finalized_value)
|
|
124
|
+
|
|
125
|
+
# Use object.__setattr__ to bypass frozen dataclass restrictions
|
|
126
|
+
object.__setattr__(r, "_finalized", True)
|
|
127
|
+
return r
|
|
128
|
+
|
|
129
|
+
def update(
|
|
130
|
+
self,
|
|
131
|
+
source: DataclassLike | Configurable[object] | None = None,
|
|
132
|
+
*,
|
|
133
|
+
skip_missing: bool = False,
|
|
134
|
+
**kwargs: object,
|
|
135
|
+
) -> Self:
|
|
136
|
+
"""Update config attributes from source and/or kwargs.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
source: Optional source object to copy attributes from.
|
|
140
|
+
skip_missing: If True, skip kwargs keys that don't exist as attributes.
|
|
141
|
+
**kwargs: Additional attribute overrides (use **mapping to pass a dict).
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
self: Updated instance for method chaining.
|
|
145
|
+
|
|
146
|
+
"""
|
|
147
|
+
# Build valid_keys set if needed for skip_missing
|
|
148
|
+
valid_keys: set[str] | None = None
|
|
149
|
+
if skip_missing:
|
|
150
|
+
valid_keys = set(_get_object_attribute_names(self))
|
|
151
|
+
|
|
152
|
+
# Apply source attributes (kwargs take precedence)
|
|
153
|
+
if source is not None:
|
|
154
|
+
for name in _get_object_attribute_names(source):
|
|
155
|
+
# Skip if already in kwargs (kwargs override source)
|
|
156
|
+
if name in kwargs:
|
|
157
|
+
continue
|
|
158
|
+
# Skip if not a valid key
|
|
159
|
+
if valid_keys is not None and name not in valid_keys:
|
|
160
|
+
continue
|
|
161
|
+
try:
|
|
162
|
+
setattr(self, name, getattr(source, name))
|
|
163
|
+
except AttributeError:
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
# Apply kwargs
|
|
167
|
+
for k, v in kwargs.items():
|
|
168
|
+
if valid_keys is not None and k not in valid_keys:
|
|
169
|
+
continue
|
|
170
|
+
setattr(self, k, v)
|
|
171
|
+
|
|
172
|
+
return self
|
|
173
|
+
|
|
174
|
+
def _repr_pretty_(self, p: object, cycle: bool) -> None:
|
|
175
|
+
"""IPython pretty printer hook for rich display in notebooks.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
p: IPython RepresentationPrinter instance.
|
|
179
|
+
cycle: True if a reference cycle is detected.
|
|
180
|
+
|
|
181
|
+
"""
|
|
182
|
+
if cycle:
|
|
183
|
+
# p is IPython's RepresentationPrinter, typed as object for optional dep
|
|
184
|
+
p.text( # pyright: ignore[reportAttributeAccessIssue,reportUnknownMemberType]
|
|
185
|
+
f"{type(self).__name__}(...)",
|
|
186
|
+
)
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
p.text( # pyright: ignore[reportAttributeAccessIssue,reportUnknownMemberType]
|
|
190
|
+
pformat(self),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class _Default:
|
|
195
|
+
__slots__: ClassVar[tuple[str, ...]] = ("value",)
|
|
196
|
+
|
|
197
|
+
def __init__(self, value: object):
|
|
198
|
+
self.value = value
|
|
199
|
+
|
|
200
|
+
def __bool__(self) -> bool:
|
|
201
|
+
return bool(self.value)
|
|
202
|
+
|
|
203
|
+
@override
|
|
204
|
+
def __repr__(self) -> str:
|
|
205
|
+
return f"{self.value!r}"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class _DataclassParams:
|
|
209
|
+
__mro__: ClassVar[list[type]]
|
|
210
|
+
__name__: ClassVar[str]
|
|
211
|
+
__slots__: ClassVar[tuple[str, ...]] = (
|
|
212
|
+
"eq",
|
|
213
|
+
"frozen",
|
|
214
|
+
"init",
|
|
215
|
+
"kw_only",
|
|
216
|
+
"match_args",
|
|
217
|
+
"order",
|
|
218
|
+
"repr",
|
|
219
|
+
"slots",
|
|
220
|
+
"unsafe_hash",
|
|
221
|
+
"weakref_slot",
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
def __init__(
|
|
225
|
+
self,
|
|
226
|
+
init: bool = True,
|
|
227
|
+
repr: bool = True,
|
|
228
|
+
eq: bool = True,
|
|
229
|
+
order: bool = False,
|
|
230
|
+
unsafe_hash: bool = False,
|
|
231
|
+
frozen: bool = False,
|
|
232
|
+
match_args: bool = True,
|
|
233
|
+
# The following differs from dataclasses.dataclass.
|
|
234
|
+
kw_only: bool = True,
|
|
235
|
+
slots: bool = True,
|
|
236
|
+
weakref_slot: bool = True,
|
|
237
|
+
):
|
|
238
|
+
self.init = init
|
|
239
|
+
self.repr = repr
|
|
240
|
+
self.eq = eq
|
|
241
|
+
self.order = order
|
|
242
|
+
self.unsafe_hash = unsafe_hash
|
|
243
|
+
self.frozen = frozen
|
|
244
|
+
self.match_args = match_args
|
|
245
|
+
self.kw_only = kw_only
|
|
246
|
+
self.slots = slots
|
|
247
|
+
self.weakref_slot = weakref_slot
|
|
248
|
+
|
|
249
|
+
@override
|
|
250
|
+
def __repr__(self) -> str:
|
|
251
|
+
return (
|
|
252
|
+
f"{type(self).__name__}("
|
|
253
|
+
+ ", ".join(f"{k}={self[k]!r}" for k in self.keys())
|
|
254
|
+
+ ")"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
def __getitem__(self, key: str) -> bool:
|
|
258
|
+
return getattr(self, key)
|
|
259
|
+
|
|
260
|
+
def __iter__(self) -> Iterator[str]:
|
|
261
|
+
seen = set[str]()
|
|
262
|
+
for c in type(self).__mro__:
|
|
263
|
+
slots = getattr(c, "__slots__", ())
|
|
264
|
+
if isinstance(slots, str):
|
|
265
|
+
slots = (slots,)
|
|
266
|
+
for s in slots:
|
|
267
|
+
if s in seen:
|
|
268
|
+
continue
|
|
269
|
+
seen.update(s)
|
|
270
|
+
yield s
|
|
271
|
+
|
|
272
|
+
keys = __iter__
|
|
273
|
+
|
|
274
|
+
@classmethod
|
|
275
|
+
def create(
|
|
276
|
+
cls,
|
|
277
|
+
existing: _DataclassParams,
|
|
278
|
+
**kwargs: bool | _Default,
|
|
279
|
+
) -> _DataclassParams:
|
|
280
|
+
new = _DataclassParams()
|
|
281
|
+
missing = object()
|
|
282
|
+
for k in new:
|
|
283
|
+
# Check kwargs first
|
|
284
|
+
v = kwargs.get(k, missing)
|
|
285
|
+
if v is missing or isinstance(v, _Default):
|
|
286
|
+
# Fall back to existing
|
|
287
|
+
v = getattr(existing, k, missing)
|
|
288
|
+
if v is missing:
|
|
289
|
+
continue
|
|
290
|
+
setattr(new, k, bool(v))
|
|
291
|
+
return new
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
_True = _Default(True)
|
|
295
|
+
_False = _Default(False)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
class _DataclassMeta(type):
|
|
299
|
+
__classcell__: CellType | None = None
|
|
300
|
+
__dataclass_params__: _DataclassParams = _DataclassParams()
|
|
301
|
+
|
|
302
|
+
def __new__(
|
|
303
|
+
mcls: type[_DataclassMeta],
|
|
304
|
+
name: str,
|
|
305
|
+
bases: tuple[type, ...],
|
|
306
|
+
attrs: dict[str, object],
|
|
307
|
+
*,
|
|
308
|
+
init: bool | _Default = _True,
|
|
309
|
+
repr: bool | _Default = _True,
|
|
310
|
+
eq: bool | _Default = _True,
|
|
311
|
+
order: bool | _Default = _False,
|
|
312
|
+
unsafe_hash: bool | _Default = _False,
|
|
313
|
+
frozen: bool | _Default = _False,
|
|
314
|
+
match_args: bool | _Default = _True,
|
|
315
|
+
# The following differs from dataclasses.dataclass.
|
|
316
|
+
kw_only: bool | _Default = _True,
|
|
317
|
+
slots: bool | _Default = _True,
|
|
318
|
+
weakref_slot: bool | _Default | None = None,
|
|
319
|
+
require_defaults: bool = True,
|
|
320
|
+
) -> _DataclassMeta:
|
|
321
|
+
cls = super().__new__(mcls, name, bases, attrs)
|
|
322
|
+
if classcell := attrs.get("__classcell__"):
|
|
323
|
+
cls.__classcell__ = cast("CellType", classcell)
|
|
324
|
+
if "__slots__" in cls.__dict__:
|
|
325
|
+
return cls
|
|
326
|
+
kwargs = _DataclassParams.create(
|
|
327
|
+
cls.__dataclass_params__,
|
|
328
|
+
init=init,
|
|
329
|
+
repr=repr,
|
|
330
|
+
eq=eq,
|
|
331
|
+
order=order,
|
|
332
|
+
unsafe_hash=unsafe_hash,
|
|
333
|
+
frozen=frozen,
|
|
334
|
+
match_args=match_args,
|
|
335
|
+
kw_only=kw_only,
|
|
336
|
+
slots=slots,
|
|
337
|
+
weakref_slot=slots if weakref_slot is None else weakref_slot,
|
|
338
|
+
)
|
|
339
|
+
cls = dataclasses.dataclass(cls, **kwargs)
|
|
340
|
+
|
|
341
|
+
if require_defaults:
|
|
342
|
+
current_annotations = cast(
|
|
343
|
+
"dict[str, object]",
|
|
344
|
+
attrs.get("__annotations__", {}),
|
|
345
|
+
)
|
|
346
|
+
for field in dataclasses.fields(cls): # pyright: ignore[reportArgumentType]
|
|
347
|
+
if field.name not in current_annotations:
|
|
348
|
+
continue
|
|
349
|
+
if (
|
|
350
|
+
field.default is dataclasses.MISSING
|
|
351
|
+
and field.default_factory is dataclasses.MISSING
|
|
352
|
+
):
|
|
353
|
+
raise TypeError(
|
|
354
|
+
f"{name}.{field.name} must have a default value. "
|
|
355
|
+
f"Use require_defaults=False to disable this check.",
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
cls.__dataclass_params__ = kwargs
|
|
359
|
+
cls = cast("_DataclassMeta", cls)
|
|
360
|
+
return cls
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@dataclass_transform(kw_only_default=True)
|
|
364
|
+
class DataclassMeta(_DataclassMeta):
|
|
365
|
+
"""Public metaclass for creating dataclass-based config classes.
|
|
366
|
+
|
|
367
|
+
This metaclass automatically applies @dataclass decorator with sensible
|
|
368
|
+
defaults (kw_only=True, slots=True, etc.) to any class using it.
|
|
369
|
+
|
|
370
|
+
"""
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
class Dataclass(metaclass=DataclassMeta):
|
|
374
|
+
"""Base class that auto-applies @dataclass with sensible defaults."""
|
|
375
|
+
|
|
376
|
+
__slots__: ClassVar[tuple[str, ...]] = ()
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@dataclass_transform(kw_only_default=True)
|
|
380
|
+
class FigMeta(_DataclassMeta, SetupableMeta):
|
|
381
|
+
"""Combined metaclass for Fig.
|
|
382
|
+
|
|
383
|
+
This metaclass combines _DataclassMeta (automatic dataclass conversion) and
|
|
384
|
+
SetupableMeta (parent class tracking) to enable the nested Config pattern where
|
|
385
|
+
Config classes can call .setup() to instantiate their parent class.
|
|
386
|
+
|
|
387
|
+
"""
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
class Fig(Setupable, metaclass=FigMeta):
|
|
391
|
+
"""Dataclass with setup/finalize/update for the nested Config pattern."""
|
|
392
|
+
|
|
393
|
+
__slots__: ClassVar[tuple[str, ...]] = ()
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
_ValueT = TypeVar("_ValueT")
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _get_object_attribute_names(obj: object) -> Iterator[str]:
|
|
400
|
+
"""Get attribute names from an object via __slots__ or __dict__.
|
|
401
|
+
|
|
402
|
+
Yields:
|
|
403
|
+
name: Attribute name (excluding special attributes like __weakref__,
|
|
404
|
+
__dict__, and _finalized).
|
|
405
|
+
|
|
406
|
+
"""
|
|
407
|
+
for path, _ in recursively_iterate_over_object_descendants(
|
|
408
|
+
obj,
|
|
409
|
+
recurse=lambda path, _: len(path) <= 1,
|
|
410
|
+
):
|
|
411
|
+
# Filter to string attribute names only (not integer indices from sequences)
|
|
412
|
+
# Skip the root (empty path)
|
|
413
|
+
if (
|
|
414
|
+
len(path) == 1
|
|
415
|
+
and isinstance(path[0], str)
|
|
416
|
+
and path[0] not in ("__weakref__", "__dict__", "_finalized")
|
|
417
|
+
):
|
|
418
|
+
yield path[0]
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _needs_finalization(x: object) -> bool:
|
|
422
|
+
"""Check if value needs finalization.
|
|
423
|
+
|
|
424
|
+
Returns:
|
|
425
|
+
needs_finalization: True if x has a finalize() method and either has no
|
|
426
|
+
_finalized attribute or has _finalized=False.
|
|
427
|
+
|
|
428
|
+
"""
|
|
429
|
+
return hasattr(x, "finalize") and not getattr(x, "_finalized", False)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _finalize_value(value: _ValueT) -> _ValueT:
|
|
433
|
+
"""Recursively finalize values containing Fig instances.
|
|
434
|
+
|
|
435
|
+
Uses traversal to discover all Fig instances in nested structures
|
|
436
|
+
(sequences, mappings, sets, objects with __slots__/__dict__) and finalizes them.
|
|
437
|
+
Preserves original container types.
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
value: Value to finalize recursively.
|
|
441
|
+
|
|
442
|
+
Returns:
|
|
443
|
+
finalized_value: Finalized copy of the value with all nested configs finalized.
|
|
444
|
+
|
|
445
|
+
"""
|
|
446
|
+
if _needs_finalization(value):
|
|
447
|
+
# Dynamic dispatch to finalize() checked by _needs_finalization
|
|
448
|
+
return value.finalize() # pyright: ignore[reportAttributeAccessIssue,reportUnknownMemberType,reportUnknownVariableType]
|
|
449
|
+
|
|
450
|
+
# Skip classes and types - they don't need finalization
|
|
451
|
+
if isinstance(value, type):
|
|
452
|
+
return value
|
|
453
|
+
|
|
454
|
+
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
|
|
455
|
+
finalized_items: list[object] = [_finalize_value(v) for v in value]
|
|
456
|
+
if isinstance(value, tuple):
|
|
457
|
+
if type(value) is tuple:
|
|
458
|
+
return tuple(finalized_items) # pyright: ignore[reportReturnType]
|
|
459
|
+
# Namedtuple needs unpacking
|
|
460
|
+
return type(value)(*finalized_items) # pyright: ignore[reportArgumentType]
|
|
461
|
+
finalized = finalized_items
|
|
462
|
+
elif isinstance(value, Mapping):
|
|
463
|
+
# Mapping key type is unknown at runtime
|
|
464
|
+
finalized = {k: _finalize_value(v) for k, v in value.items()} # pyright: ignore[reportUnknownVariableType]
|
|
465
|
+
elif isinstance(value, AbstractSet):
|
|
466
|
+
finalized = {_finalize_value(v) for v in value}
|
|
467
|
+
else:
|
|
468
|
+
r = copy.copy(value)
|
|
469
|
+
|
|
470
|
+
for name in _get_object_attribute_names(r):
|
|
471
|
+
try:
|
|
472
|
+
attr_value = getattr(r, name)
|
|
473
|
+
except AttributeError:
|
|
474
|
+
continue
|
|
475
|
+
finalized_attr_value = _finalize_value(attr_value)
|
|
476
|
+
if finalized_attr_value is not attr_value:
|
|
477
|
+
object.__setattr__(r, name, finalized_attr_value)
|
|
478
|
+
|
|
479
|
+
return r
|
|
480
|
+
|
|
481
|
+
# Reconstruct container with finalized items
|
|
482
|
+
return type(value)(finalized) # pyright: ignore[reportCallIssue,reportUnknownArgumentType,reportUnknownVariableType]
|
configgle/inline.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Inline configuration classes for wrapping functions and partial functions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, MutableMapping, MutableSequence
|
|
6
|
+
from typing import TYPE_CHECKING, Generic, Self, TypeVar
|
|
7
|
+
|
|
8
|
+
import copy
|
|
9
|
+
import dataclasses
|
|
10
|
+
import functools
|
|
11
|
+
import reprlib
|
|
12
|
+
|
|
13
|
+
from typing_extensions import override
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from configgle.custom_types import Configurable, DataclassLike
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
__all__ = ["InlineConfig", "PartialConfig"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_T = TypeVar("_T")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclasses.dataclass(slots=True, init=False, repr=True, weakref_slot=True)
|
|
27
|
+
class InlineConfig(Generic[_T]):
|
|
28
|
+
"""Config wrapper for arbitrary callables with deferred execution.
|
|
29
|
+
|
|
30
|
+
Stores a function and its arguments, calling them when setup() is invoked.
|
|
31
|
+
Supports nested configs in args/kwargs which are finalized/setup recursively.
|
|
32
|
+
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
func: Callable[..., _T]
|
|
36
|
+
_finalized: bool = dataclasses.field(
|
|
37
|
+
default=False,
|
|
38
|
+
init=False,
|
|
39
|
+
repr=False,
|
|
40
|
+
)
|
|
41
|
+
_args: MutableSequence[object] = dataclasses.field(
|
|
42
|
+
default_factory=list[object],
|
|
43
|
+
init=False,
|
|
44
|
+
repr=False,
|
|
45
|
+
)
|
|
46
|
+
# This must be last or else you need to manually define __deepcopy__ and __copy__.
|
|
47
|
+
_kwargs: MutableMapping[str, object] = dataclasses.field(
|
|
48
|
+
default_factory=dict[str, object],
|
|
49
|
+
init=False,
|
|
50
|
+
repr=False,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
/,
|
|
56
|
+
func: Callable[..., _T],
|
|
57
|
+
*args: object,
|
|
58
|
+
**kwargs: object,
|
|
59
|
+
) -> None:
|
|
60
|
+
self.func = func
|
|
61
|
+
self._finalized = False
|
|
62
|
+
self._args = list(args)
|
|
63
|
+
self._kwargs = kwargs # Must be last.
|
|
64
|
+
|
|
65
|
+
def setup(self) -> _T:
|
|
66
|
+
"""Finalize and invoke the wrapped function.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
result: Result of calling func(*args, **kwargs).
|
|
70
|
+
|
|
71
|
+
"""
|
|
72
|
+
r = self.finalize()
|
|
73
|
+
# Dynamic dispatch to setup() on args that may have it
|
|
74
|
+
args = [v.setup() if hasattr(v, "setup") else v for v in r._args] # noqa: SLF001 # pyright: ignore[reportAttributeAccessIssue,reportUnknownMemberType,reportUnknownVariableType]
|
|
75
|
+
kwargs = { # pyright: ignore[reportUnknownVariableType]
|
|
76
|
+
k: v.setup() if hasattr(v, "setup") else v # pyright: ignore[reportAttributeAccessIssue,reportUnknownMemberType]
|
|
77
|
+
for k, v in r._kwargs.items() # noqa: SLF001
|
|
78
|
+
}
|
|
79
|
+
return r.func(*args, **kwargs)
|
|
80
|
+
|
|
81
|
+
def finalize(self) -> Self:
|
|
82
|
+
"""Create a finalized copy with nested configs finalized.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
finalized: Copy with _finalized=True and nested configs finalized.
|
|
86
|
+
|
|
87
|
+
"""
|
|
88
|
+
r = copy.copy(self)
|
|
89
|
+
# Dynamic dispatch to finalize() on args that may have it
|
|
90
|
+
r._args = [v.finalize() if hasattr(v, "finalize") else v for v in r._args] # noqa: SLF001 # pyright: ignore[reportAttributeAccessIssue,reportUnknownMemberType]
|
|
91
|
+
r._kwargs = { # noqa: SLF001
|
|
92
|
+
k: v.finalize() if hasattr(v, "finalize") else v # pyright: ignore[reportAttributeAccessIssue,reportUnknownMemberType]
|
|
93
|
+
for k, v in r._kwargs.items() # noqa: SLF001
|
|
94
|
+
}
|
|
95
|
+
r._finalized = True # noqa: SLF001
|
|
96
|
+
return r
|
|
97
|
+
|
|
98
|
+
def update(
|
|
99
|
+
self,
|
|
100
|
+
source: DataclassLike | Configurable[object] | None = None,
|
|
101
|
+
*,
|
|
102
|
+
skip_missing: bool = False,
|
|
103
|
+
**kwargs: object,
|
|
104
|
+
) -> Self:
|
|
105
|
+
"""Update config kwargs from source and/or kwargs.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
source: Optional source object to copy attributes from.
|
|
109
|
+
skip_missing: If True, skip kwargs keys that don't exist in _kwargs.
|
|
110
|
+
**kwargs: Additional attribute overrides.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
self: Updated instance for method chaining.
|
|
114
|
+
|
|
115
|
+
"""
|
|
116
|
+
del skip_missing # InlineConfig doesn't have fixed attributes
|
|
117
|
+
if source is not None:
|
|
118
|
+
if dataclasses.is_dataclass(source):
|
|
119
|
+
for field in dataclasses.fields(source):
|
|
120
|
+
self._kwargs[field.name] = getattr(source, field.name)
|
|
121
|
+
else:
|
|
122
|
+
# Try to copy attributes from non-dataclass source
|
|
123
|
+
for key in dir(source):
|
|
124
|
+
if not key.startswith("_"):
|
|
125
|
+
try:
|
|
126
|
+
self._kwargs[key] = getattr(source, key)
|
|
127
|
+
except (AttributeError, TypeError):
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
for key, value in kwargs.items():
|
|
131
|
+
self._kwargs[key] = value
|
|
132
|
+
|
|
133
|
+
return self
|
|
134
|
+
|
|
135
|
+
@override
|
|
136
|
+
def __delattr__(self, key: str) -> None:
|
|
137
|
+
try:
|
|
138
|
+
del self._kwargs[key]
|
|
139
|
+
return
|
|
140
|
+
except KeyError:
|
|
141
|
+
pass
|
|
142
|
+
object.__delattr__(self, key)
|
|
143
|
+
|
|
144
|
+
def __getattr__(self, key: str) -> object:
|
|
145
|
+
try:
|
|
146
|
+
return object.__getattribute__(self, "_kwargs")[key]
|
|
147
|
+
except (TypeError, AttributeError, KeyError):
|
|
148
|
+
pass
|
|
149
|
+
return object.__getattribute__(self, key)
|
|
150
|
+
|
|
151
|
+
@override
|
|
152
|
+
def __setattr__(self, key: str, value: object) -> None:
|
|
153
|
+
try:
|
|
154
|
+
_ = object.__getattribute__(self, key)
|
|
155
|
+
hasattr_ = True
|
|
156
|
+
except (TypeError, AttributeError):
|
|
157
|
+
hasattr_ = False
|
|
158
|
+
if hasattr_:
|
|
159
|
+
# This is the reason kwargs must come last--we're relying on the
|
|
160
|
+
# non passthroughs already being set.
|
|
161
|
+
object.__setattr__(self, key, value)
|
|
162
|
+
return
|
|
163
|
+
try:
|
|
164
|
+
self._kwargs[key] = value
|
|
165
|
+
return
|
|
166
|
+
except AttributeError:
|
|
167
|
+
pass
|
|
168
|
+
object.__setattr__(self, key, value)
|
|
169
|
+
|
|
170
|
+
@reprlib.recursive_repr()
|
|
171
|
+
def __repr__(self) -> str:
|
|
172
|
+
return (
|
|
173
|
+
f"{type(self).__qualname__}("
|
|
174
|
+
+ ", ".join(
|
|
175
|
+
[repr(self.func)]
|
|
176
|
+
+ [repr(v) for v in self._args]
|
|
177
|
+
+ [f"{k}={v!r}" for k, v in self._kwargs.items()],
|
|
178
|
+
)
|
|
179
|
+
+ ")"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class PartialConfig(InlineConfig[Callable[..., _T]]):
|
|
184
|
+
"""InlineConfig that returns a functools.partial instead of calling the function."""
|
|
185
|
+
|
|
186
|
+
def __init__(
|
|
187
|
+
self,
|
|
188
|
+
/,
|
|
189
|
+
func: Callable[..., _T],
|
|
190
|
+
*args: object,
|
|
191
|
+
**kwargs: object,
|
|
192
|
+
) -> None:
|
|
193
|
+
super().__init__(functools.partial, func, *args, **kwargs)
|