sg-observable 0.1.0a0__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.
observable/__init__.py ADDED
File without changes
@@ -0,0 +1,582 @@
1
+ """
2
+ MIT License
3
+
4
+ Copyright (c) 2026 Sébastien Gachoud
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
23
+
24
+ -------------------------------------------------------------------------------
25
+
26
+ Author: Sébastien Gachoud
27
+ Created: 2026-10-05
28
+ Description: This module provides observable fields and change notifications.
29
+ 🦙
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ __author__ = "Sébastien Gachoud"
35
+ __license__ = "MIT"
36
+
37
+ import sys
38
+ from copy import copy
39
+ from dataclasses import dataclass
40
+ from enum import IntFlag
41
+ from enum import auto as e_auto
42
+ from typing import (
43
+ Any,
44
+ Callable,
45
+ Self,
46
+ TypeVar,
47
+ get_args,
48
+ get_origin,
49
+ cast,
50
+ overload,
51
+ )
52
+
53
+ import numpy as np
54
+
55
+ from metacore import TracedException, annotation_registry
56
+ from metacore.annotations import AnnotationsRegistry
57
+ from metacore.typing import resolve_annotation_types
58
+
59
+ type Observer = Callable[..., Any]
60
+
61
+
62
+ class ObservableFieldError(TracedException):
63
+ """Base error for observable field definitions and access."""
64
+
65
+
66
+ class UninitializedError(ObservableFieldError):
67
+ """A field has neither a stored value nor a default."""
68
+
69
+
70
+ class MemberDefinitionError(ObservableFieldError):
71
+ """An observable member has an invalid definition."""
72
+
73
+
74
+ class TypeFormatError(ObservableFieldError):
75
+ """A field annotation has an unsupported format."""
76
+
77
+
78
+ class DefaultValueError(ObservableFieldError):
79
+ """A field default cannot be constructed."""
80
+
81
+
82
+ class AccessViolationError(ObservableFieldError):
83
+ """A field operation violates its access policy."""
84
+
85
+
86
+ class ReadAccessViolationError(AccessViolationError):
87
+ """Reading the field is forbidden."""
88
+
89
+
90
+ class WriteAccessViolationError(AccessViolationError):
91
+ """Writing the field is forbidden."""
92
+
93
+
94
+ class PropertyDefinitionError(ObservableFieldError):
95
+ """An observable property has an invalid definition."""
96
+
97
+
98
+ class GetterDefinitionError(ObservableFieldError):
99
+ """An observable getter has an invalid definition."""
100
+
101
+
102
+ class SetterDefinitionError(ObservableFieldError):
103
+ """An observable setter has an invalid definition."""
104
+
105
+
106
+ class Access(IntFlag):
107
+ """Access policy of an observable field.
108
+
109
+ Attributes:
110
+ none (int): No access. The field cannot be written or read. This can be
111
+ useful to have a notification only field. All fields can be observed.
112
+ read (int): Read access. Allows the field to be read.
113
+ write (int): Write access. Allows the field to be written.
114
+ """
115
+
116
+ # Lowercase flag names are part of the public Access.read / Access.write API.
117
+ # pylint: disable=invalid-name
118
+ none = 0
119
+ read = e_auto()
120
+ write = e_auto()
121
+ both = read | write
122
+ r = read
123
+ w = write
124
+ rw = read | write
125
+
126
+
127
+ class Empty:
128
+ """Represent a value not provided."""
129
+
130
+ def __new__(cls) -> type[Empty]:
131
+ return Empty
132
+
133
+ def __repr__(self) -> str:
134
+ return "Observably Empty"
135
+
136
+ def __bool__(self) -> bool:
137
+ return False
138
+
139
+
140
+ class Auto:
141
+ """Request the default to be automatically derived from the field's type annotation."""
142
+
143
+ def __new__(cls) -> type[Auto]:
144
+ return Auto
145
+
146
+ def __repr__(self) -> str:
147
+ return "Observably Auto"
148
+
149
+
150
+ class ObservableFieldDecorators[T, I]:
151
+ """Collect callbacks declared with field decorators."""
152
+
153
+ convert: Callable[[I], T] | None = None
154
+ compare: Callable[[T, T], bool] | None = None
155
+ assign: Callable[[Observable, T], None] | None = None
156
+ set: Callable[[Observable, I], None] | None = None
157
+ get: Callable[[Observable], T] | None = None
158
+
159
+ def converter(self, converter: Callable[[I], T]) -> Callable[[I], T]:
160
+ """Register a conversion callback and return it unchanged."""
161
+ self.convert = converter
162
+ return converter
163
+
164
+ def comparator(self, comparator: Callable[[T, T], bool]) -> Callable[[T, T], bool]:
165
+ """Register the comparison used to suppress unchanged-value notifications."""
166
+ self.compare = comparator
167
+ return comparator
168
+
169
+ def assigner[S: Observable](
170
+ self, assigner: Callable[[S, T], None]
171
+ ) -> Callable[[S, T], None]:
172
+ """Register the callback that stores a converted value."""
173
+ self.assign = cast(Callable[[Observable, T], None], assigner)
174
+ return assigner
175
+
176
+ def setter[S: Observable](
177
+ self, setter: Callable[[S, I], None]
178
+ ) -> Callable[[S, I], None]:
179
+ """Register a callback that replaces the default setter."""
180
+ self.set = cast(Callable[[Observable, I], None], setter)
181
+ return setter
182
+
183
+ def getter[S: Observable](self, getter: Callable[[S], T]) -> Callable[[S], T]:
184
+ """Register a callback that replaces the default getter."""
185
+ self.get = cast(Callable[[Observable], T], getter)
186
+ return getter
187
+
188
+
189
+ @dataclass
190
+ class AutoField[T, I = T](ObservableFieldDecorators[T, I]):
191
+ """Field configuration whose types are supplied by the class annotation."""
192
+
193
+ # The configuration exposes each independent descriptor option explicitly.
194
+ # pylint: disable=too-many-instance-attributes
195
+ default: T | type[Empty] | type[Auto] = Empty
196
+ default_factory: Callable[[], T] | None = None
197
+ access: Access = Access.both
198
+ custom_annotation_registry: AnnotationsRegistry | None = None
199
+ force_type: tuple[Any, Any] | type[Empty] = Empty
200
+ convert: Callable[[I], T] | None = None
201
+ compare: Callable[[T, T], bool] | None = None
202
+ assign: Callable[[Observable, T], None] | None = None
203
+ set: Callable[[Observable, I], None] | None = None
204
+ get: Callable[[Observable], T] | None = None
205
+
206
+ def as_observable_field(self) -> ObservableField[T, I]:
207
+ """Build a descriptor with this configuration and its callbacks."""
208
+ return ObservableField[T, I](
209
+ default=self.default,
210
+ default_factory=self.default_factory,
211
+ access=self.access,
212
+ custom_annotation_registry=self.custom_annotation_registry,
213
+ force_type=self.force_type,
214
+ converter=self.convert,
215
+ comparator=self.compare,
216
+ assigner=self.assign,
217
+ setter=self.set,
218
+ getter=self.get,
219
+ )
220
+
221
+
222
+ # Keep the existing positional API for the independent descriptor options.
223
+ # pylint: disable-next=too-many-arguments,too-many-positional-arguments
224
+ def auto_field[T, I = T](
225
+ default: T | type[Empty] | type[Auto] = Empty,
226
+ default_factory: Callable[[], T] | None = None,
227
+ access: Access = Access.both,
228
+ custom_annotation_registry: AnnotationsRegistry | None = None,
229
+ force_type: tuple[Any, Any] | type[Empty] = Empty,
230
+ converter: Callable[[I], T] | None = None,
231
+ comparator: Callable[[T, T], bool] | None = None,
232
+ assigner: Callable[[Observable, T], None] | None = None,
233
+ setter: Callable[[Observable, I], None] | None = None,
234
+ getter: Callable[[Observable], T] | None = None,
235
+ ) -> ObservableField[T, I]:
236
+ """Declare a field; the metaclass replaces this configuration with a descriptor.
237
+
238
+ The return type permits assignment to an ObservableField annotation while the
239
+ runtime configuration retains the decorator methods used in the class body.
240
+ """
241
+ configuration = AutoField[T, I](
242
+ default,
243
+ default_factory,
244
+ access,
245
+ custom_annotation_registry,
246
+ force_type,
247
+ converter,
248
+ comparator,
249
+ assigner,
250
+ setter,
251
+ getter,
252
+ )
253
+ # The metaclass turns this declaration into a descriptor before the class is used.
254
+ return cast(ObservableField[T, I], configuration)
255
+
256
+
257
+ class ObservableField[T, I = T](ObservableFieldDecorators[T, I]):
258
+ """Descriptor that converts values, controls access, and notifies observers."""
259
+
260
+ # Each callback and resolved field option is stored independently.
261
+ # pylint: disable=too-many-instance-attributes
262
+ # Keep the existing constructor's positional API.
263
+ # pylint: disable-next=too-many-arguments,too-many-positional-arguments
264
+ def __init__(
265
+ self,
266
+ default: T | type[Empty] | type[Auto] = Empty,
267
+ default_factory: Callable[[], T] | None = None,
268
+ access: Access = Access.both,
269
+ custom_annotation_registry: AnnotationsRegistry | None = None,
270
+ force_type: tuple[Any, Any] | type[Empty] = Empty,
271
+ converter: Callable[[I], T] | None = None,
272
+ comparator: Callable[[T, T], bool] | None = None,
273
+ assigner: Callable[[Observable, T], None] | None = None,
274
+ setter: Callable[[Observable, I], None] | None = None,
275
+ getter: Callable[[Observable], T] | None = None,
276
+ ) -> None:
277
+
278
+ if default is not Empty and default_factory is not None:
279
+ raise ValueError(
280
+ "default_value and default_factory can not be provided at the same time."
281
+ )
282
+
283
+ self.__post_value = (
284
+ default,
285
+ force_type,
286
+ converter,
287
+ comparator,
288
+ assigner,
289
+ setter,
290
+ getter,
291
+ )
292
+
293
+ self.default_factory = default_factory
294
+ self._registry = custom_annotation_registry or annotation_registry()
295
+ self.access = access
296
+
297
+ self.name = "Undefined"
298
+
299
+ def __post_init__(self) -> None:
300
+ default_value, force_type, converter, comparator, assigner, setter, getter = (
301
+ self.__post_value
302
+ )
303
+ del self.__post_value
304
+ if isinstance(force_type, tuple):
305
+ _type, input_types = force_type
306
+ elif (orig_class := getattr(self, "__orig_class__", None)) is not None:
307
+ _type, input_types = get_args(orig_class)
308
+ if isinstance(input_types, TypeVar):
309
+ input_types = _type
310
+ else:
311
+ raise ValueError(
312
+ "ObservableField must be used with an Observable class. If you think it is done "
313
+ "properly but still fails, please open an issue on github. To bypass this error, "
314
+ "you can provide "
315
+ "the type and input types manually through force_type."
316
+ )
317
+ self.default = self.__make_default(default_value, self.default_factory, _type)
318
+ self.type = _type
319
+ self.input_types = input_types
320
+
321
+ self.default_convert: Callable[[I], T] = (
322
+ self._registry.converter_from_annotation(_type)
323
+ )
324
+ self._converter: Callable[[I], T] = converter or self.default_convert
325
+ self._comparator: Callable[[Any, Any], bool] = (
326
+ comparator or self.default_compare
327
+ )
328
+ self._assigner: Callable[[Observable, T], None] = (
329
+ assigner or self.default_assign
330
+ )
331
+ self._setter: Callable[[Observable, I], None] = setter or self.default_set
332
+ self._getter: Callable[[Observable], T] = getter or self.default_get
333
+
334
+ def __make_default(
335
+ self,
336
+ default: T | type[Empty] | type[Auto],
337
+ factory: Callable[[], T] | None,
338
+ annotation: Any,
339
+ ) -> T | type[Empty]:
340
+ if default is Auto:
341
+ return self._registry.default_annotation(annotation)
342
+ if default is not Empty:
343
+ return cast(T, default)
344
+ if factory is not None:
345
+ return factory()
346
+ return Empty
347
+
348
+ def default_compare(self, value1: Any, value2: Any) -> bool:
349
+ """Compare scalar or NumPy values for equality."""
350
+ if isinstance(value1, np.ndarray) or isinstance(value2, np.ndarray):
351
+ return np.array_equal(value1, value2)
352
+ return value1 == value2
353
+
354
+ def default_assign(self, instance: Observable, value: T) -> None:
355
+ """Store a converted value in the owning instance."""
356
+ # The descriptor and its owner share the module's internal storage protocol.
357
+ # pylint: disable=protected-access
358
+ instance._observable_values[self] = value
359
+
360
+ def default_set(self, instance: Observable, value: I) -> None:
361
+ """Convert and store an input, notifying observers when its value changes."""
362
+ # pylint: disable=protected-access
363
+ new_value = self._converter(value)
364
+ if self in instance._observable_values and self._comparator(
365
+ instance._observable_values[self], new_value
366
+ ):
367
+ return
368
+ self._assigner(instance, new_value)
369
+ instance.notify(self, value)
370
+
371
+ def default_get(self, instance: Observable) -> T:
372
+ """Return the stored value or default, enforcing the read policy."""
373
+ # pylint: disable=protected-access
374
+ if Access.read not in self.access:
375
+ raise ReadAccessViolationError(
376
+ f"Cannot read attribute '{self.name}', read is not allowed."
377
+ )
378
+ if Access.write not in self.access:
379
+ if self.default is not Empty:
380
+ return cast(T, self.default)
381
+ raise UninitializedError(
382
+ f"Attribute '{self.name}' has not value and is not writable."
383
+ )
384
+ if self in instance._observable_values:
385
+ return instance._observable_values[self]
386
+ if self.default is not Empty:
387
+ return cast(T, self.default)
388
+ raise UninitializedError(f"Attribute '{self.name}' is not initialized.")
389
+
390
+ def __set_name__(self, owner: type, name: str) -> None:
391
+ self.name = name
392
+
393
+ @overload
394
+ def __get__(
395
+ self, instance: None, owner: type[Observable] | None = None
396
+ ) -> Self: ...
397
+
398
+ @overload
399
+ def __get__(
400
+ self, instance: Observable, owner: type[Observable] | None = None
401
+ ) -> T: ...
402
+
403
+ def __get__(
404
+ self, instance: Observable | None, owner: type[Observable] | None = None
405
+ ) -> T | Self:
406
+ if instance is None:
407
+ return self
408
+ return self._getter(instance)
409
+
410
+ def __set__(self, instance: Observable, value: I) -> None:
411
+ if Access.write not in self.access:
412
+ raise WriteAccessViolationError(
413
+ f"Cannot write attribute '{self.name}', write is not allowed."
414
+ )
415
+ self._setter(instance, value)
416
+
417
+
418
+ class ObservableMeta(type):
419
+ """Resolve field declarations and collect inherited descriptors."""
420
+
421
+ def __new__(
422
+ mcs,
423
+ name: str,
424
+ bases: tuple[type, ...],
425
+ namespace: dict[str, Any],
426
+ /,
427
+ **kwargs: Any,
428
+ ) -> ObservableMeta:
429
+ # Python 3.14 defers class annotations unless postponed annotations are enabled.
430
+ if sys.version_info >= (3, 14):
431
+ # Import only on interpreters that provide annotationlib.
432
+ # pylint: disable-next=import-outside-toplevel
433
+ from annotationlib import (
434
+ Format,
435
+ call_annotate_function,
436
+ get_annotate_from_class_namespace,
437
+ )
438
+
439
+ if "__annotations__" not in namespace:
440
+ annotate = get_annotate_from_class_namespace(namespace)
441
+ if annotate is not None:
442
+ namespace["__annotations__"] = call_annotate_function(
443
+ annotate, Format.FORWARDREF
444
+ )
445
+
446
+ fields = mcs._collect_observable_fields(bases, namespace)
447
+ # Inherited descriptors have already been finalized by their declaring class.
448
+ for value in namespace.values():
449
+ if isinstance(value, ObservableField):
450
+ value.__post_init__()
451
+ mcs._populate_observables(namespace, fields)
452
+ return super().__new__(mcs, name, bases, namespace, **kwargs)
453
+
454
+ @classmethod
455
+ def _collect_observable_fields(
456
+ mcs, bases: tuple[type, ...], namespace: dict[str, Any]
457
+ ) -> dict[str, ObservableField[Any, Any]]:
458
+ # The metaclass collects the same private field map that it creates below.
459
+ # pylint: disable=protected-access
460
+ fields: dict[str, ObservableField[Any, Any]] = {}
461
+ for base in bases:
462
+ if hasattr(base, "_observable_fields"):
463
+ fields.update(base._observable_fields)
464
+ for name, value in namespace.items():
465
+ if isinstance(value, ObservableField):
466
+ fields[name] = value
467
+ if isinstance(value, AutoField):
468
+ fields[name] = mcs._process_auto_field(namespace, name, value)
469
+ namespace[name] = fields[name]
470
+ return fields
471
+
472
+ @classmethod
473
+ def _process_auto_field(
474
+ mcs, namespace: dict[str, Any], name: str, field: AutoField[Any, Any]
475
+ ) -> ObservableField[Any, Any]:
476
+ annotation = namespace.get("__annotations__", {}).get(name, None)
477
+ if annotation is None:
478
+ raise TypeError(f"Automatic field '{name}' must have a type annotation.")
479
+ module = sys.modules.get(namespace.get("__module__", ""))
480
+ nglobals = module.__dict__ if module is not None else None
481
+ annotation = resolve_annotation_types({name: annotation}, nglobals, namespace)[
482
+ name
483
+ ]
484
+ orig = get_origin(annotation)
485
+ if orig is None or orig is not ObservableField:
486
+ raise TypeError(
487
+ f"Automatic field '{name}' must be annotated as an ObservableField."
488
+ )
489
+ of = field.as_observable_field()
490
+ setattr(of, "__orig_class__", annotation)
491
+ return of
492
+
493
+ @classmethod
494
+ def _populate_observables(
495
+ mcs,
496
+ namespace: dict[str, Any],
497
+ fields: dict[str, ObservableField[Any, Any]],
498
+ ) -> None:
499
+ namespace["_observable_fields"] = fields
500
+
501
+
502
+ class Observable(metaclass=ObservableMeta):
503
+ """Owner of observable values and per-instance observer subscriptions."""
504
+
505
+ _observable_fields: dict[str, ObservableField[Any, Any]]
506
+ _observable_values: dict[ObservableField[Any, Any], Any]
507
+ _observers_internal: dict[ObservableField[Any, Any], list[Observer]]
508
+ _observers: dict[ObservableField[Any, Any], list[Observer]]
509
+
510
+ def __new__(cls, *_args: Any, **_kwargs: Any) -> Self:
511
+ # Initialize before subclass __init__, which may immediately assign fields
512
+ # without calling super().__init__. Only field descriptors are shared.
513
+ instance = super().__new__(cls)
514
+ instance._observable_values = {}
515
+ instance._observers_internal = {f: [] for f in cls._observable_fields.values()}
516
+ instance._observers = {f: [] for f in cls._observable_fields.values()}
517
+ return instance
518
+
519
+ @property
520
+ def F(self) -> type[Self]: # pylint: disable=invalid-name
521
+ """Convenience property to access the ObservableField types.
522
+ For example in a method of the class MyClass(Observable), instead of
523
+ `self.register_observer(MyClass.my_field, lambda x: print(x))`
524
+ you can write `self.register_observer(self.F.my_field, lambda x: print(x))`.
525
+ """
526
+ return type(self)
527
+
528
+ def set_val[T, I](self, of: ObservableField[T, I], val: I) -> None:
529
+ """Write through a descriptor supplied explicitly by the caller."""
530
+ # Descriptor dispatch is intentional; there is no equivalent set() method.
531
+ of.__set__(self, val) # pylint: disable=unnecessary-dunder-call
532
+
533
+ def get_val[T, I](self, of: ObservableField[T, I]) -> T:
534
+ """Read through a descriptor supplied explicitly by the caller."""
535
+ return of.__get__(self) # pylint: disable=unnecessary-dunder-call
536
+
537
+ def _register_observer_internal(
538
+ self, of: ObservableField[Any, Any], observer: Observer
539
+ ) -> Observer:
540
+ self._observers_internal[of].append(observer)
541
+ return observer
542
+
543
+ def _remove_observer_internal(
544
+ self, of: ObservableField[Any, Any], observer: Observer
545
+ ) -> None:
546
+ self._observers_internal[of].remove(observer)
547
+
548
+ def get_observers_internal(self, of: ObservableField[Any, Any]) -> list[Observer]:
549
+ """Return a copy of the field's internal subscription list."""
550
+ return copy(self._observers_internal[of])
551
+
552
+ def register_observer(
553
+ self, of: ObservableField[Any, Any], observer: Observer
554
+ ) -> Observer:
555
+ """Subscribe a callback to this instance's field and return the callback."""
556
+ self._observers[of].append(observer)
557
+ return observer
558
+
559
+ def remove_observer(
560
+ self, of: ObservableField[Any, Any], observer: Observer
561
+ ) -> None:
562
+ """Remove a previously registered callback from this instance's field."""
563
+ self._observers[of].remove(observer)
564
+
565
+ def get_observers(self, of: ObservableField[Any, Any]) -> list[Observer]:
566
+ """Return a copy of the field's public subscription list."""
567
+ return copy(self._observers[of])
568
+
569
+ def notify(
570
+ self,
571
+ of_s: ObservableField[Any, Any] | list[ObservableField[Any, Any]],
572
+ *a: Any,
573
+ **kw: Any,
574
+ ) -> None:
575
+ """Notify internal and public subscribers of one or more fields."""
576
+ if isinstance(of_s, ObservableField):
577
+ of_s = [of_s]
578
+ for of in of_s:
579
+ for observer in self._observers_internal[of]:
580
+ observer(*a, **kw)
581
+ for observer in self._observers[of]:
582
+ observer(*a, **kw)
observable/py.typed ADDED
File without changes
@@ -0,0 +1,72 @@
1
+ Metadata-Version: 2.4
2
+ Name: sg-observable
3
+ Version: 0.1.0a0
4
+ Summary: Typed observable fields with conversion and change notifications
5
+ License: MIT
6
+ Requires-Python: >=3.13
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: sg-metacore>=0.1.0a1
10
+ Requires-Dist: numpy
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=7.0; extra == "dev"
13
+ Requires-Dist: pyright>=1.1.411; extra == "dev"
14
+ Dynamic: license-file
15
+
16
+ # observable
17
+
18
+ Observable fields with conversion, access policies, and change notifications.
19
+ Requires Python 3.13 or newer.
20
+
21
+ ## Installation
22
+
23
+ Install the `sg-observable` package from PyPI:
24
+
25
+ ```bash
26
+ python -m pip install sg-observable
27
+ ```
28
+
29
+ Pip also installs its dependencies: `sg-metacore` from PyPI and NumPy.
30
+
31
+ ## Usage
32
+
33
+ ```python
34
+ from observable.observable import Observable, ObservableField, auto_field
35
+
36
+ class Counter(Observable):
37
+ value: ObservableField[int, str] = auto_field(default=0)
38
+
39
+ counter = Counter()
40
+ counter.value = "42" # Writes accept str and convert to int.
41
+ assert counter.value == 42 # Reads return int.
42
+ ```
43
+
44
+ ## Runtime and typing
45
+
46
+ `src/observable/observable.py` contains the implementation and its public type
47
+ annotations, including descriptor overloads and the types of field decorators.
48
+
49
+ `auto_field()` is intended for annotated declarations in an `Observable` class.
50
+ At runtime it returns an `AutoField` configuration that the metaclass replaces
51
+ with an `ObservableField`. Its return annotation describes that resulting
52
+ descriptor, with a localized cast in the factory to account for the metaclass
53
+ transformation and preserve the field's read and write types.
54
+ Use `AutoField` directly when working with a configuration outside a class body.
55
+
56
+ The wheel includes a `py.typed` marker so type checkers use the annotations in
57
+ the implementation. Existing comments and TODOs remain in the implementation.
58
+
59
+ ## Development
60
+
61
+ From the `observable` project directory, install the development dependencies
62
+ and run the checks. MetaCore is installed as the `sg-metacore` PyPI dependency.
63
+
64
+ ```bash
65
+ python -m pip install -e ".[dev]"
66
+ python -m pyright --project pyproject.toml
67
+ python -m pytest
68
+ ```
69
+
70
+ The tests cover runtime behavior and rejected type errors.
71
+ Static examples under `tests/typing/` verify descriptor inference and
72
+ callback signatures.
@@ -0,0 +1,8 @@
1
+ observable/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ observable/observable.py,sha256=Hhh6Ts3maXcyRgu2ukYJuCRGCnRDGrsRWog-9O15onA,21807
3
+ observable/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ sg_observable-0.1.0a0.dist-info/licenses/LICENSE,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ sg_observable-0.1.0a0.dist-info/METADATA,sha256=maR36swIhW1OHM0j2vggBfOHHZ2BOkxJQAI29ywDBHI,2355
6
+ sg_observable-0.1.0a0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
7
+ sg_observable-0.1.0a0.dist-info/top_level.txt,sha256=cHPFLIyulWa53F7NJVFl-O2nefpmJySLaKFfIPOX4IE,11
8
+ sg_observable-0.1.0a0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
File without changes
@@ -0,0 +1 @@
1
+ observable