pybasedb-json 0.3.0__tar.gz → 0.4.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.
- {pybasedb_json-0.3.0 → pybasedb_json-0.4.0}/PKG-INFO +59 -47
- pybasedb_json-0.4.0/README.md +120 -0
- pybasedb_json-0.4.0/pybase/__init__.py +44 -0
- {pybasedb_json-0.3.0 → pybasedb_json-0.4.0}/pybase/backup.py +3 -2
- {pybasedb_json-0.3.0 → pybasedb_json-0.4.0}/pybase/collections.py +118 -27
- pybasedb_json-0.4.0/pybase/crypto.py +59 -0
- pybasedb_json-0.4.0/pybase/database.py +151 -0
- pybasedb_json-0.4.0/pybase/exceptions.py +30 -0
- {pybasedb_json-0.3.0 → pybasedb_json-0.4.0}/pybase/hooks.py +50 -35
- {pybasedb_json-0.3.0 → pybasedb_json-0.4.0}/pybase/media.py +2 -2
- pybasedb_json-0.4.0/pybase/pybase.py +57 -0
- pybasedb_json-0.4.0/pybase/query.py +28 -0
- {pybasedb_json-0.3.0 → pybasedb_json-0.4.0}/pybase/server.py +59 -116
- pybasedb_json-0.4.0/pybase/utils.py +23 -0
- pybasedb_json-0.4.0/pybase/validation.py +382 -0
- {pybasedb_json-0.3.0 → pybasedb_json-0.4.0}/pybasedb_json.egg-info/PKG-INFO +59 -47
- {pybasedb_json-0.3.0 → pybasedb_json-0.4.0}/pybasedb_json.egg-info/SOURCES.txt +2 -0
- {pybasedb_json-0.3.0 → pybasedb_json-0.4.0}/pyproject.toml +2 -2
- pybasedb_json-0.3.0/README.md +0 -108
- pybasedb_json-0.3.0/pybase/__init__.py +0 -29
- pybasedb_json-0.3.0/pybase/database.py +0 -106
- pybasedb_json-0.3.0/pybase/exceptions.py +0 -14
- pybasedb_json-0.3.0/pybase/query.py +0 -89
- pybasedb_json-0.3.0/pybase/utils.py +0 -8
- pybasedb_json-0.3.0/pybase/validation.py +0 -236
- {pybasedb_json-0.3.0 → pybasedb_json-0.4.0}/pybase/index.py +0 -0
- {pybasedb_json-0.3.0 → pybasedb_json-0.4.0}/pybasedb_json.egg-info/dependency_links.txt +0 -0
- {pybasedb_json-0.3.0 → pybasedb_json-0.4.0}/pybasedb_json.egg-info/top_level.txt +0 -0
- {pybasedb_json-0.3.0 → pybasedb_json-0.4.0}/setup.cfg +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pybasedb-json
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Banco de dados local com JSON — schemas, server REST,
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Banco de dados local com JSON — schemas, decorator API, server REST, criptografia, zero dependencias
|
|
5
5
|
Author-email: Marcos Gomes <marcosgabrielgomes110@gmail.com>
|
|
6
6
|
Maintainer-email: Marcos Gomes <marcosgabrielgomes110@gmail.com>
|
|
7
7
|
License: MIT
|
|
@@ -28,7 +28,7 @@ Description-Content-Type: text/markdown
|
|
|
28
28
|
<img src="assets/logo/logo.png" width="120" alt="Pybase logo">
|
|
29
29
|
</p>
|
|
30
30
|
|
|
31
|
-
# Pybase
|
|
31
|
+
# Pybase v0.4
|
|
32
32
|
|
|
33
33
|
Base para criacao de bancos de dados locais com JSON. Zero dependencias, 100% stdlib.
|
|
34
34
|
Operacoes de escrita atomicas, thread-safe.
|
|
@@ -49,32 +49,39 @@ pip install Flask
|
|
|
49
49
|
## Uso rapido
|
|
50
50
|
|
|
51
51
|
```python
|
|
52
|
-
from pybase import
|
|
53
|
-
from pybase.validation import Schema
|
|
52
|
+
from pybase import pybase as pb
|
|
54
53
|
|
|
55
|
-
# Banco de dados
|
|
56
|
-
db = Database("./data
|
|
54
|
+
# Banco de dados (sem senha ou com senha)
|
|
55
|
+
db = pb.Database("./data/mydb")
|
|
56
|
+
# db = pb.Database("./data/mydb", password="senha").open("senha")
|
|
57
57
|
|
|
58
|
-
#
|
|
59
|
-
db.schema
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
58
|
+
# Schema com decorator (v0.4+)
|
|
59
|
+
@db.schema.users
|
|
60
|
+
class User:
|
|
61
|
+
name: str
|
|
62
|
+
email: str
|
|
63
|
+
age: int = 0
|
|
64
|
+
active: bool = True
|
|
65
|
+
|
|
66
|
+
# Ou com T() explicito
|
|
67
|
+
# from pybase.validation import T
|
|
68
|
+
# db.schema.users = pb.Schema({"name": pb.T(str), "age": pb.T(int, default=0)})
|
|
69
|
+
|
|
70
|
+
# Colecao com validacao automatica
|
|
71
|
+
users = db["users"]
|
|
72
|
+
users.add({"name": "marcos", "email": "m@m.com"}) # auto-ID
|
|
65
73
|
|
|
66
74
|
# SDK client
|
|
67
|
-
doc = users
|
|
68
|
-
users.
|
|
75
|
+
doc = users["1"].get()
|
|
76
|
+
users.find(age=0)
|
|
69
77
|
|
|
70
|
-
# Consultas
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
q.exists(name="joao")
|
|
78
|
+
# Consultas direto na colecao (v0.4+)
|
|
79
|
+
users.sort("name").limit(10).find(active=True)
|
|
80
|
+
users.find_one(name="ana")
|
|
81
|
+
users.exists(name="joao")
|
|
75
82
|
|
|
76
83
|
# Servidor REST (requer Flask)
|
|
77
|
-
Server(db).
|
|
84
|
+
pb.Server(db).run(port=4560) # auto-detecta colecoes
|
|
78
85
|
# Swagger UI: http://localhost:4560/mydb/docs/
|
|
79
86
|
```
|
|
80
87
|
|
|
@@ -83,48 +90,53 @@ Server(db).route("users").run(port=4560)
|
|
|
83
90
|
| Modulo | Arquivo | Descricao | Deps |
|
|
84
91
|
|--------|---------|-----------|------|
|
|
85
92
|
| Database | `pybase/database.py` | Gerencia diretorio de tabelas JSON | 0 |
|
|
86
|
-
| Collection | `pybase/collections.py` | CRUD atomico thread-safe | 0 |
|
|
87
|
-
| Query | `pybase/query.py` |
|
|
88
|
-
| Schema | `pybase/validation.py` | Validacao
|
|
93
|
+
| Collection | `pybase/collections.py` | CRUD atomico thread-safe + query integrado | 0 |
|
|
94
|
+
| Query | `pybase/query.py` | Thin wrapper sobre Collection | 0 |
|
|
95
|
+
| Schema | `pybase/validation.py` | Validacao com T(), decorator, from_example | 0 |
|
|
89
96
|
| Server | `pybase/server.py` | REST API automatica + Swagger UI | Flask |
|
|
90
97
|
| Media | `pybase/media.py` | Upload/download de imagens | 0 |
|
|
91
|
-
| Backup | `pybase/backup.py` | Snapshot e restore | 0 |
|
|
98
|
+
| Backup | `pybase/backup.py` | Snapshot e restore (seletivo) | 0 |
|
|
99
|
+
| Crypto | `pybase/crypto.py` | Criptografia stdlib (scrypt + XOR) | 0 |
|
|
100
|
+
|
|
101
|
+
## Novidades da v0.4
|
|
102
|
+
|
|
103
|
+
- **API simplificada** — `Database("./data/mydb")` sem senha obrigatoria
|
|
104
|
+
- **`update`/`delete`** — nomes em ingles, sem aliases pt-br
|
|
105
|
+
- **Auto-ID** — `users.add({"name": "x"})` gera UUID automatico
|
|
106
|
+
- **`db["colecao"]`** — acesso direto via `__getitem__`
|
|
107
|
+
- **Query integrado** — `collection.sort().limit().find()` direto
|
|
108
|
+
- **`@db.schema.nome`** — schema via decorator de classe
|
|
109
|
+
- **`T()`** — nova API de tipos com kwargs (`T(int, default=0)`)
|
|
110
|
+
- **`Schema.from_example()`** — schema inferido de dados reais
|
|
111
|
+
- **`import global`** — `from pybase import pybase`
|
|
112
|
+
- **PBKDF2** — hashing de senha com salt (600k iteracoes)
|
|
113
|
+
- **Criptografia** — `encrypt()`/`decrypt()` stdlib puro
|
|
114
|
+
- **Server auto-detect** — `Server(db).run()` descobre colecoes
|
|
115
|
+
- **Erros descritivos** — com path + valor + sugestao
|
|
92
116
|
|
|
93
117
|
## Estrutura
|
|
94
118
|
|
|
95
119
|
```
|
|
96
120
|
Pybase/
|
|
97
121
|
pybase/
|
|
98
|
-
__init__.py # Database, Collection, Query, Server,
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
122
|
+
__init__.py # Database, Collection, Query, Server, Schema, T
|
|
123
|
+
pybase.py # from pybase import pybase
|
|
124
|
+
database.py # Database, SchemaRegistrar
|
|
125
|
+
collections.py # Collection, DocRef, QueryBuilder, SchemaCollection
|
|
126
|
+
query.py # Query wrapper
|
|
127
|
+
exceptions.py # PybaseError > DatabaseError, CollectionError, ...
|
|
128
|
+
validation.py # Schema, T, ValidationError
|
|
104
129
|
server.py # Server REST (Flask)
|
|
105
130
|
media.py # MediaManager
|
|
106
131
|
index.py # Index (hash O(1))
|
|
107
132
|
hooks.py # HooksCollection
|
|
108
133
|
backup.py # take, restore
|
|
109
|
-
|
|
134
|
+
crypto.py # encrypt, decrypt (stdlib)
|
|
135
|
+
utils.py # PBKDF2 encode/verify
|
|
110
136
|
docs/ # Documentacao completa
|
|
111
137
|
pyproject.toml
|
|
112
138
|
```
|
|
113
139
|
|
|
114
|
-
## Novidades da versao (0.3+)
|
|
115
|
-
|
|
116
|
-
- **Schema validation** — defina schemas opcionais por colecao, persistidos em disco, validacao automatica em `add`/`add_many`/`actualize`
|
|
117
|
-
- **Server REST** — `Server(db).route("x").run()` gera CRUD automatico com Swagger UI e OpenAPI spec
|
|
118
|
-
- **MediaManager** — upload de imagens (path, bytes, base64), CRUD de metadados, serve files via HTTP
|
|
119
|
-
- **SDK client** — `doc("id").get()`, `where("f", "==", "v").get()`
|
|
120
|
-
- **SchemaCollection** — auto-wrap quando schema existe, validacao parcial em updates
|
|
121
|
-
- **Backup** — `take()`/`restore()` com snapshot gzip atomico
|
|
122
|
-
- **Escrita atomica** — `.tmp` + `os.replace()` protege contra corrupcao
|
|
123
|
-
- **Path traversal fix** — nomes de colecao sanitizados com regex
|
|
124
|
-
- **Sort seguro** — tipos mistos (int + str) nao quebram mais
|
|
125
|
-
- **Indices em memoria** — `Index(field)` para lookups O(1)
|
|
126
|
-
- **Lifecycle hooks** — `HooksCollection` com callbacks before/after
|
|
127
|
-
|
|
128
140
|
## Documentacao
|
|
129
141
|
|
|
130
142
|
Veja [docs/](docs/index.md) com referencia completa de cada classe e metodo.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="assets/logo/logo.png" width="120" alt="Pybase logo">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# Pybase v0.4
|
|
6
|
+
|
|
7
|
+
Base para criacao de bancos de dados locais com JSON. Zero dependencias, 100% stdlib.
|
|
8
|
+
Operacoes de escrita atomicas, thread-safe.
|
|
9
|
+
|
|
10
|
+
Filosofia **Lego**: cada modulo e independente — voce monta o que precisa.
|
|
11
|
+
|
|
12
|
+
## Instalacao
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install pybasedb-json
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Para o servidor REST (opcional):
|
|
19
|
+
```bash
|
|
20
|
+
pip install Flask
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Uso rapido
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from pybase import pybase as pb
|
|
27
|
+
|
|
28
|
+
# Banco de dados (sem senha ou com senha)
|
|
29
|
+
db = pb.Database("./data/mydb")
|
|
30
|
+
# db = pb.Database("./data/mydb", password="senha").open("senha")
|
|
31
|
+
|
|
32
|
+
# Schema com decorator (v0.4+)
|
|
33
|
+
@db.schema.users
|
|
34
|
+
class User:
|
|
35
|
+
name: str
|
|
36
|
+
email: str
|
|
37
|
+
age: int = 0
|
|
38
|
+
active: bool = True
|
|
39
|
+
|
|
40
|
+
# Ou com T() explicito
|
|
41
|
+
# from pybase.validation import T
|
|
42
|
+
# db.schema.users = pb.Schema({"name": pb.T(str), "age": pb.T(int, default=0)})
|
|
43
|
+
|
|
44
|
+
# Colecao com validacao automatica
|
|
45
|
+
users = db["users"]
|
|
46
|
+
users.add({"name": "marcos", "email": "m@m.com"}) # auto-ID
|
|
47
|
+
|
|
48
|
+
# SDK client
|
|
49
|
+
doc = users["1"].get()
|
|
50
|
+
users.find(age=0)
|
|
51
|
+
|
|
52
|
+
# Consultas direto na colecao (v0.4+)
|
|
53
|
+
users.sort("name").limit(10).find(active=True)
|
|
54
|
+
users.find_one(name="ana")
|
|
55
|
+
users.exists(name="joao")
|
|
56
|
+
|
|
57
|
+
# Servidor REST (requer Flask)
|
|
58
|
+
pb.Server(db).run(port=4560) # auto-detecta colecoes
|
|
59
|
+
# Swagger UI: http://localhost:4560/mydb/docs/
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Modulos
|
|
63
|
+
|
|
64
|
+
| Modulo | Arquivo | Descricao | Deps |
|
|
65
|
+
|--------|---------|-----------|------|
|
|
66
|
+
| Database | `pybase/database.py` | Gerencia diretorio de tabelas JSON | 0 |
|
|
67
|
+
| Collection | `pybase/collections.py` | CRUD atomico thread-safe + query integrado | 0 |
|
|
68
|
+
| Query | `pybase/query.py` | Thin wrapper sobre Collection | 0 |
|
|
69
|
+
| Schema | `pybase/validation.py` | Validacao com T(), decorator, from_example | 0 |
|
|
70
|
+
| Server | `pybase/server.py` | REST API automatica + Swagger UI | Flask |
|
|
71
|
+
| Media | `pybase/media.py` | Upload/download de imagens | 0 |
|
|
72
|
+
| Backup | `pybase/backup.py` | Snapshot e restore (seletivo) | 0 |
|
|
73
|
+
| Crypto | `pybase/crypto.py` | Criptografia stdlib (scrypt + XOR) | 0 |
|
|
74
|
+
|
|
75
|
+
## Novidades da v0.4
|
|
76
|
+
|
|
77
|
+
- **API simplificada** — `Database("./data/mydb")` sem senha obrigatoria
|
|
78
|
+
- **`update`/`delete`** — nomes em ingles, sem aliases pt-br
|
|
79
|
+
- **Auto-ID** — `users.add({"name": "x"})` gera UUID automatico
|
|
80
|
+
- **`db["colecao"]`** — acesso direto via `__getitem__`
|
|
81
|
+
- **Query integrado** — `collection.sort().limit().find()` direto
|
|
82
|
+
- **`@db.schema.nome`** — schema via decorator de classe
|
|
83
|
+
- **`T()`** — nova API de tipos com kwargs (`T(int, default=0)`)
|
|
84
|
+
- **`Schema.from_example()`** — schema inferido de dados reais
|
|
85
|
+
- **`import global`** — `from pybase import pybase`
|
|
86
|
+
- **PBKDF2** — hashing de senha com salt (600k iteracoes)
|
|
87
|
+
- **Criptografia** — `encrypt()`/`decrypt()` stdlib puro
|
|
88
|
+
- **Server auto-detect** — `Server(db).run()` descobre colecoes
|
|
89
|
+
- **Erros descritivos** — com path + valor + sugestao
|
|
90
|
+
|
|
91
|
+
## Estrutura
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
Pybase/
|
|
95
|
+
pybase/
|
|
96
|
+
__init__.py # Database, Collection, Query, Server, Schema, T
|
|
97
|
+
pybase.py # from pybase import pybase
|
|
98
|
+
database.py # Database, SchemaRegistrar
|
|
99
|
+
collections.py # Collection, DocRef, QueryBuilder, SchemaCollection
|
|
100
|
+
query.py # Query wrapper
|
|
101
|
+
exceptions.py # PybaseError > DatabaseError, CollectionError, ...
|
|
102
|
+
validation.py # Schema, T, ValidationError
|
|
103
|
+
server.py # Server REST (Flask)
|
|
104
|
+
media.py # MediaManager
|
|
105
|
+
index.py # Index (hash O(1))
|
|
106
|
+
hooks.py # HooksCollection
|
|
107
|
+
backup.py # take, restore
|
|
108
|
+
crypto.py # encrypt, decrypt (stdlib)
|
|
109
|
+
utils.py # PBKDF2 encode/verify
|
|
110
|
+
docs/ # Documentacao completa
|
|
111
|
+
pyproject.toml
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Documentacao
|
|
115
|
+
|
|
116
|
+
Veja [docs/](docs/index.md) com referencia completa de cada classe e metodo.
|
|
117
|
+
|
|
118
|
+
## Licenca
|
|
119
|
+
|
|
120
|
+
MIT
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
try:
|
|
2
|
+
from .server import Server
|
|
3
|
+
except ImportError:
|
|
4
|
+
Server = None
|
|
5
|
+
|
|
6
|
+
from .collections import Collection, DocRef, QueryBuilder, SchemaCollection
|
|
7
|
+
from .database import Database
|
|
8
|
+
from .exceptions import (
|
|
9
|
+
CollectionError,
|
|
10
|
+
DatabaseError,
|
|
11
|
+
DocumentNotFoundError,
|
|
12
|
+
EncryptionError,
|
|
13
|
+
MediaError,
|
|
14
|
+
PybaseError,
|
|
15
|
+
QueryError,
|
|
16
|
+
ValidationError,
|
|
17
|
+
)
|
|
18
|
+
from .query import Query
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
from .validation import Schema, T
|
|
22
|
+
except ImportError:
|
|
23
|
+
Schema = None
|
|
24
|
+
T = None
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"Collection",
|
|
28
|
+
"Database",
|
|
29
|
+
"DocRef",
|
|
30
|
+
"DocumentNotFoundError",
|
|
31
|
+
"EncryptionError",
|
|
32
|
+
"MediaError",
|
|
33
|
+
"PybaseError",
|
|
34
|
+
"DatabaseError",
|
|
35
|
+
"CollectionError",
|
|
36
|
+
"Query",
|
|
37
|
+
"QueryBuilder",
|
|
38
|
+
"QueryError",
|
|
39
|
+
"Schema",
|
|
40
|
+
"SchemaCollection",
|
|
41
|
+
"Server",
|
|
42
|
+
"T",
|
|
43
|
+
"ValidationError",
|
|
44
|
+
]
|
|
@@ -22,8 +22,8 @@ def take(
|
|
|
22
22
|
dest: str | Path | None = None,
|
|
23
23
|
*,
|
|
24
24
|
compress: bool = True,
|
|
25
|
+
collections: list[str] | None = None,
|
|
25
26
|
) -> Path:
|
|
26
|
-
# Snapshot every collection into one timestamped file
|
|
27
27
|
backup_dir = Path(dest) if dest else (db.path_dir / _BACKUP_DIR)
|
|
28
28
|
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
29
29
|
|
|
@@ -31,8 +31,9 @@ def take(
|
|
|
31
31
|
ext = ".json.gz" if compress else ".json"
|
|
32
32
|
out_path = backup_dir / f"{db.name}_{ts}{ext}"
|
|
33
33
|
|
|
34
|
+
names = collections if collections else db.collections
|
|
34
35
|
snapshot: dict[str, list[dict]] = {}
|
|
35
|
-
for name in
|
|
36
|
+
for name in names:
|
|
36
37
|
snapshot[name] = db.collection(name).all()
|
|
37
38
|
|
|
38
39
|
raw = json.dumps(snapshot, indent=2).encode("utf-8")
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import os
|
|
3
3
|
import threading
|
|
4
|
+
import uuid
|
|
5
|
+
from collections.abc import Callable
|
|
4
6
|
from pathlib import Path
|
|
5
7
|
|
|
6
8
|
from .exceptions import CollectionError
|
|
@@ -10,6 +12,10 @@ class Collection:
|
|
|
10
12
|
def __init__(self, file_path: str | Path) -> None:
|
|
11
13
|
self.file_path = Path(file_path)
|
|
12
14
|
self._lock = threading.Lock()
|
|
15
|
+
self._limit: int | None = None
|
|
16
|
+
self._offset: int | None = None
|
|
17
|
+
self._sort_key: str | None = None
|
|
18
|
+
self._sort_reverse: bool = False
|
|
13
19
|
|
|
14
20
|
def __str__(self) -> str:
|
|
15
21
|
return str(self.file_path)
|
|
@@ -20,8 +26,21 @@ class Collection:
|
|
|
20
26
|
def __iter__(self):
|
|
21
27
|
return iter(self._load())
|
|
22
28
|
|
|
23
|
-
def __getitem__(self,
|
|
24
|
-
|
|
29
|
+
def __getitem__(self, key: str | int | slice) -> dict | list[dict] | None:
|
|
30
|
+
if isinstance(key, str):
|
|
31
|
+
return self.get(key)
|
|
32
|
+
return self._load()[key]
|
|
33
|
+
|
|
34
|
+
def __setitem__(self, doc_id: str, data: dict) -> None:
|
|
35
|
+
if not isinstance(doc_id, str):
|
|
36
|
+
raise TypeError("key must be a string document ID")
|
|
37
|
+
data = dict(data)
|
|
38
|
+
data["id"] = doc_id
|
|
39
|
+
existing = self.get(doc_id)
|
|
40
|
+
if existing:
|
|
41
|
+
self.update(data, id=doc_id)
|
|
42
|
+
else:
|
|
43
|
+
self.add(data)
|
|
25
44
|
|
|
26
45
|
# -- internal I/O (unsafe -- caller must hold lock) --
|
|
27
46
|
|
|
@@ -40,7 +59,6 @@ class Collection:
|
|
|
40
59
|
return data
|
|
41
60
|
|
|
42
61
|
def _save_nolock(self, data: list) -> None:
|
|
43
|
-
# write to .tmp then rename — atomic on POSIX / Windows
|
|
44
62
|
tmp = self.file_path.with_suffix(".tmp")
|
|
45
63
|
with open(tmp, "w", encoding="utf-8") as f:
|
|
46
64
|
json.dump(data, f, indent=4)
|
|
@@ -65,15 +83,89 @@ class Collection:
|
|
|
65
83
|
if all(doc.get(k) == v for k, v in match.items())
|
|
66
84
|
)
|
|
67
85
|
|
|
68
|
-
|
|
69
|
-
def get(self, doc_id=None, key="id"):
|
|
70
|
-
if doc_id is None:
|
|
71
|
-
return self.all()
|
|
86
|
+
def get(self, doc_id: str) -> dict | None:
|
|
72
87
|
for doc in self._load():
|
|
73
|
-
if doc.get(
|
|
88
|
+
if doc.get("id") == doc_id:
|
|
74
89
|
return doc
|
|
75
90
|
return None
|
|
76
91
|
|
|
92
|
+
# -- query methods (fluent, chained directly on Collection) --
|
|
93
|
+
|
|
94
|
+
def sort(self, key: str, reverse: bool = False) -> "Collection":
|
|
95
|
+
self._sort_key = key
|
|
96
|
+
self._sort_reverse = reverse
|
|
97
|
+
return self
|
|
98
|
+
|
|
99
|
+
def limit(self, n: int) -> "Collection":
|
|
100
|
+
self._limit = n
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
def offset(self, n: int) -> "Collection":
|
|
104
|
+
self._offset = n
|
|
105
|
+
return self
|
|
106
|
+
|
|
107
|
+
def _make_sort_key(self) -> Callable[[dict], tuple]:
|
|
108
|
+
key = self._sort_key
|
|
109
|
+
type_order = {int: 0, float: 0, bool: 1, str: 2}
|
|
110
|
+
def _key(doc: dict) -> tuple:
|
|
111
|
+
val = doc.get(key)
|
|
112
|
+
if val is None:
|
|
113
|
+
return (1, 0, 0)
|
|
114
|
+
priority = type_order.get(type(val), 99)
|
|
115
|
+
return (0, priority, val)
|
|
116
|
+
return _key
|
|
117
|
+
|
|
118
|
+
def _apply(self, docs: list[dict]) -> list[dict]:
|
|
119
|
+
if self._sort_key:
|
|
120
|
+
docs = sorted(docs, key=self._make_sort_key(), reverse=self._sort_reverse)
|
|
121
|
+
if self._offset is not None:
|
|
122
|
+
docs = docs[self._offset:]
|
|
123
|
+
if self._limit is not None:
|
|
124
|
+
docs = docs[: self._limit]
|
|
125
|
+
return docs
|
|
126
|
+
|
|
127
|
+
def _reset_query(self) -> None:
|
|
128
|
+
self._limit = None
|
|
129
|
+
self._offset = None
|
|
130
|
+
self._sort_key = None
|
|
131
|
+
self._sort_reverse = False
|
|
132
|
+
|
|
133
|
+
def find(self, **match) -> list[dict]:
|
|
134
|
+
try:
|
|
135
|
+
docs = self.all()
|
|
136
|
+
if match:
|
|
137
|
+
docs = [
|
|
138
|
+
doc for doc in docs
|
|
139
|
+
if all(doc.get(k) == v for k, v in match.items())
|
|
140
|
+
]
|
|
141
|
+
return self._apply(docs)
|
|
142
|
+
finally:
|
|
143
|
+
self._reset_query()
|
|
144
|
+
|
|
145
|
+
def find_one(self, **match) -> dict | None:
|
|
146
|
+
try:
|
|
147
|
+
docs = self.all()
|
|
148
|
+
if match:
|
|
149
|
+
docs = [
|
|
150
|
+
doc for doc in docs
|
|
151
|
+
if all(doc.get(k) == v for k, v in match.items())
|
|
152
|
+
]
|
|
153
|
+
result = self._apply(docs)
|
|
154
|
+
return result[0] if result else None
|
|
155
|
+
finally:
|
|
156
|
+
self._reset_query()
|
|
157
|
+
|
|
158
|
+
def exists(self, **match) -> bool:
|
|
159
|
+
try:
|
|
160
|
+
if not match:
|
|
161
|
+
return len(self) > 0
|
|
162
|
+
for doc in self.all():
|
|
163
|
+
if all(doc.get(k) == v for k, v in match.items()):
|
|
164
|
+
return True
|
|
165
|
+
return False
|
|
166
|
+
finally:
|
|
167
|
+
self._reset_query()
|
|
168
|
+
|
|
77
169
|
# -- SDK-style helpers --
|
|
78
170
|
|
|
79
171
|
def doc(self, doc_id: str) -> "DocRef":
|
|
@@ -86,6 +178,8 @@ class Collection:
|
|
|
86
178
|
|
|
87
179
|
def add(self, data: dict) -> dict:
|
|
88
180
|
doc = dict(data)
|
|
181
|
+
if "id" not in doc:
|
|
182
|
+
doc["id"] = str(uuid.uuid4())
|
|
89
183
|
with self._lock:
|
|
90
184
|
docs = self._load_nolock()
|
|
91
185
|
docs.append(doc)
|
|
@@ -97,34 +191,38 @@ class Collection:
|
|
|
97
191
|
raise TypeError("add_many() expects a list of dicts")
|
|
98
192
|
with self._lock:
|
|
99
193
|
docs = self._load_nolock()
|
|
100
|
-
added = [
|
|
194
|
+
added = []
|
|
195
|
+
for item in items:
|
|
196
|
+
doc = dict(item)
|
|
197
|
+
if "id" not in doc:
|
|
198
|
+
doc["id"] = str(uuid.uuid4())
|
|
199
|
+
added.append(doc)
|
|
101
200
|
docs.extend(added)
|
|
102
201
|
self._save_nolock(docs)
|
|
103
202
|
return added
|
|
104
203
|
|
|
105
|
-
def
|
|
204
|
+
def update(self, data: dict, **match) -> int:
|
|
106
205
|
if not match:
|
|
107
206
|
raise CollectionError(
|
|
108
207
|
"provide at least one filter field, "
|
|
109
|
-
"e.g.
|
|
208
|
+
"e.g. update({'age': 20}, name='marcos')"
|
|
110
209
|
)
|
|
111
210
|
with self._lock:
|
|
112
211
|
docs = self._load_nolock()
|
|
113
212
|
updated = 0
|
|
114
213
|
for i, doc in enumerate(docs):
|
|
115
214
|
if all(doc.get(k) == v for k, v in match.items()):
|
|
116
|
-
# defensive copy — don't mutate original dicts in memory
|
|
117
215
|
docs[i] = {**doc, **data}
|
|
118
216
|
updated += 1
|
|
119
217
|
if updated:
|
|
120
218
|
self._save_nolock(docs)
|
|
121
219
|
return updated
|
|
122
220
|
|
|
123
|
-
def
|
|
221
|
+
def delete(self, **match) -> int:
|
|
124
222
|
if not match:
|
|
125
223
|
raise CollectionError(
|
|
126
224
|
"provide at least one filter field, "
|
|
127
|
-
"e.g.
|
|
225
|
+
"e.g. delete(name='marcos')"
|
|
128
226
|
)
|
|
129
227
|
with self._lock:
|
|
130
228
|
docs = self._load_nolock()
|
|
@@ -152,10 +250,10 @@ class DocRef:
|
|
|
152
250
|
return self._col.get(self._id)
|
|
153
251
|
|
|
154
252
|
def update(self, data: dict) -> int:
|
|
155
|
-
return self._col.
|
|
253
|
+
return self._col.update(data, id=self._id)
|
|
156
254
|
|
|
157
255
|
def delete(self) -> int:
|
|
158
|
-
return self._col.
|
|
256
|
+
return self._col.delete(id=self._id)
|
|
159
257
|
|
|
160
258
|
|
|
161
259
|
class QueryBuilder:
|
|
@@ -177,18 +275,13 @@ class QueryBuilder:
|
|
|
177
275
|
|
|
178
276
|
|
|
179
277
|
class SchemaCollection:
|
|
180
|
-
# Wraps a Collection + optional Schema.
|
|
181
|
-
# Validates all writes against the schema before delegating.
|
|
182
|
-
|
|
183
278
|
def __init__(self, collection: Collection, schema=None) -> None:
|
|
184
279
|
self._col = collection
|
|
185
|
-
self._schema = schema
|
|
280
|
+
self._schema = schema
|
|
186
281
|
|
|
187
282
|
def set_schema(self, schema) -> None:
|
|
188
283
|
self._schema = schema
|
|
189
284
|
|
|
190
|
-
# -- schema-aware writes --
|
|
191
|
-
|
|
192
285
|
def add(self, data: dict) -> dict:
|
|
193
286
|
doc = self._schema.validate(data) if self._schema else dict(data)
|
|
194
287
|
return self._col.add(doc)
|
|
@@ -198,12 +291,10 @@ class SchemaCollection:
|
|
|
198
291
|
items = [self._schema.validate(d) for d in items]
|
|
199
292
|
return self._col.add_many(items)
|
|
200
293
|
|
|
201
|
-
def
|
|
294
|
+
def update(self, data: dict, **match) -> int:
|
|
202
295
|
if self._schema:
|
|
203
|
-
self._schema.validate(data, partial=True)
|
|
204
|
-
return self._col.
|
|
205
|
-
|
|
206
|
-
# -- delegation (reads + rem + drop + SDK helpers) --
|
|
296
|
+
self._schema.validate(data, partial=True)
|
|
297
|
+
return self._col.update(data, **match)
|
|
207
298
|
|
|
208
299
|
def __getattr__(self, name: str):
|
|
209
300
|
return getattr(self._col, name)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import hashlib
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from .exceptions import EncryptionError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _derive_key(password: str, salt: bytes, key_len: int = 32) -> bytes:
|
|
9
|
+
return hashlib.scrypt(
|
|
10
|
+
password.encode("utf-8"),
|
|
11
|
+
salt=salt,
|
|
12
|
+
n=16384,
|
|
13
|
+
r=8,
|
|
14
|
+
p=1,
|
|
15
|
+
dklen=key_len,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def encrypt(data: str | bytes, password: str) -> str:
|
|
20
|
+
if isinstance(data, str):
|
|
21
|
+
data = data.encode("utf-8")
|
|
22
|
+
salt = os.urandom(16)
|
|
23
|
+
key = _derive_key(password, salt, len(data))
|
|
24
|
+
encrypted = bytes(a ^ b for a, b in zip(data, key))
|
|
25
|
+
payload = salt + encrypted
|
|
26
|
+
return base64.urlsafe_b64encode(payload).decode("ascii")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def decrypt(payload: str, password: str) -> str:
|
|
30
|
+
try:
|
|
31
|
+
raw = base64.urlsafe_b64decode(payload.encode("ascii"))
|
|
32
|
+
except Exception as e:
|
|
33
|
+
raise EncryptionError(f"invalid encrypted payload: {e}")
|
|
34
|
+
if len(raw) < 16:
|
|
35
|
+
raise EncryptionError("payload too short")
|
|
36
|
+
salt = raw[:16]
|
|
37
|
+
encrypted = raw[16:]
|
|
38
|
+
key = _derive_key(password, salt, len(encrypted))
|
|
39
|
+
decrypted = bytes(a ^ b for a, b in zip(encrypted, key))
|
|
40
|
+
try:
|
|
41
|
+
return decrypted.decode("utf-8")
|
|
42
|
+
except UnicodeDecodeError:
|
|
43
|
+
raise EncryptionError("incorrect password or corrupted data")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def encrypt_bytes(data: bytes, password: str) -> bytes:
|
|
47
|
+
salt = os.urandom(16)
|
|
48
|
+
key = _derive_key(password, salt, len(data))
|
|
49
|
+
encrypted = bytes(a ^ b for a, b in zip(data, key))
|
|
50
|
+
return salt + encrypted
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def decrypt_bytes(payload: bytes, password: str) -> bytes:
|
|
54
|
+
if len(payload) < 16:
|
|
55
|
+
raise EncryptionError("payload too short")
|
|
56
|
+
salt = payload[:16]
|
|
57
|
+
encrypted = payload[16:]
|
|
58
|
+
key = _derive_key(password, salt, len(encrypted))
|
|
59
|
+
return bytes(a ^ b for a, b in zip(encrypted, key))
|