brainlessdb 0.17.4__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.
Files changed (38) hide show
  1. brainlessdb-1.0.0/.python-version +1 -0
  2. {brainlessdb-0.17.4 → brainlessdb-1.0.0}/CHANGELOG.md +21 -0
  3. {brainlessdb-0.17.4 → brainlessdb-1.0.0}/PKG-INFO +30 -1
  4. {brainlessdb-0.17.4 → brainlessdb-1.0.0}/README.md +29 -0
  5. {brainlessdb-0.17.4 → brainlessdb-1.0.0}/brainlessdb/__init__.py +4 -1
  6. {brainlessdb-0.17.4 → brainlessdb-1.0.0}/brainlessdb/collection.py +22 -0
  7. brainlessdb-1.0.0/brainlessdb/ingest.py +218 -0
  8. {brainlessdb-0.17.4 → brainlessdb-1.0.0}/brainlessdb/struct.py +42 -8
  9. {brainlessdb-0.17.4 → brainlessdb-1.0.0}/pyproject.toml +1 -1
  10. brainlessdb-1.0.0/tests/test_ingest.py +175 -0
  11. {brainlessdb-0.17.4 → brainlessdb-1.0.0}/uv.lock +1 -1
  12. brainlessdb-0.17.4/.python-version +0 -1
  13. {brainlessdb-0.17.4 → brainlessdb-1.0.0}/.gitignore +0 -0
  14. {brainlessdb-0.17.4 → brainlessdb-1.0.0}/brainlessdb/bucket.py +0 -0
  15. {brainlessdb-0.17.4 → brainlessdb-1.0.0}/brainlessdb/client.py +0 -0
  16. {brainlessdb-0.17.4 → brainlessdb-1.0.0}/brainlessdb/fields.py +0 -0
  17. {brainlessdb-0.17.4 → brainlessdb-1.0.0}/brainlessdb/registry.py +0 -0
  18. {brainlessdb-0.17.4 → brainlessdb-1.0.0}/cliff.toml +0 -0
  19. {brainlessdb-0.17.4 → brainlessdb-1.0.0}/code_style_guide.md +0 -0
  20. {brainlessdb-0.17.4 → brainlessdb-1.0.0}/justfile +0 -0
  21. {brainlessdb-0.17.4 → brainlessdb-1.0.0}/test_results.txt +0 -0
  22. {brainlessdb-0.17.4 → brainlessdb-1.0.0}/tests/__init__.py +0 -0
  23. {brainlessdb-0.17.4 → brainlessdb-1.0.0}/tests/conftest.py +0 -0
  24. {brainlessdb-0.17.4 → brainlessdb-1.0.0}/tests/mock_nats.py +0 -0
  25. {brainlessdb-0.17.4 → brainlessdb-1.0.0}/tests/test_bucket.py +0 -0
  26. {brainlessdb-0.17.4 → brainlessdb-1.0.0}/tests/test_client.py +0 -0
  27. {brainlessdb-0.17.4 → brainlessdb-1.0.0}/tests/test_collection.py +0 -0
  28. {brainlessdb-0.17.4 → brainlessdb-1.0.0}/tests/test_dead_letter.py +0 -0
  29. {brainlessdb-0.17.4 → brainlessdb-1.0.0}/tests/test_deserialization.py +0 -0
  30. {brainlessdb-0.17.4 → brainlessdb-1.0.0}/tests/test_duplicates.py +0 -0
  31. {brainlessdb-0.17.4 → brainlessdb-1.0.0}/tests/test_fields.py +0 -0
  32. {brainlessdb-0.17.4 → brainlessdb-1.0.0}/tests/test_flush_errors.py +0 -0
  33. {brainlessdb-0.17.4 → brainlessdb-1.0.0}/tests/test_load_errors.py +0 -0
  34. {brainlessdb-0.17.4 → brainlessdb-1.0.0}/tests/test_registry.py +0 -0
  35. {brainlessdb-0.17.4 → brainlessdb-1.0.0}/tests/test_sync.py +0 -0
  36. {brainlessdb-0.17.4 → brainlessdb-1.0.0}/tests/test_thread_safety.py +0 -0
  37. {brainlessdb-0.17.4 → brainlessdb-1.0.0}/tests/test_update_atomic.py +0 -0
  38. {brainlessdb-0.17.4 → brainlessdb-1.0.0}/tests/test_version.py +0 -0
@@ -0,0 +1 @@
1
+ 3.11
@@ -3,6 +3,27 @@
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
+
6
27
  ## [0.17.4] - 2026-11-03
7
28
 
8
29
  ### 🐛 Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: brainlessdb
3
- Version: 0.17.4
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.17.4"
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
@@ -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,6 +224,20 @@ 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.
209
243
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "brainlessdb"
3
- version = "0.17.4"
3
+ version = "1.0.0"
4
4
  description = "Typed collections backed by NATS JetStream KV"
5
5
  authors = [
6
6
  {name = "INSOFT s.r.o.", email = "helpdesk@insoft.cz"}
@@ -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()
@@ -17,7 +17,7 @@ wheels = [
17
17
 
18
18
  [[package]]
19
19
  name = "brainlessdb"
20
- version = "0.17.3"
20
+ version = "0.17.4"
21
21
  source = { editable = "." }
22
22
  dependencies = [
23
23
  { name = "msgspec" },
@@ -1 +0,0 @@
1
- 3.14
File without changes
File without changes
File without changes