brainlessdb 0.17.3__tar.gz → 1.0.0__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.
- brainlessdb-1.0.0/.python-version +1 -0
- {brainlessdb-0.17.3 → brainlessdb-1.0.0}/CHANGELOG.md +30 -0
- {brainlessdb-0.17.3 → brainlessdb-1.0.0}/PKG-INFO +31 -2
- {brainlessdb-0.17.3 → brainlessdb-1.0.0}/README.md +29 -0
- {brainlessdb-0.17.3 → brainlessdb-1.0.0}/brainlessdb/__init__.py +4 -1
- {brainlessdb-0.17.3 → brainlessdb-1.0.0}/brainlessdb/collection.py +22 -0
- brainlessdb-1.0.0/brainlessdb/ingest.py +218 -0
- {brainlessdb-0.17.3 → brainlessdb-1.0.0}/brainlessdb/registry.py +7 -0
- {brainlessdb-0.17.3 → brainlessdb-1.0.0}/brainlessdb/struct.py +46 -9
- {brainlessdb-0.17.3 → brainlessdb-1.0.0}/pyproject.toml +1 -1
- brainlessdb-1.0.0/tests/test_ingest.py +175 -0
- {brainlessdb-0.17.3 → brainlessdb-1.0.0}/tests/test_registry.py +15 -0
- {brainlessdb-0.17.3 → brainlessdb-1.0.0}/uv.lock +23 -23
- brainlessdb-0.17.3/.python-version +0 -1
- {brainlessdb-0.17.3 → brainlessdb-1.0.0}/.gitignore +0 -0
- {brainlessdb-0.17.3 → brainlessdb-1.0.0}/brainlessdb/bucket.py +0 -0
- {brainlessdb-0.17.3 → brainlessdb-1.0.0}/brainlessdb/client.py +0 -0
- {brainlessdb-0.17.3 → brainlessdb-1.0.0}/brainlessdb/fields.py +0 -0
- {brainlessdb-0.17.3 → brainlessdb-1.0.0}/cliff.toml +0 -0
- {brainlessdb-0.17.3 → brainlessdb-1.0.0}/code_style_guide.md +0 -0
- {brainlessdb-0.17.3 → brainlessdb-1.0.0}/justfile +0 -0
- {brainlessdb-0.17.3 → brainlessdb-1.0.0}/test_results.txt +0 -0
- {brainlessdb-0.17.3 → brainlessdb-1.0.0}/tests/__init__.py +0 -0
- {brainlessdb-0.17.3 → brainlessdb-1.0.0}/tests/conftest.py +0 -0
- {brainlessdb-0.17.3 → brainlessdb-1.0.0}/tests/mock_nats.py +0 -0
- {brainlessdb-0.17.3 → brainlessdb-1.0.0}/tests/test_bucket.py +0 -0
- {brainlessdb-0.17.3 → brainlessdb-1.0.0}/tests/test_client.py +0 -0
- {brainlessdb-0.17.3 → brainlessdb-1.0.0}/tests/test_collection.py +0 -0
- {brainlessdb-0.17.3 → brainlessdb-1.0.0}/tests/test_dead_letter.py +0 -0
- {brainlessdb-0.17.3 → brainlessdb-1.0.0}/tests/test_deserialization.py +0 -0
- {brainlessdb-0.17.3 → brainlessdb-1.0.0}/tests/test_duplicates.py +0 -0
- {brainlessdb-0.17.3 → brainlessdb-1.0.0}/tests/test_fields.py +0 -0
- {brainlessdb-0.17.3 → brainlessdb-1.0.0}/tests/test_flush_errors.py +0 -0
- {brainlessdb-0.17.3 → brainlessdb-1.0.0}/tests/test_load_errors.py +0 -0
- {brainlessdb-0.17.3 → brainlessdb-1.0.0}/tests/test_sync.py +0 -0
- {brainlessdb-0.17.3 → brainlessdb-1.0.0}/tests/test_thread_safety.py +0 -0
- {brainlessdb-0.17.3 → brainlessdb-1.0.0}/tests/test_update_atomic.py +0 -0
- {brainlessdb-0.17.3 → brainlessdb-1.0.0}/tests/test_version.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.11
|
|
@@ -3,6 +3,36 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
5
|
|
|
6
|
+
## [1.0.0] - 2026-11-03
|
|
7
|
+
|
|
8
|
+
### 🚀 Features
|
|
9
|
+
|
|
10
|
+
- Ingest() to store external data with invalid values repaired
|
|
11
|
+
- add(), update() and save() raise on any invalid value, so one bad column
|
|
12
|
+
from SQL dropped the whole item. Collection.ingest(data, key),
|
|
13
|
+
BrainlessBucket.ingest(data) and BrainlessRegistry.ingest(data) repair
|
|
14
|
+
instead: an invalid value keeps the current one, a new item gets the field
|
|
15
|
+
default, invalid list items and dict entries are removed. Values are
|
|
16
|
+
coerced like msgspec.convert(strict=False).
|
|
17
|
+
|
|
18
|
+
Repairs are returned (path, action, error) without the invalid value,
|
|
19
|
+
which may be a secret. Struct types are walked directly, so msgspec error
|
|
20
|
+
messages are not parsed. List items are not matched to current ones yet.
|
|
21
|
+
|
|
22
|
+
### ⚙️ Miscellaneous Tasks
|
|
23
|
+
|
|
24
|
+
- [**breaking**] Change minimal required Python version from 3.9 to 3.11
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
## [0.17.4] - 2026-11-03
|
|
28
|
+
|
|
29
|
+
### 🐛 Bug Fixes
|
|
30
|
+
|
|
31
|
+
- Validate registry on save so invalid values are never flushed
|
|
32
|
+
- Plain attribute assignment is not type-checked by msgspec, so e.g. None in
|
|
33
|
+
a non-Optional field was flushed and every reader dead-lettered the entry.
|
|
34
|
+
|
|
35
|
+
|
|
6
36
|
## [0.17.3] - 2026-10-21
|
|
7
37
|
|
|
8
38
|
### 🐛 Bug Fixes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: brainlessdb
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 1.0.0
|
|
4
4
|
Summary: Typed collections backed by NATS JetStream KV
|
|
5
5
|
Author-email: "INSOFT s.r.o." <helpdesk@insoft.cz>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -205,6 +205,35 @@ for item in coll: ...
|
|
|
205
205
|
item in coll
|
|
206
206
|
```
|
|
207
207
|
|
|
208
|
+
## Ingest
|
|
209
|
+
|
|
210
|
+
For data from outside (SQL rows, config files) that may not match the struct.
|
|
211
|
+
Where `add()`, `update()` and `save()` raise on an invalid value, `ingest()`
|
|
212
|
+
repairs it and reports what it did:
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
# add or update item found by a UNIQUE field
|
|
216
|
+
item, repairs = coll.ingest(row, key="id")
|
|
217
|
+
|
|
218
|
+
# partial update of an item, like update()
|
|
219
|
+
repairs = item.ingest({"vlan": row["vlan"]})
|
|
220
|
+
|
|
221
|
+
# registry
|
|
222
|
+
repairs = cfg.ingest({"port": "5070"}) # coerced to 5070
|
|
223
|
+
|
|
224
|
+
for r in repairs:
|
|
225
|
+
log.warning("%s %s: %s", ".".join(map(str, r.path)), r.action, r.error)
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
An invalid value keeps the current one (`kept`), or uses the field default on
|
|
229
|
+
a new item (`default`). Invalid list items and dict entries are removed
|
|
230
|
+
(`removed`); list items are not matched to current ones by position. Values
|
|
231
|
+
are coerced like `msgspec.convert(strict=False)`, e.g. `"5070"` to `5070`.
|
|
232
|
+
A repair records the path and error, never the invalid value, which may be a
|
|
233
|
+
password. Data that can't be repaired (an invalid required field of a new
|
|
234
|
+
item) raises `msgspec.ValidationError`, unique violations raise
|
|
235
|
+
`UniqueConstraintError` as usual.
|
|
236
|
+
|
|
208
237
|
## Filtering
|
|
209
238
|
|
|
210
239
|
All filter/find methods are **sync** (operate on in-memory data):
|
|
@@ -194,6 +194,35 @@ for item in coll: ...
|
|
|
194
194
|
item in coll
|
|
195
195
|
```
|
|
196
196
|
|
|
197
|
+
## Ingest
|
|
198
|
+
|
|
199
|
+
For data from outside (SQL rows, config files) that may not match the struct.
|
|
200
|
+
Where `add()`, `update()` and `save()` raise on an invalid value, `ingest()`
|
|
201
|
+
repairs it and reports what it did:
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
# add or update item found by a UNIQUE field
|
|
205
|
+
item, repairs = coll.ingest(row, key="id")
|
|
206
|
+
|
|
207
|
+
# partial update of an item, like update()
|
|
208
|
+
repairs = item.ingest({"vlan": row["vlan"]})
|
|
209
|
+
|
|
210
|
+
# registry
|
|
211
|
+
repairs = cfg.ingest({"port": "5070"}) # coerced to 5070
|
|
212
|
+
|
|
213
|
+
for r in repairs:
|
|
214
|
+
log.warning("%s %s: %s", ".".join(map(str, r.path)), r.action, r.error)
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
An invalid value keeps the current one (`kept`), or uses the field default on
|
|
218
|
+
a new item (`default`). Invalid list items and dict entries are removed
|
|
219
|
+
(`removed`); list items are not matched to current ones by position. Values
|
|
220
|
+
are coerced like `msgspec.convert(strict=False)`, e.g. `"5070"` to `5070`.
|
|
221
|
+
A repair records the path and error, never the invalid value, which may be a
|
|
222
|
+
password. Data that can't be repaired (an invalid required field of a new
|
|
223
|
+
item) raises `msgspec.ValidationError`, unique violations raise
|
|
224
|
+
`UniqueConstraintError` as usual.
|
|
225
|
+
|
|
197
226
|
## Filtering
|
|
198
227
|
|
|
199
228
|
All filter/find methods are **sync** (operate on in-memory data):
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
"""Brainless DB - Typed collections backed by NATS JetStream KV."""
|
|
2
2
|
|
|
3
|
-
__version__ = "0.
|
|
3
|
+
__version__ = "1.0.0"
|
|
4
4
|
|
|
5
5
|
from typing import Any, Optional, TypeVar
|
|
6
6
|
|
|
7
7
|
from .bucket import ShortReadError
|
|
8
8
|
from .client import BrainlessDB
|
|
9
9
|
from .collection import Collection, HasUUID
|
|
10
|
+
from .ingest import IngestResult, Repair
|
|
10
11
|
from .registry import Registry
|
|
11
12
|
from .struct import (
|
|
12
13
|
AppAccessError,
|
|
@@ -27,6 +28,8 @@ __all__ = [
|
|
|
27
28
|
"Collection",
|
|
28
29
|
"Registry",
|
|
29
30
|
"HasUUID",
|
|
31
|
+
"IngestResult",
|
|
32
|
+
"Repair",
|
|
30
33
|
"AppAccessError",
|
|
31
34
|
"ShortReadError",
|
|
32
35
|
"UniqueConstraintError",
|
|
@@ -12,6 +12,7 @@ import msgspec
|
|
|
12
12
|
|
|
13
13
|
from .bucket import Bucket
|
|
14
14
|
from .fields import analyze_fields
|
|
15
|
+
from .ingest import IngestResult, repair
|
|
15
16
|
from .struct import (
|
|
16
17
|
BrainlessBucket,
|
|
17
18
|
ConfigWrapper,
|
|
@@ -512,6 +513,27 @@ class Collection(Generic[T]):
|
|
|
512
513
|
self._fire_change(old, obj, local=True)
|
|
513
514
|
return obj
|
|
514
515
|
|
|
516
|
+
def ingest(self, data: dict[str, Any], key: str) -> IngestResult:
|
|
517
|
+
"""Add or update item found by unique field `key`, repairing invalid values.
|
|
518
|
+
|
|
519
|
+
Update works like item.ingest(): an invalid value keeps the current one.
|
|
520
|
+
A new item gets the field default instead. Inside lists and dicts the
|
|
521
|
+
invalid item is removed. Raises msgspec.ValidationError when data can't be
|
|
522
|
+
repaired, e.g. a new item with invalid required field.
|
|
523
|
+
"""
|
|
524
|
+
if key not in self._analysis.unique_fields:
|
|
525
|
+
raise ValueError(f"{self._cls.__name__}.{key} is not a unique field")
|
|
526
|
+
if key not in data:
|
|
527
|
+
raise ValueError(f"Missing key field {key!r} in data")
|
|
528
|
+
|
|
529
|
+
item = self.find(**{key: data[key]})
|
|
530
|
+
if item is not None:
|
|
531
|
+
repairs = item.ingest(data)
|
|
532
|
+
return IngestResult(self.get(str(item.uuid)), repairs)
|
|
533
|
+
|
|
534
|
+
fixed, repairs = repair(data, self._cls)
|
|
535
|
+
return IngestResult(self.add(fixed), repairs)
|
|
536
|
+
|
|
515
537
|
def get(self, uuid: str) -> Optional[T]:
|
|
516
538
|
return self._entities.get(uuid)
|
|
517
539
|
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Repair of external data so it fits a struct, for ingest()."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, Any, NamedTuple, Optional, Union, get_args, get_origin
|
|
4
|
+
|
|
5
|
+
import msgspec
|
|
6
|
+
from msgspec import Struct
|
|
7
|
+
|
|
8
|
+
Path = tuple[Union[str, int], ...]
|
|
9
|
+
|
|
10
|
+
_MISSING = object()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Repair(Struct, frozen=True):
|
|
14
|
+
"""One value ingest() could not store as given.
|
|
15
|
+
|
|
16
|
+
action is "kept" (current value kept), "default" (field default used) or
|
|
17
|
+
"removed" (list item or dict entry left out). The invalid value itself is
|
|
18
|
+
not recorded, it may be a password or key.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
path: Path
|
|
22
|
+
action: str
|
|
23
|
+
error: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class IngestResult(NamedTuple):
|
|
27
|
+
"""Stored item and repairs made to store it."""
|
|
28
|
+
|
|
29
|
+
item: Any
|
|
30
|
+
repairs: list[Repair]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _Unrepairable(Exception):
|
|
34
|
+
"""Value can't be repaired at its level, the level above has to drop it."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, error: str) -> None:
|
|
37
|
+
super().__init__(error)
|
|
38
|
+
self.error = error
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _check(value: Any, tp: Any) -> Optional[str]:
|
|
42
|
+
"""Validation error message, None when value is valid."""
|
|
43
|
+
try:
|
|
44
|
+
msgspec.convert(value, tp, strict=False)
|
|
45
|
+
except msgspec.ValidationError as ex:
|
|
46
|
+
return str(ex)
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _unwrap(tp: Any) -> list[Any]:
|
|
51
|
+
"""Candidate types of a hint: Annotated removed, Union split, None left out."""
|
|
52
|
+
if get_origin(tp) is Annotated:
|
|
53
|
+
tp = get_args(tp)[0]
|
|
54
|
+
if get_origin(tp) is Union or type(tp).__name__ == "UnionType":
|
|
55
|
+
result = []
|
|
56
|
+
for arg in get_args(tp):
|
|
57
|
+
result.extend(_unwrap(arg))
|
|
58
|
+
return result
|
|
59
|
+
return [] if tp is type(None) else [tp]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _struct_for(candidates: list[Any], value: dict) -> Optional[type]:
|
|
63
|
+
"""Struct type the dict value belongs to, using the tag for tagged unions."""
|
|
64
|
+
structs = [c for c in candidates if isinstance(c, type) and issubclass(c, Struct)]
|
|
65
|
+
for cls in structs:
|
|
66
|
+
config = cls.__struct_config__
|
|
67
|
+
if config.tag is not None and value.get(config.tag_field) == config.tag:
|
|
68
|
+
return cls
|
|
69
|
+
return structs[0] if len(structs) == 1 else None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _repair_value(value: Any, tp: Any, current: Any, path: Path, repairs: list[Repair]) -> Any:
|
|
73
|
+
"""Repaired value, raises _Unrepairable when the value has to be dropped."""
|
|
74
|
+
error = _check(value, tp)
|
|
75
|
+
if error is None:
|
|
76
|
+
return value
|
|
77
|
+
|
|
78
|
+
local: list[Repair] = []
|
|
79
|
+
candidates = _unwrap(tp)
|
|
80
|
+
fixed: Any = _MISSING
|
|
81
|
+
|
|
82
|
+
if isinstance(value, dict):
|
|
83
|
+
cls = _struct_for(candidates, value)
|
|
84
|
+
if cls is not None:
|
|
85
|
+
fixed = _repair_struct(value, cls, current if isinstance(current, dict) else None, path, local)
|
|
86
|
+
else:
|
|
87
|
+
dict_type = next((c for c in candidates if get_origin(c) is dict), None)
|
|
88
|
+
if dict_type is not None:
|
|
89
|
+
fixed = _repair_dict(value, dict_type, path, local)
|
|
90
|
+
elif isinstance(value, list):
|
|
91
|
+
list_type = next((c for c in candidates if _item_type(c) is not None), None)
|
|
92
|
+
if list_type is not None:
|
|
93
|
+
fixed = _repair_list(value, _item_type(list_type), path, local)
|
|
94
|
+
|
|
95
|
+
if fixed is _MISSING:
|
|
96
|
+
raise _Unrepairable(error)
|
|
97
|
+
|
|
98
|
+
# constraints of the container itself, e.g. min_length, may still fail
|
|
99
|
+
error = _check(fixed, tp)
|
|
100
|
+
if error is not None:
|
|
101
|
+
raise _Unrepairable(error)
|
|
102
|
+
|
|
103
|
+
repairs.extend(local)
|
|
104
|
+
return fixed
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _item_type(tp: Any) -> Any:
|
|
108
|
+
"""Item type of list-like type, None for other types."""
|
|
109
|
+
origin = get_origin(tp)
|
|
110
|
+
args = get_args(tp)
|
|
111
|
+
if origin in (list, set, frozenset) and args:
|
|
112
|
+
return args[0]
|
|
113
|
+
if origin is tuple and len(args) == 2 and args[1] is Ellipsis:
|
|
114
|
+
return args[0]
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _repair_list(value: list, item_type: Any, path: Path, repairs: list[Repair]) -> list:
|
|
119
|
+
"""Invalid items are removed. Items are not matched to current ones, positions may differ."""
|
|
120
|
+
fixed = []
|
|
121
|
+
for index, item in enumerate(value):
|
|
122
|
+
try:
|
|
123
|
+
fixed.append(_repair_value(item, item_type, None, (*path, index), repairs))
|
|
124
|
+
except _Unrepairable as ex:
|
|
125
|
+
repairs.append(Repair((*path, index), "removed", ex.error))
|
|
126
|
+
return fixed
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _repair_dict(value: dict, dict_type: Any, path: Path, repairs: list[Repair]) -> dict:
|
|
130
|
+
"""Entries with invalid key or value are removed."""
|
|
131
|
+
key_type, value_type = get_args(dict_type)
|
|
132
|
+
fixed = {}
|
|
133
|
+
for key, item in value.items():
|
|
134
|
+
error = _check(key, key_type)
|
|
135
|
+
if error is not None:
|
|
136
|
+
repairs.append(Repair((*path, key), "removed", error))
|
|
137
|
+
continue
|
|
138
|
+
try:
|
|
139
|
+
fixed[key] = _repair_value(item, value_type, None, (*path, key), repairs)
|
|
140
|
+
except _Unrepairable as ex:
|
|
141
|
+
repairs.append(Repair((*path, key), "removed", ex.error))
|
|
142
|
+
return fixed
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _repair_struct(
|
|
146
|
+
value: dict,
|
|
147
|
+
cls: type,
|
|
148
|
+
current: Optional[dict],
|
|
149
|
+
path: Path,
|
|
150
|
+
repairs: list[Repair],
|
|
151
|
+
*,
|
|
152
|
+
partial: bool = False,
|
|
153
|
+
) -> dict:
|
|
154
|
+
"""Repair fields of struct dict.
|
|
155
|
+
|
|
156
|
+
Invalid field keeps its current value when known, otherwise it is left out so
|
|
157
|
+
its default is used. Without either the whole struct is unrepairable. With
|
|
158
|
+
partial=True fields missing in value are not required (update semantics).
|
|
159
|
+
"""
|
|
160
|
+
fixed = dict(value)
|
|
161
|
+
local: list[Repair] = []
|
|
162
|
+
for field in msgspec.structs.fields(cls):
|
|
163
|
+
key = field.encode_name
|
|
164
|
+
field_path = (*path, key)
|
|
165
|
+
known = current.get(key, _MISSING) if current is not None else _MISSING
|
|
166
|
+
|
|
167
|
+
if key not in fixed:
|
|
168
|
+
if partial or not field.required:
|
|
169
|
+
continue
|
|
170
|
+
if known is _MISSING:
|
|
171
|
+
raise _Unrepairable(f"Object missing required field `{key}`")
|
|
172
|
+
fixed[key] = known
|
|
173
|
+
local.append(Repair(field_path, "kept", f"Object missing required field `{key}`"))
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
fixed[key] = _repair_value(fixed[key], field.type, known, field_path, local)
|
|
178
|
+
except _Unrepairable as ex:
|
|
179
|
+
if known is not _MISSING:
|
|
180
|
+
fixed[key] = known
|
|
181
|
+
local.append(Repair(field_path, "kept", ex.error))
|
|
182
|
+
elif not field.required:
|
|
183
|
+
del fixed[key]
|
|
184
|
+
local.append(Repair(field_path, "default", ex.error))
|
|
185
|
+
else:
|
|
186
|
+
raise
|
|
187
|
+
|
|
188
|
+
repairs.extend(local)
|
|
189
|
+
return fixed
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def repair(
|
|
193
|
+
data: dict[str, Any],
|
|
194
|
+
cls: type,
|
|
195
|
+
current: Optional[dict[str, Any]] = None,
|
|
196
|
+
*,
|
|
197
|
+
partial: bool = False,
|
|
198
|
+
) -> tuple[dict[str, Any], list[Repair]]:
|
|
199
|
+
"""Make data valid for cls, return it converted to builtins with the repairs made.
|
|
200
|
+
|
|
201
|
+
current is the stored item as builtins: invalid values fall back to it, except
|
|
202
|
+
inside lists and dicts. Values are coerced like msgspec.convert(strict=False),
|
|
203
|
+
e.g. "5060" to 5060. Raises msgspec.ValidationError when data can't be repaired,
|
|
204
|
+
e.g. a required field without current value is invalid.
|
|
205
|
+
"""
|
|
206
|
+
repairs: list[Repair] = []
|
|
207
|
+
try:
|
|
208
|
+
fixed = _repair_struct(data, cls, current, (), repairs, partial=partial)
|
|
209
|
+
except _Unrepairable as ex:
|
|
210
|
+
raise msgspec.ValidationError(ex.error) from None
|
|
211
|
+
|
|
212
|
+
fields = {field.encode_name: field for field in msgspec.structs.fields(cls)}
|
|
213
|
+
converted = {
|
|
214
|
+
key: msgspec.to_builtins(msgspec.convert(value, fields[key].type, strict=False))
|
|
215
|
+
for key, value in fixed.items()
|
|
216
|
+
if key in fields
|
|
217
|
+
}
|
|
218
|
+
return converted, repairs
|
|
@@ -81,6 +81,13 @@ class Registry(Generic[T]):
|
|
|
81
81
|
# === Dirty / save ===
|
|
82
82
|
|
|
83
83
|
def mark_dirty(self) -> None:
|
|
84
|
+
"""Validate the instance and schedule it for flush.
|
|
85
|
+
|
|
86
|
+
Plain attribute assignment is not type-checked by msgspec, so an invalid
|
|
87
|
+
value (e.g. None for a non-Optional field) is caught here and raises
|
|
88
|
+
msgspec.ValidationError before it can be flushed and break every reader.
|
|
89
|
+
"""
|
|
90
|
+
self._apply(self._to_data())
|
|
84
91
|
self._dirty = True
|
|
85
92
|
self._client.schedule_flush()
|
|
86
93
|
|
|
@@ -10,6 +10,8 @@ from weakref import WeakValueDictionary
|
|
|
10
10
|
import msgspec
|
|
11
11
|
from msgspec import Struct, convert, field, to_builtins
|
|
12
12
|
|
|
13
|
+
from .ingest import Repair, repair
|
|
14
|
+
|
|
13
15
|
_VERSION_RE = re.compile(r"^(.+?)V(\d+)$")
|
|
14
16
|
|
|
15
17
|
|
|
@@ -157,14 +159,7 @@ class BrainlessBucket(Struct, kw_only=True, tag=True, tag_field="TYPE"):
|
|
|
157
159
|
stores its own validated copy, so re-fetch to get the stored object.
|
|
158
160
|
"""
|
|
159
161
|
fields = self.__struct_fields__
|
|
160
|
-
merged = to_builtins(self)
|
|
161
|
-
for key, value in data.items():
|
|
162
|
-
if key not in fields:
|
|
163
|
-
continue
|
|
164
|
-
current = getattr(self, key)
|
|
165
|
-
if isinstance(value, dict) and hasattr(current, "__struct_fields__"):
|
|
166
|
-
value = {**to_builtins(current), **value}
|
|
167
|
-
merged[key] = to_builtins(value)
|
|
162
|
+
merged = {**to_builtins(self), **_merge_nested(self, data)}
|
|
168
163
|
new = convert(merged, type(self))
|
|
169
164
|
collection = _registry.get(self._uuid)
|
|
170
165
|
if collection is not None:
|
|
@@ -173,6 +168,31 @@ class BrainlessBucket(Struct, kw_only=True, tag=True, tag_field="TYPE"):
|
|
|
173
168
|
setattr(self, f, getattr(new, f))
|
|
174
169
|
return self
|
|
175
170
|
|
|
171
|
+
def ingest(self, data: dict) -> list[Repair]:
|
|
172
|
+
"""Like update(), but repair invalid values instead of raising.
|
|
173
|
+
|
|
174
|
+
An invalid value keeps the current one; inside lists and dicts the invalid
|
|
175
|
+
item is removed (items are not matched by position). Values are coerced,
|
|
176
|
+
e.g. "5060" to 5060. Returns the repairs made. Raises like update() when
|
|
177
|
+
data can't be repaired or violates a unique constraint.
|
|
178
|
+
"""
|
|
179
|
+
fixed, repairs = repair(_merge_nested(self, data), type(self), to_builtins(self), partial=True)
|
|
180
|
+
self.update(fixed)
|
|
181
|
+
return repairs
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _merge_nested(obj: Struct, data: dict) -> dict:
|
|
185
|
+
"""Builtins of data keys which are fields of obj, dicts merged into nested structs."""
|
|
186
|
+
result = {}
|
|
187
|
+
for key, value in data.items():
|
|
188
|
+
if key not in obj.__struct_fields__:
|
|
189
|
+
continue
|
|
190
|
+
current = getattr(obj, key)
|
|
191
|
+
if isinstance(value, dict) and hasattr(current, "__struct_fields__"):
|
|
192
|
+
value = {**to_builtins(current), **value}
|
|
193
|
+
result[key] = to_builtins(value)
|
|
194
|
+
return result
|
|
195
|
+
|
|
176
196
|
|
|
177
197
|
# Fixed namespace for deriving registry storage keys via uuid5.
|
|
178
198
|
_REGISTRY_NS = uuid5(NAMESPACE_DNS, "brainlessdb.registry")
|
|
@@ -204,8 +224,25 @@ class BrainlessRegistry(Struct, kw_only=True, tag=True, tag_field="TYPE", dict=T
|
|
|
204
224
|
def uuid(self) -> UUID:
|
|
205
225
|
return uuid5(_REGISTRY_NS, type(self).__key__)
|
|
206
226
|
|
|
227
|
+
def ingest(self, data: dict) -> list[Repair]:
|
|
228
|
+
"""Set fields from data and save, repairing invalid values instead of raising.
|
|
229
|
+
|
|
230
|
+
An invalid value keeps the current one. Values are coerced, e.g. "5060"
|
|
231
|
+
to 5060. Returns the repairs made.
|
|
232
|
+
"""
|
|
233
|
+
current = to_builtins(self)
|
|
234
|
+
fixed, repairs = repair(_merge_nested(self, data), type(self), current, partial=True)
|
|
235
|
+
new = convert({**current, **fixed}, type(self))
|
|
236
|
+
for f in self.__struct_fields__:
|
|
237
|
+
setattr(self, f, getattr(new, f))
|
|
238
|
+
self.save()
|
|
239
|
+
return repairs
|
|
240
|
+
|
|
207
241
|
def save(self) -> None:
|
|
208
|
-
"""
|
|
242
|
+
"""Validate and mark this registry entry dirty in its Registry.
|
|
243
|
+
|
|
244
|
+
Raises msgspec.ValidationError when a field holds a value of wrong type.
|
|
245
|
+
"""
|
|
209
246
|
owner_ref = getattr(self, "_owner", None)
|
|
210
247
|
owner = owner_ref() if owner_ref is not None else None
|
|
211
248
|
if owner is not None:
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Test ingest(): storing external data with invalid values repaired."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, Optional, Union
|
|
4
|
+
|
|
5
|
+
import msgspec
|
|
6
|
+
import pytest
|
|
7
|
+
from msgspec import Meta
|
|
8
|
+
|
|
9
|
+
from brainlessdb import BrainlessBucket, BrainlessDB, BrainlessDBFeat, BrainlessStruct, Repair
|
|
10
|
+
from tests.conftest import SipConfigV1
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PeerV1(BrainlessStruct):
|
|
14
|
+
port: Annotated[int, Meta(gt=0, lt=65536)] = 5060
|
|
15
|
+
addr: Optional[str] = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SpeedDialV1(BrainlessStruct):
|
|
19
|
+
pos: int
|
|
20
|
+
number: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ExtensionLineV1(BrainlessStruct):
|
|
24
|
+
pos: int
|
|
25
|
+
peer: PeerV1 = msgspec.field(default_factory=PeerV1)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PhoneV1(BrainlessBucket, kw_only=True):
|
|
29
|
+
@staticmethod
|
|
30
|
+
def allows_app(app: str) -> bool:
|
|
31
|
+
return True
|
|
32
|
+
|
|
33
|
+
id: Annotated[int, Meta(extra={"brainlessdb_flags": BrainlessDBFeat.UNIQUE})]
|
|
34
|
+
name: str
|
|
35
|
+
mac: Optional[Annotated[str, Meta(pattern="^[0-9A-F]{12}$")]] = None
|
|
36
|
+
vlan: Annotated[int, Meta(ge=0, lt=4096)] = 0
|
|
37
|
+
nat: bool = False
|
|
38
|
+
peer: PeerV1 = msgspec.field(default_factory=PeerV1)
|
|
39
|
+
lines: list[Union[SpeedDialV1, ExtensionLineV1]] = []
|
|
40
|
+
data: dict[str, int] = {}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.fixture
|
|
44
|
+
async def phones():
|
|
45
|
+
db = BrainlessDB(namespace="test")
|
|
46
|
+
coll = db.collection(PhoneV1)
|
|
47
|
+
await db.start()
|
|
48
|
+
return coll
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def actions(repairs: list[Repair]) -> dict[tuple, str]:
|
|
52
|
+
return {r.path: r.action for r in repairs}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TestCollectionIngestAdd:
|
|
56
|
+
@pytest.mark.asyncio
|
|
57
|
+
async def test_valid_data_has_no_repairs(self, phones):
|
|
58
|
+
item, repairs = phones.ingest({"id": 1, "name": "a", "vlan": 10}, key="id")
|
|
59
|
+
assert repairs == []
|
|
60
|
+
assert item.vlan == 10
|
|
61
|
+
assert phones.find(id=1) is item
|
|
62
|
+
|
|
63
|
+
@pytest.mark.asyncio
|
|
64
|
+
async def test_invalid_field_gets_default(self, phones):
|
|
65
|
+
item, repairs = phones.ingest({"id": 1, "name": "a", "mac": "nope", "vlan": 5000}, key="id")
|
|
66
|
+
assert item.mac is None
|
|
67
|
+
assert item.vlan == 0
|
|
68
|
+
assert actions(repairs) == {("mac",): "default", ("vlan",): "default"}
|
|
69
|
+
|
|
70
|
+
@pytest.mark.asyncio
|
|
71
|
+
async def test_values_are_coerced(self, phones):
|
|
72
|
+
item, repairs = phones.ingest({"id": "1", "name": "a", "vlan": "10", "nat": "true"}, key="id")
|
|
73
|
+
assert (item.id, item.vlan, item.nat) == (1, 10, True)
|
|
74
|
+
assert repairs == []
|
|
75
|
+
|
|
76
|
+
@pytest.mark.asyncio
|
|
77
|
+
async def test_invalid_list_item_is_removed(self, phones):
|
|
78
|
+
lines = [
|
|
79
|
+
{"TYPE": "SpeedDialV1", "pos": 0, "number": "100"},
|
|
80
|
+
{"TYPE": "SpeedDialV1", "pos": 1}, # missing required number
|
|
81
|
+
{"TYPE": "ExtensionLineV1", "pos": 2, "peer": {"port": 0}},
|
|
82
|
+
]
|
|
83
|
+
item, repairs = phones.ingest({"id": 1, "name": "a", "lines": lines}, key="id")
|
|
84
|
+
assert [line.pos for line in item.lines] == [0, 2]
|
|
85
|
+
assert item.lines[1].peer.port == 5060
|
|
86
|
+
assert actions(repairs) == {("lines", 1): "removed", ("lines", 2, "peer", "port"): "default"}
|
|
87
|
+
|
|
88
|
+
@pytest.mark.asyncio
|
|
89
|
+
async def test_invalid_dict_entry_is_removed(self, phones):
|
|
90
|
+
item, repairs = phones.ingest({"id": 1, "name": "a", "data": {"a": 1, "b": "x"}}, key="id")
|
|
91
|
+
assert item.data == {"a": 1}
|
|
92
|
+
assert actions(repairs) == {("data", "b"): "removed"}
|
|
93
|
+
|
|
94
|
+
@pytest.mark.asyncio
|
|
95
|
+
async def test_invalid_required_field_raises(self, phones):
|
|
96
|
+
with pytest.raises(msgspec.ValidationError):
|
|
97
|
+
phones.ingest({"id": 1, "name": None}, key="id")
|
|
98
|
+
assert len(phones) == 0
|
|
99
|
+
|
|
100
|
+
@pytest.mark.asyncio
|
|
101
|
+
async def test_repair_does_not_record_value(self, phones):
|
|
102
|
+
_, repairs = phones.ingest({"id": 1, "name": "a", "mac": "secret-value"}, key="id")
|
|
103
|
+
assert "secret-value" not in repr(repairs)
|
|
104
|
+
|
|
105
|
+
@pytest.mark.asyncio
|
|
106
|
+
async def test_input_is_not_mutated(self, phones):
|
|
107
|
+
data = {"id": 1, "name": "a", "mac": "nope", "lines": [{"TYPE": "SpeedDialV1", "pos": 1}]}
|
|
108
|
+
phones.ingest(data, key="id")
|
|
109
|
+
assert data == {"id": 1, "name": "a", "mac": "nope", "lines": [{"TYPE": "SpeedDialV1", "pos": 1}]}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class TestCollectionIngestUpdate:
|
|
113
|
+
@pytest.mark.asyncio
|
|
114
|
+
async def test_invalid_field_keeps_current(self, phones):
|
|
115
|
+
phones.ingest({"id": 1, "name": "a", "vlan": 10, "mac": "AABBCCDDEEFF"}, key="id")
|
|
116
|
+
item, repairs = phones.ingest({"id": 1, "name": None, "vlan": "x", "nat": True}, key="id")
|
|
117
|
+
assert (item.name, item.vlan, item.nat, item.mac) == ("a", 10, True, "AABBCCDDEEFF")
|
|
118
|
+
assert actions(repairs) == {("name",): "kept", ("vlan",): "kept"}
|
|
119
|
+
|
|
120
|
+
@pytest.mark.asyncio
|
|
121
|
+
async def test_nested_struct_keeps_current(self, phones):
|
|
122
|
+
phones.ingest({"id": 1, "name": "a", "peer": {"port": 5070, "addr": "10.0.0.1"}}, key="id")
|
|
123
|
+
item, repairs = phones.ingest({"id": 1, "peer": {"port": -1, "addr": "10.0.0.2"}}, key="id")
|
|
124
|
+
assert (item.peer.port, item.peer.addr) == (5070, "10.0.0.2")
|
|
125
|
+
assert actions(repairs) == {("peer", "port"): "kept"}
|
|
126
|
+
|
|
127
|
+
@pytest.mark.asyncio
|
|
128
|
+
async def test_returns_stored_item(self, phones):
|
|
129
|
+
phones.ingest({"id": 1, "name": "a"}, key="id")
|
|
130
|
+
item, _ = phones.ingest({"id": 1, "name": "b"}, key="id")
|
|
131
|
+
assert item is phones.find(id=1)
|
|
132
|
+
assert item.name == "b"
|
|
133
|
+
|
|
134
|
+
@pytest.mark.asyncio
|
|
135
|
+
async def test_key_must_be_unique_field(self, phones):
|
|
136
|
+
with pytest.raises(ValueError, match="unique"):
|
|
137
|
+
phones.ingest({"id": 1, "name": "a"}, key="name")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class TestItemIngest:
|
|
141
|
+
@pytest.mark.asyncio
|
|
142
|
+
async def test_partial_update_keeps_other_fields(self, phones):
|
|
143
|
+
item, _ = phones.ingest({"id": 1, "name": "a", "vlan": 10}, key="id")
|
|
144
|
+
repairs = item.ingest({"nat": True, "vlan": 99999})
|
|
145
|
+
stored = phones.find(id=1)
|
|
146
|
+
assert (stored.name, stored.vlan, stored.nat) == ("a", 10, True)
|
|
147
|
+
assert actions(repairs) == {("vlan",): "kept"}
|
|
148
|
+
|
|
149
|
+
@pytest.mark.asyncio
|
|
150
|
+
async def test_ignores_unknown_keys(self, phones):
|
|
151
|
+
item, _ = phones.ingest({"id": 1, "name": "a"}, key="id")
|
|
152
|
+
assert item.ingest({"unknown": 1}) == []
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class TestRegistryIngest:
|
|
156
|
+
@pytest.mark.asyncio
|
|
157
|
+
async def test_invalid_value_keeps_current(self):
|
|
158
|
+
db = BrainlessDB(namespace="test")
|
|
159
|
+
sip = db.registry(SipConfigV1)
|
|
160
|
+
await db.start()
|
|
161
|
+
|
|
162
|
+
repairs = sip.ingest({"host": None, "port": "5070"})
|
|
163
|
+
assert (sip.host, sip.port) == ("127.0.0.1", 5070)
|
|
164
|
+
assert actions(repairs) == {("host",): "kept"}
|
|
165
|
+
await db.stop()
|
|
166
|
+
|
|
167
|
+
@pytest.mark.asyncio
|
|
168
|
+
async def test_marks_dirty(self):
|
|
169
|
+
db = BrainlessDB(namespace="test")
|
|
170
|
+
sip = db.registry(SipConfigV1)
|
|
171
|
+
await db.start()
|
|
172
|
+
|
|
173
|
+
sip.ingest({"port": 5070})
|
|
174
|
+
assert db._registries["sipconfigv1"]._dirty
|
|
175
|
+
await db.stop()
|
|
@@ -62,6 +62,21 @@ class TestRegistryBasics:
|
|
|
62
62
|
class TestRegistryPersistence:
|
|
63
63
|
"""Save / reload through the shared bucket."""
|
|
64
64
|
|
|
65
|
+
@pytest.mark.asyncio
|
|
66
|
+
async def test_save_rejects_invalid_value(self):
|
|
67
|
+
nats = MockNats()
|
|
68
|
+
db = BrainlessDB(nats, namespace="loc1", flush_interval=0)
|
|
69
|
+
sip = db.registry(SipConfigV1)
|
|
70
|
+
await db.start()
|
|
71
|
+
|
|
72
|
+
sip.host = None # not Optional
|
|
73
|
+
with pytest.raises(msgspec.ValidationError):
|
|
74
|
+
sip.save()
|
|
75
|
+
assert await db.flush() == 0
|
|
76
|
+
assert SipConfigV1.storage_key() not in await nats.jetstream()._buckets[REGISTRY_BUCKET].keys()
|
|
77
|
+
|
|
78
|
+
await db.stop()
|
|
79
|
+
|
|
65
80
|
@pytest.mark.asyncio
|
|
66
81
|
async def test_save_and_reload(self):
|
|
67
82
|
nats = MockNats()
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
version = 1
|
|
2
|
-
revision =
|
|
2
|
+
revision = 5
|
|
3
3
|
requires-python = ">=3.9"
|
|
4
4
|
resolution-markers = [
|
|
5
5
|
"python_full_version >= '3.10'",
|
|
@@ -17,7 +17,7 @@ wheels = [
|
|
|
17
17
|
|
|
18
18
|
[[package]]
|
|
19
19
|
name = "brainlessdb"
|
|
20
|
-
version = "0.17.
|
|
20
|
+
version = "0.17.4"
|
|
21
21
|
source = { editable = "." }
|
|
22
22
|
dependencies = [
|
|
23
23
|
{ name = "msgspec" },
|
|
@@ -60,7 +60,7 @@ name = "exceptiongroup"
|
|
|
60
60
|
version = "1.3.1"
|
|
61
61
|
source = { registry = "https://pypi.org/simple" }
|
|
62
62
|
dependencies = [
|
|
63
|
-
{ name = "typing-extensions"
|
|
63
|
+
{ name = "typing-extensions" },
|
|
64
64
|
]
|
|
65
65
|
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
|
66
66
|
wheels = [
|
|
@@ -196,13 +196,13 @@ resolution-markers = [
|
|
|
196
196
|
"python_full_version < '3.10'",
|
|
197
197
|
]
|
|
198
198
|
dependencies = [
|
|
199
|
-
{ name = "colorama", marker = "
|
|
200
|
-
{ name = "exceptiongroup"
|
|
201
|
-
{ name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }
|
|
202
|
-
{ name = "packaging"
|
|
203
|
-
{ name = "pluggy"
|
|
204
|
-
{ name = "pygments"
|
|
205
|
-
{ name = "tomli"
|
|
199
|
+
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
|
200
|
+
{ name = "exceptiongroup" },
|
|
201
|
+
{ name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" } },
|
|
202
|
+
{ name = "packaging" },
|
|
203
|
+
{ name = "pluggy" },
|
|
204
|
+
{ name = "pygments" },
|
|
205
|
+
{ name = "tomli" },
|
|
206
206
|
]
|
|
207
207
|
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
|
|
208
208
|
wheels = [
|
|
@@ -217,13 +217,13 @@ resolution-markers = [
|
|
|
217
217
|
"python_full_version >= '3.10'",
|
|
218
218
|
]
|
|
219
219
|
dependencies = [
|
|
220
|
-
{ name = "colorama", marker = "
|
|
221
|
-
{ name = "exceptiongroup", marker = "python_full_version
|
|
222
|
-
{ name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }
|
|
223
|
-
{ name = "packaging"
|
|
224
|
-
{ name = "pluggy"
|
|
225
|
-
{ name = "pygments"
|
|
226
|
-
{ name = "tomli", marker = "python_full_version
|
|
220
|
+
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
|
221
|
+
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
|
222
|
+
{ name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" } },
|
|
223
|
+
{ name = "packaging" },
|
|
224
|
+
{ name = "pluggy" },
|
|
225
|
+
{ name = "pygments" },
|
|
226
|
+
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
|
227
227
|
]
|
|
228
228
|
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
|
229
229
|
wheels = [
|
|
@@ -238,9 +238,9 @@ resolution-markers = [
|
|
|
238
238
|
"python_full_version < '3.10'",
|
|
239
239
|
]
|
|
240
240
|
dependencies = [
|
|
241
|
-
{ name = "backports-asyncio-runner"
|
|
242
|
-
{ name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }
|
|
243
|
-
{ name = "typing-extensions"
|
|
241
|
+
{ name = "backports-asyncio-runner" },
|
|
242
|
+
{ name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" } },
|
|
243
|
+
{ name = "typing-extensions" },
|
|
244
244
|
]
|
|
245
245
|
sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" }
|
|
246
246
|
wheels = [
|
|
@@ -255,9 +255,9 @@ resolution-markers = [
|
|
|
255
255
|
"python_full_version >= '3.10'",
|
|
256
256
|
]
|
|
257
257
|
dependencies = [
|
|
258
|
-
{ name = "backports-asyncio-runner", marker = "python_full_version
|
|
259
|
-
{ name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }
|
|
260
|
-
{ name = "typing-extensions", marker = "python_full_version
|
|
258
|
+
{ name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" },
|
|
259
|
+
{ name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" } },
|
|
260
|
+
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
|
261
261
|
]
|
|
262
262
|
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
|
|
263
263
|
wheels = [
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
3.9
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|