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/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)