brainlessdb 1.0.0__tar.gz → 1.2.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 → brainlessdb-1.2.0}/CHANGELOG.md +25 -0
- {brainlessdb-1.0.0 → brainlessdb-1.2.0}/PKG-INFO +1 -1
- {brainlessdb-1.0.0 → brainlessdb-1.2.0}/brainlessdb/__init__.py +1 -1
- {brainlessdb-1.0.0 → brainlessdb-1.2.0}/brainlessdb/client.py +8 -3
- {brainlessdb-1.0.0 → brainlessdb-1.2.0}/brainlessdb/ingest.py +13 -6
- {brainlessdb-1.0.0 → brainlessdb-1.2.0}/brainlessdb/struct.py +8 -7
- {brainlessdb-1.0.0 → brainlessdb-1.2.0}/pyproject.toml +1 -1
- {brainlessdb-1.0.0 → brainlessdb-1.2.0}/tests/test_client.py +22 -0
- {brainlessdb-1.0.0 → brainlessdb-1.2.0}/tests/test_ingest.py +20 -2
- {brainlessdb-1.0.0 → brainlessdb-1.2.0}/uv.lock +1 -1
- {brainlessdb-1.0.0 → brainlessdb-1.2.0}/.gitignore +0 -0
- {brainlessdb-1.0.0 → brainlessdb-1.2.0}/.python-version +0 -0
- {brainlessdb-1.0.0 → brainlessdb-1.2.0}/README.md +0 -0
- {brainlessdb-1.0.0 → brainlessdb-1.2.0}/brainlessdb/bucket.py +0 -0
- {brainlessdb-1.0.0 → brainlessdb-1.2.0}/brainlessdb/collection.py +0 -0
- {brainlessdb-1.0.0 → brainlessdb-1.2.0}/brainlessdb/fields.py +0 -0
- {brainlessdb-1.0.0 → brainlessdb-1.2.0}/brainlessdb/registry.py +0 -0
- {brainlessdb-1.0.0 → brainlessdb-1.2.0}/cliff.toml +0 -0
- {brainlessdb-1.0.0 → brainlessdb-1.2.0}/code_style_guide.md +0 -0
- {brainlessdb-1.0.0 → brainlessdb-1.2.0}/justfile +0 -0
- {brainlessdb-1.0.0 → brainlessdb-1.2.0}/test_results.txt +0 -0
- {brainlessdb-1.0.0 → brainlessdb-1.2.0}/tests/__init__.py +0 -0
- {brainlessdb-1.0.0 → brainlessdb-1.2.0}/tests/conftest.py +0 -0
- {brainlessdb-1.0.0 → brainlessdb-1.2.0}/tests/mock_nats.py +0 -0
- {brainlessdb-1.0.0 → brainlessdb-1.2.0}/tests/test_bucket.py +0 -0
- {brainlessdb-1.0.0 → brainlessdb-1.2.0}/tests/test_collection.py +0 -0
- {brainlessdb-1.0.0 → brainlessdb-1.2.0}/tests/test_dead_letter.py +0 -0
- {brainlessdb-1.0.0 → brainlessdb-1.2.0}/tests/test_deserialization.py +0 -0
- {brainlessdb-1.0.0 → brainlessdb-1.2.0}/tests/test_duplicates.py +0 -0
- {brainlessdb-1.0.0 → brainlessdb-1.2.0}/tests/test_fields.py +0 -0
- {brainlessdb-1.0.0 → brainlessdb-1.2.0}/tests/test_flush_errors.py +0 -0
- {brainlessdb-1.0.0 → brainlessdb-1.2.0}/tests/test_load_errors.py +0 -0
- {brainlessdb-1.0.0 → brainlessdb-1.2.0}/tests/test_registry.py +0 -0
- {brainlessdb-1.0.0 → brainlessdb-1.2.0}/tests/test_sync.py +0 -0
- {brainlessdb-1.0.0 → brainlessdb-1.2.0}/tests/test_thread_safety.py +0 -0
- {brainlessdb-1.0.0 → brainlessdb-1.2.0}/tests/test_update_atomic.py +0 -0
- {brainlessdb-1.0.0 → brainlessdb-1.2.0}/tests/test_version.py +0 -0
|
@@ -3,6 +3,31 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
5
|
|
|
6
|
+
## [1.2.0] - 2026-11-15
|
|
7
|
+
|
|
8
|
+
### 🚀 Features
|
|
9
|
+
|
|
10
|
+
- Plain __allowed_apps__ list grants access to a collection
|
|
11
|
+
- A class without allows_app() but with __allowed_apps__ (a list of app
|
|
12
|
+
namespaces) is allowed when the namespace is in the list. ucs_data
|
|
13
|
+
buckets declare only __allowed_apps__ and were all rejected with
|
|
14
|
+
AppAccessError. allows_app() still takes precedence.
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
## [1.1.0] - 2026-11-03
|
|
18
|
+
|
|
19
|
+
### 🚀 Features
|
|
20
|
+
|
|
21
|
+
- Ingest() reports unknown keys
|
|
22
|
+
- Top level keys which are not fields of the struct were silently left out,
|
|
23
|
+
so a typo or a renamed field went unnoticed. They are still left out, but
|
|
24
|
+
now reported as Repair with action "ignored".
|
|
25
|
+
|
|
26
|
+
### ⚙️ Miscellaneous Tasks
|
|
27
|
+
|
|
28
|
+
- Update uv.lock to 1.0.0
|
|
29
|
+
|
|
30
|
+
|
|
6
31
|
## [1.0.0] - 2026-11-03
|
|
7
32
|
|
|
8
33
|
### 🚀 Features
|
|
@@ -87,11 +87,16 @@ class BrainlessDB:
|
|
|
87
87
|
"""Validate __allowed_apps__. Errors are deferred to start() before
|
|
88
88
|
start, raised immediately after."""
|
|
89
89
|
allows_app = getattr(cls, "allows_app", None)
|
|
90
|
+
allowed_apps = getattr(cls, "__allowed_apps__", None)
|
|
90
91
|
error = None
|
|
91
|
-
if allows_app is None:
|
|
92
|
+
if allows_app is not None:
|
|
93
|
+
if not allows_app(self._namespace):
|
|
94
|
+
error = AppAccessError(cls.__name__, self._namespace)
|
|
95
|
+
elif allowed_apps is not None:
|
|
96
|
+
if self._namespace not in allowed_apps:
|
|
97
|
+
error = AppAccessError(cls.__name__, self._namespace)
|
|
98
|
+
else:
|
|
92
99
|
error = AppAccessError(cls.__name__)
|
|
93
|
-
elif not allows_app(self._namespace):
|
|
94
|
-
error = AppAccessError(cls.__name__, self._namespace)
|
|
95
100
|
|
|
96
101
|
if error is not None:
|
|
97
102
|
if self._started:
|
|
@@ -13,9 +13,10 @@ _MISSING = object()
|
|
|
13
13
|
class Repair(Struct, frozen=True):
|
|
14
14
|
"""One value ingest() could not store as given.
|
|
15
15
|
|
|
16
|
-
action is "kept" (current value kept), "default" (field default used)
|
|
17
|
-
"removed" (list item or dict entry left out)
|
|
18
|
-
not
|
|
16
|
+
action is "kept" (current value kept), "default" (field default used),
|
|
17
|
+
"removed" (list item or dict entry left out) or "ignored" (top level key
|
|
18
|
+
is not a field of the struct). The invalid value itself is not recorded,
|
|
19
|
+
it may be a password or key.
|
|
19
20
|
"""
|
|
20
21
|
|
|
21
22
|
path: Path
|
|
@@ -201,15 +202,21 @@ def repair(
|
|
|
201
202
|
current is the stored item as builtins: invalid values fall back to it, except
|
|
202
203
|
inside lists and dicts. Values are coerced like msgspec.convert(strict=False),
|
|
203
204
|
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
|
+
e.g. a required field without current value is invalid. Top level keys
|
|
206
|
+
which are not fields of cls are left out and reported as "ignored".
|
|
205
207
|
"""
|
|
206
|
-
|
|
208
|
+
fields = {field.encode_name: field for field in msgspec.structs.fields(cls)}
|
|
209
|
+
tag_field = cls.__struct_config__.tag_field
|
|
210
|
+
repairs = [
|
|
211
|
+
Repair((key,), "ignored", f"Object has unknown field `{key}`")
|
|
212
|
+
for key in data
|
|
213
|
+
if key not in fields and key != tag_field
|
|
214
|
+
]
|
|
207
215
|
try:
|
|
208
216
|
fixed = _repair_struct(data, cls, current, (), repairs, partial=partial)
|
|
209
217
|
except _Unrepairable as ex:
|
|
210
218
|
raise msgspec.ValidationError(ex.error) from None
|
|
211
219
|
|
|
212
|
-
fields = {field.encode_name: field for field in msgspec.structs.fields(cls)}
|
|
213
220
|
converted = {
|
|
214
221
|
key: msgspec.to_builtins(msgspec.convert(value, fields[key].type, strict=False))
|
|
215
222
|
for key, value in fixed.items()
|
|
@@ -172,11 +172,12 @@ class BrainlessBucket(Struct, kw_only=True, tag=True, tag_field="TYPE"):
|
|
|
172
172
|
"""Like update(), but repair invalid values instead of raising.
|
|
173
173
|
|
|
174
174
|
An invalid value keeps the current one; inside lists and dicts the invalid
|
|
175
|
-
item is removed (items are not matched by position).
|
|
176
|
-
e.g. "5060" to 5060. Returns
|
|
177
|
-
data can't be repaired or
|
|
175
|
+
item is removed (items are not matched by position). Unknown keys are
|
|
176
|
+
ignored and reported. Values are coerced, e.g. "5060" to 5060. Returns
|
|
177
|
+
the repairs made. Raises like update() when data can't be repaired or
|
|
178
|
+
violates a unique constraint.
|
|
178
179
|
"""
|
|
179
|
-
fixed, repairs = repair(_merge_nested(self, data), type(self), to_builtins(self), partial=True)
|
|
180
|
+
fixed, repairs = repair({**data, **_merge_nested(self, data)}, type(self), to_builtins(self), partial=True)
|
|
180
181
|
self.update(fixed)
|
|
181
182
|
return repairs
|
|
182
183
|
|
|
@@ -227,11 +228,11 @@ class BrainlessRegistry(Struct, kw_only=True, tag=True, tag_field="TYPE", dict=T
|
|
|
227
228
|
def ingest(self, data: dict) -> list[Repair]:
|
|
228
229
|
"""Set fields from data and save, repairing invalid values instead of raising.
|
|
229
230
|
|
|
230
|
-
An invalid value keeps the current one
|
|
231
|
-
to 5060. Returns the repairs made.
|
|
231
|
+
An invalid value keeps the current one, unknown keys are ignored and
|
|
232
|
+
reported. Values are coerced, e.g. "5060" to 5060. Returns the repairs made.
|
|
232
233
|
"""
|
|
233
234
|
current = to_builtins(self)
|
|
234
|
-
fixed, repairs = repair(_merge_nested(self, data), type(self), current, partial=True)
|
|
235
|
+
fixed, repairs = repair({**data, **_merge_nested(self, data)}, type(self), current, partial=True)
|
|
235
236
|
new = convert({**current, **fixed}, type(self))
|
|
236
237
|
for f in self.__struct_fields__:
|
|
237
238
|
setattr(self, f, getattr(new, f))
|
|
@@ -123,6 +123,28 @@ class TestAppAccess:
|
|
|
123
123
|
with pytest.raises(RuntimeError, match="must declare __allowed_apps__"):
|
|
124
124
|
await db.start()
|
|
125
125
|
|
|
126
|
+
def test_collection_allowed_apps_attribute(self):
|
|
127
|
+
"""__allowed_apps__ without allows_app - namespace in the list is allowed."""
|
|
128
|
+
from brainlessdb import BrainlessBucket
|
|
129
|
+
|
|
130
|
+
class ListedV1(BrainlessBucket):
|
|
131
|
+
__allowed_apps__ = ["test", "other"]
|
|
132
|
+
|
|
133
|
+
db = BrainlessDB(namespace="test")
|
|
134
|
+
db.collection(ListedV1)
|
|
135
|
+
assert db._app_errors == []
|
|
136
|
+
|
|
137
|
+
def test_collection_allowed_apps_attribute_wrong_app(self):
|
|
138
|
+
"""__allowed_apps__ without allows_app - namespace not in the list is an error."""
|
|
139
|
+
from brainlessdb import BrainlessBucket
|
|
140
|
+
|
|
141
|
+
class ListedV1(BrainlessBucket):
|
|
142
|
+
__allowed_apps__ = ["other"]
|
|
143
|
+
|
|
144
|
+
db = BrainlessDB(namespace="test")
|
|
145
|
+
db.collection(ListedV1)
|
|
146
|
+
assert len(db._app_errors) == 1
|
|
147
|
+
|
|
126
148
|
@pytest.mark.asyncio
|
|
127
149
|
async def test_collection_after_start_wrong_app_raises_immediately(self):
|
|
128
150
|
"""Wrong namespace after start — raises immediately."""
|
|
@@ -60,6 +60,12 @@ class TestCollectionIngestAdd:
|
|
|
60
60
|
assert item.vlan == 10
|
|
61
61
|
assert phones.find(id=1) is item
|
|
62
62
|
|
|
63
|
+
@pytest.mark.asyncio
|
|
64
|
+
async def test_reports_unknown_keys(self, phones):
|
|
65
|
+
item, repairs = phones.ingest({"id": 1, "name": "a", "unknown": 1}, key="id")
|
|
66
|
+
assert item.name == "a"
|
|
67
|
+
assert actions(repairs) == {("unknown",): "ignored"}
|
|
68
|
+
|
|
63
69
|
@pytest.mark.asyncio
|
|
64
70
|
async def test_invalid_field_gets_default(self, phones):
|
|
65
71
|
item, repairs = phones.ingest({"id": 1, "name": "a", "mac": "nope", "vlan": 5000}, key="id")
|
|
@@ -147,9 +153,10 @@ class TestItemIngest:
|
|
|
147
153
|
assert actions(repairs) == {("vlan",): "kept"}
|
|
148
154
|
|
|
149
155
|
@pytest.mark.asyncio
|
|
150
|
-
async def
|
|
156
|
+
async def test_reports_unknown_keys(self, phones):
|
|
151
157
|
item, _ = phones.ingest({"id": 1, "name": "a"}, key="id")
|
|
152
|
-
assert item.ingest({"unknown": 1}) ==
|
|
158
|
+
assert actions(item.ingest({"unknown": 1})) == {("unknown",): "ignored"}
|
|
159
|
+
assert phones.find(id=1).name == "a"
|
|
153
160
|
|
|
154
161
|
|
|
155
162
|
class TestRegistryIngest:
|
|
@@ -164,6 +171,17 @@ class TestRegistryIngest:
|
|
|
164
171
|
assert actions(repairs) == {("host",): "kept"}
|
|
165
172
|
await db.stop()
|
|
166
173
|
|
|
174
|
+
@pytest.mark.asyncio
|
|
175
|
+
async def test_reports_unknown_keys(self):
|
|
176
|
+
db = BrainlessDB(namespace="test")
|
|
177
|
+
sip = db.registry(SipConfigV1)
|
|
178
|
+
await db.start()
|
|
179
|
+
|
|
180
|
+
repairs = sip.ingest({"hots": "10.0.0.1", "port": 5070})
|
|
181
|
+
assert (sip.host, sip.port) == ("127.0.0.1", 5070)
|
|
182
|
+
assert actions(repairs) == {("hots",): "ignored"}
|
|
183
|
+
await db.stop()
|
|
184
|
+
|
|
167
185
|
@pytest.mark.asyncio
|
|
168
186
|
async def test_marks_dirty(self):
|
|
169
187
|
db = BrainlessDB(namespace="test")
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|