sg-observable 0.1.0a0__tar.gz
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.
- sg_observable-0.1.0a0/LICENSE +0 -0
- sg_observable-0.1.0a0/MANIFEST.in +1 -0
- sg_observable-0.1.0a0/PKG-INFO +72 -0
- sg_observable-0.1.0a0/pyproject.toml +36 -0
- sg_observable-0.1.0a0/readme.md +57 -0
- sg_observable-0.1.0a0/setup.cfg +4 -0
- sg_observable-0.1.0a0/src/observable/__init__.py +0 -0
- sg_observable-0.1.0a0/src/observable/observable.py +582 -0
- sg_observable-0.1.0a0/src/observable/py.typed +0 -0
- sg_observable-0.1.0a0/src/sg_observable.egg-info/PKG-INFO +72 -0
- sg_observable-0.1.0a0/src/sg_observable.egg-info/SOURCES.txt +17 -0
- sg_observable-0.1.0a0/src/sg_observable.egg-info/dependency_links.txt +1 -0
- sg_observable-0.1.0a0/src/sg_observable.egg-info/requires.txt +6 -0
- sg_observable-0.1.0a0/src/sg_observable.egg-info/top_level.txt +1 -0
- sg_observable-0.1.0a0/tests/test_observable.py +143 -0
- sg_observable-0.1.0a0/tests/test_observable_annotations.py +46 -0
- sg_observable-0.1.0a0/tests/test_observable_typing.py +60 -0
- sg_observable-0.1.0a0/tests/typing/invalid.py.txt +19 -0
- sg_observable-0.1.0a0/tests/typing/valid.py +43 -0
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
recursive-include tests *.py *.txt
|
|
@@ -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,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sg-observable"
|
|
7
|
+
version = "0.1.0a0"
|
|
8
|
+
description = "Typed observable fields with conversion and change notifications"
|
|
9
|
+
readme = "readme.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
requires-python = ">=3.13"
|
|
12
|
+
dependencies = ["sg-metacore>=0.1.0a1", "numpy"]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
dev = ["pytest>=7.0", "pyright>=1.1.411"]
|
|
16
|
+
|
|
17
|
+
[tool.setuptools]
|
|
18
|
+
package-dir = {"" = "src"}
|
|
19
|
+
|
|
20
|
+
[tool.setuptools.packages.find]
|
|
21
|
+
where = ["src"]
|
|
22
|
+
include = ["observable", "observable.*"]
|
|
23
|
+
namespaces = false
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.package-data]
|
|
26
|
+
observable = ["py.typed"]
|
|
27
|
+
|
|
28
|
+
[tool.pytest.ini_options]
|
|
29
|
+
testpaths = ["tests"]
|
|
30
|
+
pythonpath = ["src"]
|
|
31
|
+
|
|
32
|
+
[tool.pyright]
|
|
33
|
+
include = ["src", "tests"]
|
|
34
|
+
extraPaths = ["src"]
|
|
35
|
+
typeCheckingMode = "standard"
|
|
36
|
+
pythonVersion = "3.13"
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# observable
|
|
2
|
+
|
|
3
|
+
Observable fields with conversion, access policies, and change notifications.
|
|
4
|
+
Requires Python 3.13 or newer.
|
|
5
|
+
|
|
6
|
+
## Installation
|
|
7
|
+
|
|
8
|
+
Install the `sg-observable` package from PyPI:
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
python -m pip install sg-observable
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Pip also installs its dependencies: `sg-metacore` from PyPI and NumPy.
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from observable.observable import Observable, ObservableField, auto_field
|
|
20
|
+
|
|
21
|
+
class Counter(Observable):
|
|
22
|
+
value: ObservableField[int, str] = auto_field(default=0)
|
|
23
|
+
|
|
24
|
+
counter = Counter()
|
|
25
|
+
counter.value = "42" # Writes accept str and convert to int.
|
|
26
|
+
assert counter.value == 42 # Reads return int.
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Runtime and typing
|
|
30
|
+
|
|
31
|
+
`src/observable/observable.py` contains the implementation and its public type
|
|
32
|
+
annotations, including descriptor overloads and the types of field decorators.
|
|
33
|
+
|
|
34
|
+
`auto_field()` is intended for annotated declarations in an `Observable` class.
|
|
35
|
+
At runtime it returns an `AutoField` configuration that the metaclass replaces
|
|
36
|
+
with an `ObservableField`. Its return annotation describes that resulting
|
|
37
|
+
descriptor, with a localized cast in the factory to account for the metaclass
|
|
38
|
+
transformation and preserve the field's read and write types.
|
|
39
|
+
Use `AutoField` directly when working with a configuration outside a class body.
|
|
40
|
+
|
|
41
|
+
The wheel includes a `py.typed` marker so type checkers use the annotations in
|
|
42
|
+
the implementation. Existing comments and TODOs remain in the implementation.
|
|
43
|
+
|
|
44
|
+
## Development
|
|
45
|
+
|
|
46
|
+
From the `observable` project directory, install the development dependencies
|
|
47
|
+
and run the checks. MetaCore is installed as the `sg-metacore` PyPI dependency.
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
python -m pip install -e ".[dev]"
|
|
51
|
+
python -m pyright --project pyproject.toml
|
|
52
|
+
python -m pytest
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The tests cover runtime behavior and rejected type errors.
|
|
56
|
+
Static examples under `tests/typing/` verify descriptor inference and
|
|
57
|
+
callback signatures.
|
|
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)
|
|
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,17 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
MANIFEST.in
|
|
3
|
+
pyproject.toml
|
|
4
|
+
readme.md
|
|
5
|
+
src/observable/__init__.py
|
|
6
|
+
src/observable/observable.py
|
|
7
|
+
src/observable/py.typed
|
|
8
|
+
src/sg_observable.egg-info/PKG-INFO
|
|
9
|
+
src/sg_observable.egg-info/SOURCES.txt
|
|
10
|
+
src/sg_observable.egg-info/dependency_links.txt
|
|
11
|
+
src/sg_observable.egg-info/requires.txt
|
|
12
|
+
src/sg_observable.egg-info/top_level.txt
|
|
13
|
+
tests/test_observable.py
|
|
14
|
+
tests/test_observable_annotations.py
|
|
15
|
+
tests/test_observable_typing.py
|
|
16
|
+
tests/typing/invalid.py.txt
|
|
17
|
+
tests/typing/valid.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
observable
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Regression coverage for typed descriptors and observable instance behavior."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import assert_type
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from metacore.annotations import AnnotationsRegistry
|
|
11
|
+
from observable.observable import (
|
|
12
|
+
Access,
|
|
13
|
+
Auto,
|
|
14
|
+
Observable,
|
|
15
|
+
ObservableField,
|
|
16
|
+
ReadAccessViolationError,
|
|
17
|
+
UninitializedError,
|
|
18
|
+
WriteAccessViolationError,
|
|
19
|
+
auto_field,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Counter(Observable):
|
|
24
|
+
value: ObservableField[int, str] = auto_field(default=0)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_field_annotations_resolve_module_and_class_local_names():
|
|
28
|
+
class Scoped(Observable):
|
|
29
|
+
from decimal import Decimal as Scalar
|
|
30
|
+
|
|
31
|
+
value: ObservableField[Scalar, str] = auto_field(default=Auto)
|
|
32
|
+
directory: ObservableField[Path, str] = auto_field(default=Auto)
|
|
33
|
+
|
|
34
|
+
instance = Scoped()
|
|
35
|
+
instance.value = "1.25"
|
|
36
|
+
instance.directory = "documents"
|
|
37
|
+
assert instance.value == Scoped.Scalar("1.25")
|
|
38
|
+
assert instance.directory == Path("documents")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_conversion_notifications_and_instance_isolation():
|
|
42
|
+
first, second = Counter(), Counter()
|
|
43
|
+
events: list[str] = []
|
|
44
|
+
first.register_observer(Counter.value, events.append)
|
|
45
|
+
|
|
46
|
+
first.value = "42"
|
|
47
|
+
first.value = "42"
|
|
48
|
+
|
|
49
|
+
assert first.value == 42
|
|
50
|
+
assert second.value == 0
|
|
51
|
+
assert events == ["42"]
|
|
52
|
+
assert second.get_observers(Counter.value) == []
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_explicit_descriptor_access_preserves_types():
|
|
56
|
+
counter = Counter()
|
|
57
|
+
counter.set_val(Counter.value, "3")
|
|
58
|
+
assert counter.get_val(Counter.value) == 3
|
|
59
|
+
assert_type(counter.get_val(Counter.value), int)
|
|
60
|
+
assert_type(Counter.value, ObservableField[int, str])
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_inherited_fields_are_not_initialized_twice():
|
|
64
|
+
class Child(Counter):
|
|
65
|
+
other: ObservableField[int, str] = auto_field(default=1)
|
|
66
|
+
|
|
67
|
+
first, second = Child(), Child()
|
|
68
|
+
first.value = "7"
|
|
69
|
+
first.other = "8"
|
|
70
|
+
assert first.value == 7
|
|
71
|
+
assert first.other == 8
|
|
72
|
+
assert second.value == 0
|
|
73
|
+
assert second.other == 1
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_subclass_can_assign_fields_in_its_initializer():
|
|
77
|
+
class Initialized(Counter):
|
|
78
|
+
def __init__(self, value: str):
|
|
79
|
+
self.value = value
|
|
80
|
+
|
|
81
|
+
assert Initialized("5").value == 5
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_converter_decorator():
|
|
85
|
+
class Decorated(Observable):
|
|
86
|
+
value: ObservableField[int, str] = auto_field(default=0)
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
@value.converter
|
|
90
|
+
def parse(value: str) -> int:
|
|
91
|
+
return int(value) + 10
|
|
92
|
+
|
|
93
|
+
instance = Decorated()
|
|
94
|
+
instance.value = "2"
|
|
95
|
+
assert instance.value == 12
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_custom_registry_and_automatic_default():
|
|
99
|
+
registry = AnnotationsRegistry()
|
|
100
|
+
registry.register_converter(int, lambda value: int(value) + 10)
|
|
101
|
+
|
|
102
|
+
class Custom(Observable):
|
|
103
|
+
values = ObservableField[list[int], list[str]](
|
|
104
|
+
default=Auto, custom_annotation_registry=registry
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
instance = Custom()
|
|
108
|
+
assert instance.values == []
|
|
109
|
+
instance.values = ["1", "2"]
|
|
110
|
+
assert instance.values == [11, 12]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_read_and_write_access_policies():
|
|
114
|
+
class Restricted(Observable):
|
|
115
|
+
read_only = ObservableField[int](default=1, access=Access.read)
|
|
116
|
+
write_only = ObservableField[int](access=Access.write)
|
|
117
|
+
|
|
118
|
+
instance = Restricted()
|
|
119
|
+
assert instance.read_only == 1
|
|
120
|
+
with pytest.raises(WriteAccessViolationError):
|
|
121
|
+
instance.read_only = 2
|
|
122
|
+
instance.write_only = 3
|
|
123
|
+
with pytest.raises(ReadAccessViolationError):
|
|
124
|
+
_ = instance.write_only
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_uninitialized_field():
|
|
128
|
+
class Pending(Observable):
|
|
129
|
+
value = ObservableField[int]()
|
|
130
|
+
|
|
131
|
+
with pytest.raises(UninitializedError):
|
|
132
|
+
_ = Pending().value
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_observer_removal_and_copied_subscriptions():
|
|
136
|
+
counter = Counter()
|
|
137
|
+
events: list[str] = []
|
|
138
|
+
observer = counter.register_observer(Counter.value, events.append)
|
|
139
|
+
counter.get_observers(Counter.value).clear()
|
|
140
|
+
counter.value = "1"
|
|
141
|
+
counter.remove_observer(Counter.value, observer)
|
|
142
|
+
counter.value = "2"
|
|
143
|
+
assert events == ["1"]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Exercise default annotation behavior without the future annotations import."""
|
|
2
|
+
|
|
3
|
+
from decimal import Decimal
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from observable.observable import Auto, Observable, ObservableField, auto_field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_default_annotations_resolve_global_and_class_local_types():
|
|
12
|
+
class Model(Observable):
|
|
13
|
+
Scalar = Decimal
|
|
14
|
+
value: ObservableField[Scalar, str] = auto_field(default=Auto)
|
|
15
|
+
directory: ObservableField[Path, str] = auto_field(default=Auto)
|
|
16
|
+
|
|
17
|
+
instance = Model()
|
|
18
|
+
assert instance.value == Decimal(0)
|
|
19
|
+
events = []
|
|
20
|
+
instance.register_observer(Model.value, events.append)
|
|
21
|
+
instance.value = "1.25"
|
|
22
|
+
instance.directory = "documents"
|
|
23
|
+
assert instance.value == Decimal("1.25")
|
|
24
|
+
assert instance.directory == Path("documents")
|
|
25
|
+
assert events == ["1.25"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_default_annotations_support_quoted_fields_and_inheritance():
|
|
29
|
+
class Parent(Observable):
|
|
30
|
+
value: "ObservableField[int, str]" = auto_field(default=0)
|
|
31
|
+
|
|
32
|
+
class Child(Parent):
|
|
33
|
+
other: ObservableField[int, str] = auto_field(default=1)
|
|
34
|
+
|
|
35
|
+
first, second = Child(), Child()
|
|
36
|
+
first.value = "2"
|
|
37
|
+
first.other = "3"
|
|
38
|
+
assert (first.value, first.other) == (2, 3)
|
|
39
|
+
assert (second.value, second.other) == (0, 1)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_missing_annotations_still_fail_with_a_clear_error():
|
|
43
|
+
with pytest.raises(TypeError, match="must have a type annotation"):
|
|
44
|
+
|
|
45
|
+
class Missing(Observable):
|
|
46
|
+
value = auto_field(default=0)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Ensure incorrect consumer code is rejected by the inline annotations."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import metacore
|
|
9
|
+
from observable import observable as implementation
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_typing_rejects_incorrect_defaults_writes_reads_and_callbacks(tmp_path):
|
|
13
|
+
text = (Path(__file__).parent / "typing/invalid.py.txt").read_text(encoding="utf-8")
|
|
14
|
+
sample = tmp_path / "invalid.py"
|
|
15
|
+
sample.write_text(text, encoding="utf-8")
|
|
16
|
+
config = tmp_path / "pyrightconfig.json"
|
|
17
|
+
config.write_text(
|
|
18
|
+
json.dumps(
|
|
19
|
+
{
|
|
20
|
+
"typeCheckingMode": "standard",
|
|
21
|
+
"pythonVersion": "3.13",
|
|
22
|
+
"extraPaths": [
|
|
23
|
+
str(Path(implementation.__file__).resolve().parents[1]),
|
|
24
|
+
str(Path(metacore.__file__).resolve().parents[1]),
|
|
25
|
+
],
|
|
26
|
+
}
|
|
27
|
+
),
|
|
28
|
+
encoding="utf-8",
|
|
29
|
+
)
|
|
30
|
+
result = subprocess.run(
|
|
31
|
+
[
|
|
32
|
+
sys.executable,
|
|
33
|
+
"-m",
|
|
34
|
+
"pyright",
|
|
35
|
+
"--project",
|
|
36
|
+
str(config),
|
|
37
|
+
"--pythonpath",
|
|
38
|
+
sys.executable,
|
|
39
|
+
"--outputjson",
|
|
40
|
+
str(sample),
|
|
41
|
+
],
|
|
42
|
+
capture_output=True,
|
|
43
|
+
text=True,
|
|
44
|
+
timeout=60,
|
|
45
|
+
)
|
|
46
|
+
diagnostics = json.loads(result.stdout)
|
|
47
|
+
errors = [
|
|
48
|
+
item
|
|
49
|
+
for item in diagnostics["generalDiagnostics"]
|
|
50
|
+
if item["severity"] == "error"
|
|
51
|
+
]
|
|
52
|
+
expected_lines = {
|
|
53
|
+
index
|
|
54
|
+
for index, line in enumerate(text.splitlines())
|
|
55
|
+
if "# expected-error" in line
|
|
56
|
+
}
|
|
57
|
+
assert result.returncode == 1, result.stdout + result.stderr
|
|
58
|
+
assert {item["range"]["start"]["line"] for item in errors} == expected_lines, errors
|
|
59
|
+
assert len(errors) == len(expected_lines), errors
|
|
60
|
+
assert diagnostics["summary"]["warningCount"] == 0, diagnostics
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from observable.observable import Observable, ObservableField, auto_field
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Model(Observable):
|
|
5
|
+
value: ObservableField[int, str] = auto_field(default=0)
|
|
6
|
+
bad_default: ObservableField[int, str] = auto_field(default="wrong") # expected-error
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
model = Model()
|
|
10
|
+
model.value = 42 # expected-error
|
|
11
|
+
wrong_read: str = model.value # expected-error
|
|
12
|
+
model.set_val(Model.value, 42) # expected-error
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def wrong_converter(value: str) -> str:
|
|
16
|
+
return value
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
Model.value.converter(wrong_converter) # expected-error
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Examples checked against the inline type annotations; not executed as tests."""
|
|
2
|
+
|
|
3
|
+
from typing import Callable, assert_type
|
|
4
|
+
|
|
5
|
+
from observable.observable import (
|
|
6
|
+
Auto,
|
|
7
|
+
AutoField,
|
|
8
|
+
Observable,
|
|
9
|
+
ObservableField,
|
|
10
|
+
auto_field,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Model(Observable):
|
|
15
|
+
value: ObservableField[int, str] = auto_field(default=0)
|
|
16
|
+
automatic: ObservableField[int, str] = auto_field(default=Auto)
|
|
17
|
+
numbers: ObservableField[list[int], list[str]] = auto_field(default_factory=list)
|
|
18
|
+
|
|
19
|
+
@staticmethod
|
|
20
|
+
@value.converter
|
|
21
|
+
def parse(value: str) -> int:
|
|
22
|
+
return int(value)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def check_types(model: Model) -> None:
|
|
26
|
+
assert_type(Model.value, ObservableField[int, str])
|
|
27
|
+
assert_type(model.value, int)
|
|
28
|
+
assert_type(model.automatic, int)
|
|
29
|
+
assert_type(model.numbers, list[int])
|
|
30
|
+
assert_type(model.get_val(Model.value), int)
|
|
31
|
+
assert_type(model.F, type[Model])
|
|
32
|
+
model.value = "42"
|
|
33
|
+
model.numbers = ["1", "2"]
|
|
34
|
+
model.set_val(Model.value, "3")
|
|
35
|
+
|
|
36
|
+
def accept_converter(converter: Callable[[str], int]) -> None:
|
|
37
|
+
_ = converter
|
|
38
|
+
|
|
39
|
+
accept_converter(Model.value.converter(int))
|
|
40
|
+
config = AutoField[int, str](default=0)
|
|
41
|
+
assert_type(config.as_observable_field(), ObservableField[int, str])
|
|
42
|
+
field: ObservableField[int, str] = auto_field(default=0)
|
|
43
|
+
assert_type(field, ObservableField[int, str])
|