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