pymingledb 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.
@@ -0,0 +1,96 @@
1
+ Metadata-Version: 2.3
2
+ Name: pymingledb
3
+ Version: 1.0.0
4
+ Summary: Lightweight file-based NoSQL engine (Python port of mingleDB). BSON + zlib, schema, query operators, auth.
5
+ Author: Mark Wayne Menorca
6
+ Author-email: Mark Wayne Menorca <marcuwynu23@gmail.com>
7
+ Requires-Dist: pymongo>=4.0
8
+ Requires-Dist: pytest>=7.0 ; extra == 'dev'
9
+ Requires-Python: >=3.10
10
+ Provides-Extra: dev
11
+ Description-Content-Type: text/markdown
12
+
13
+ # pymingleDB
14
+
15
+ Lightweight file-based NoSQL engine — Python port of [mingleDB](https://github.com/marcuwynu23/mingleDB). Same format as the JavaScript and [gomingleDB](https://github.com/marcuwynu23/gomingleDB) implementations: BSON serialization, zlib compression, optional schema validation, query operators, and basic authentication.
16
+
17
+ ## Install (uv)
18
+
19
+ ```bash
20
+ cd pymingleDB
21
+ uv sync
22
+ ```
23
+
24
+ Or add to your project:
25
+
26
+ ```bash
27
+ uv add pymingleDB
28
+ ```
29
+
30
+ ## Usage
31
+
32
+ ```python
33
+ from pymingledb import MingleDB, ValidationError, UsernameExistsError, AuthFailedError
34
+
35
+ db = MingleDB("./mydb") # or any path
36
+
37
+ # Schema (optional)
38
+ db.define_schema("users", {
39
+ "name": {"type": "string", "required": True},
40
+ "email": {"type": "string", "required": True, "unique": True},
41
+ "age": {"type": "number"},
42
+ })
43
+
44
+ # CRUD
45
+ db.insert_one("users", {"name": "Alice", "email": "alice@example.com", "age": 30})
46
+ db.insert_one("users", {"name": "Bob", "email": "bob@example.com", "age": 17})
47
+
48
+ db.find_all("users")
49
+ db.find_one("users", {"email": "alice@example.com"})
50
+ db.find("users", {"age": {"$gte": 18, "$lt": 60}})
51
+ db.find("users", {"name": {"$regex": "ali", "$options": "i"}})
52
+ db.find("users", {"email": {"$in": ["alice@example.com", "bob@example.com"]}})
53
+
54
+ db.update_one("users", {"name": "Alice"}, {"age": 31})
55
+ db.delete_one("users", {"email": "bob@example.com"})
56
+
57
+ # Auth (uses internal _auth collection)
58
+ db.register_user("admin", "secure123")
59
+ db.login("admin", "secure123")
60
+ db.is_authenticated("admin") # True
61
+ db.logout("admin")
62
+
63
+ # Reset (wipe all collections and schemas)
64
+ db.reset()
65
+ ```
66
+
67
+ ## Query operators
68
+
69
+ - `$gt`, `$gte`, `$lt`, `$lte` — numeric comparison
70
+ - `$eq`, `$ne` — equality
71
+ - `$in`, `$nin` — in list / not in list
72
+ - `$regex`, `$options` — regex (e.g. `"i"` for case-insensitive)
73
+
74
+ You can also pass a compiled `re.Pattern` as a filter value for regex match.
75
+
76
+ ## Exceptions
77
+
78
+ - `MingleDBError` — base
79
+ - `UsernameExistsError` — register with existing username
80
+ - `AuthFailedError` — login failed
81
+ - `ValidationError` — schema validation (required, type, unique)
82
+
83
+ ## File format
84
+
85
+ Collections are stored as `<collection>.mgdb` files: header `MINGLEDBv1`, 4-byte meta length, JSON meta, then for each document: 4-byte length + zlib(BSON(doc)). Compatible with mingleDB (JS) and gomingleDB (Go).
86
+
87
+ ## Tests
88
+
89
+ ```bash
90
+ uv sync --extra dev
91
+ uv run pytest tests/ -v
92
+ ```
93
+
94
+ ## License
95
+
96
+ Use under the same terms as mingleDB / gomingleDB.
@@ -0,0 +1,84 @@
1
+ # pymingleDB
2
+
3
+ Lightweight file-based NoSQL engine — Python port of [mingleDB](https://github.com/marcuwynu23/mingleDB). Same format as the JavaScript and [gomingleDB](https://github.com/marcuwynu23/gomingleDB) implementations: BSON serialization, zlib compression, optional schema validation, query operators, and basic authentication.
4
+
5
+ ## Install (uv)
6
+
7
+ ```bash
8
+ cd pymingleDB
9
+ uv sync
10
+ ```
11
+
12
+ Or add to your project:
13
+
14
+ ```bash
15
+ uv add pymingleDB
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ ```python
21
+ from pymingledb import MingleDB, ValidationError, UsernameExistsError, AuthFailedError
22
+
23
+ db = MingleDB("./mydb") # or any path
24
+
25
+ # Schema (optional)
26
+ db.define_schema("users", {
27
+ "name": {"type": "string", "required": True},
28
+ "email": {"type": "string", "required": True, "unique": True},
29
+ "age": {"type": "number"},
30
+ })
31
+
32
+ # CRUD
33
+ db.insert_one("users", {"name": "Alice", "email": "alice@example.com", "age": 30})
34
+ db.insert_one("users", {"name": "Bob", "email": "bob@example.com", "age": 17})
35
+
36
+ db.find_all("users")
37
+ db.find_one("users", {"email": "alice@example.com"})
38
+ db.find("users", {"age": {"$gte": 18, "$lt": 60}})
39
+ db.find("users", {"name": {"$regex": "ali", "$options": "i"}})
40
+ db.find("users", {"email": {"$in": ["alice@example.com", "bob@example.com"]}})
41
+
42
+ db.update_one("users", {"name": "Alice"}, {"age": 31})
43
+ db.delete_one("users", {"email": "bob@example.com"})
44
+
45
+ # Auth (uses internal _auth collection)
46
+ db.register_user("admin", "secure123")
47
+ db.login("admin", "secure123")
48
+ db.is_authenticated("admin") # True
49
+ db.logout("admin")
50
+
51
+ # Reset (wipe all collections and schemas)
52
+ db.reset()
53
+ ```
54
+
55
+ ## Query operators
56
+
57
+ - `$gt`, `$gte`, `$lt`, `$lte` — numeric comparison
58
+ - `$eq`, `$ne` — equality
59
+ - `$in`, `$nin` — in list / not in list
60
+ - `$regex`, `$options` — regex (e.g. `"i"` for case-insensitive)
61
+
62
+ You can also pass a compiled `re.Pattern` as a filter value for regex match.
63
+
64
+ ## Exceptions
65
+
66
+ - `MingleDBError` — base
67
+ - `UsernameExistsError` — register with existing username
68
+ - `AuthFailedError` — login failed
69
+ - `ValidationError` — schema validation (required, type, unique)
70
+
71
+ ## File format
72
+
73
+ Collections are stored as `<collection>.mgdb` files: header `MINGLEDBv1`, 4-byte meta length, JSON meta, then for each document: 4-byte length + zlib(BSON(doc)). Compatible with mingleDB (JS) and gomingleDB (Go).
74
+
75
+ ## Tests
76
+
77
+ ```bash
78
+ uv sync --extra dev
79
+ uv run pytest tests/ -v
80
+ ```
81
+
82
+ ## License
83
+
84
+ Use under the same terms as mingleDB / gomingleDB.
@@ -0,0 +1,24 @@
1
+ [project]
2
+ name = "pymingledb"
3
+ version = "1.0.0"
4
+ description = "Lightweight file-based NoSQL engine (Python port of mingleDB). BSON + zlib, schema, query operators, auth."
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Mark Wayne Menorca", email = "marcuwynu23@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "pymongo>=4.0",
12
+ ]
13
+
14
+ [project.optional-dependencies]
15
+ dev = [
16
+ "pytest>=7.0",
17
+ ]
18
+
19
+ [tool.pytest.ini_options]
20
+ testpaths = ["tests"]
21
+
22
+ [build-system]
23
+ requires = ["uv_build>=0.8.22,<0.9.0"]
24
+ build-backend = "uv_build"
@@ -0,0 +1,17 @@
1
+ """pymingleDB - Lightweight file-based NoSQL engine (Python port of mingleDB)."""
2
+
3
+ from pymingledb.mingle import (
4
+ MingleDB,
5
+ MingleDBError,
6
+ AuthFailedError,
7
+ UsernameExistsError,
8
+ ValidationError,
9
+ )
10
+
11
+ __all__ = [
12
+ "MingleDB",
13
+ "MingleDBError",
14
+ "AuthFailedError",
15
+ "UsernameExistsError",
16
+ "ValidationError",
17
+ ]
@@ -0,0 +1,343 @@
1
+ # pymingleDB - Lightweight file-based NoSQL engine (Python port of mingleDB).
2
+ # BSON serialization, zlib compression, schema validation, query operators, basic auth.
3
+
4
+ from __future__ import annotations
5
+
6
+ import hashlib
7
+ import json
8
+ import re
9
+ import struct
10
+ import threading
11
+ import zlib
12
+ from pathlib import Path
13
+ from typing import Any, Pattern
14
+
15
+ from bson import BSON
16
+
17
+
18
+ HEADER = b"MINGLEDBv1"
19
+ EXTENSION = ".mgdb"
20
+ AUTH_COLLECTION = "_auth"
21
+
22
+
23
+ class MingleDBError(Exception):
24
+ """Base exception for pymingleDB."""
25
+
26
+
27
+ class UsernameExistsError(MingleDBError):
28
+ """Username already exists when registering."""
29
+
30
+
31
+ class AuthFailedError(MingleDBError):
32
+ """Authentication failed (wrong username or password)."""
33
+
34
+
35
+ class ValidationError(MingleDBError):
36
+ """Schema validation error (required, type, unique)."""
37
+
38
+
39
+ def _float_cmp(a: Any, b: Any) -> tuple[float, float] | None:
40
+ """Return (a, b) as floats if both are numeric, else None."""
41
+ try:
42
+ fa = float(a) if isinstance(a, (int, float)) else None
43
+ fb = float(b) if isinstance(b, (int, float)) else None
44
+ if fa is not None and fb is not None:
45
+ return (fa, fb)
46
+ except (TypeError, ValueError):
47
+ pass
48
+ return None
49
+
50
+
51
+ def _value_equal(a: Any, b: Any) -> bool:
52
+ if a is None and b is None:
53
+ return True
54
+ if a is None or b is None:
55
+ return False
56
+ pair = _float_cmp(a, b)
57
+ if pair is not None:
58
+ return pair[0] == pair[1]
59
+ return a == b
60
+
61
+
62
+ def _match_operators(doc_val: Any, op_map: dict[str, Any]) -> bool:
63
+ for op, op_val in op_map.items():
64
+ if op == "$options":
65
+ continue
66
+ if op == "$gt":
67
+ pair = _float_cmp(doc_val, op_val)
68
+ if pair is None or not (pair[0] > pair[1]):
69
+ return False
70
+ elif op == "$gte":
71
+ pair = _float_cmp(doc_val, op_val)
72
+ if pair is None or not (pair[0] >= pair[1]):
73
+ return False
74
+ elif op == "$lt":
75
+ pair = _float_cmp(doc_val, op_val)
76
+ if pair is None or not (pair[0] < pair[1]):
77
+ return False
78
+ elif op == "$lte":
79
+ pair = _float_cmp(doc_val, op_val)
80
+ if pair is None or not (pair[0] <= pair[1]):
81
+ return False
82
+ elif op == "$eq":
83
+ if not _value_equal(doc_val, op_val):
84
+ return False
85
+ elif op == "$ne":
86
+ if _value_equal(doc_val, op_val):
87
+ return False
88
+ elif op == "$in":
89
+ if not isinstance(op_val, (list, tuple)):
90
+ return False
91
+ if not any(_value_equal(doc_val, v) for v in op_val):
92
+ return False
93
+ elif op == "$nin":
94
+ if isinstance(op_val, (list, tuple)) and any(_value_equal(doc_val, v) for v in op_val):
95
+ return False
96
+ elif op == "$regex":
97
+ pattern = op_val if isinstance(op_val, str) else ""
98
+ flags = 0
99
+ if isinstance(op_map.get("$options"), str) and "i" in op_map["$options"]:
100
+ flags = re.IGNORECASE
101
+ try:
102
+ rx = re.compile(pattern, flags)
103
+ except re.error:
104
+ return False
105
+ if not isinstance(doc_val, str) or not rx.search(doc_val):
106
+ return False
107
+ return True
108
+
109
+
110
+ def _match_query(doc: dict[str, Any], filter: dict[str, Any]) -> bool:
111
+ for key, filter_val in filter.items():
112
+ doc_val = doc.get(key)
113
+ if filter_val is None:
114
+ if doc_val is not None:
115
+ return False
116
+ continue
117
+ if isinstance(filter_val, dict) and any(
118
+ k.startswith("$") for k in filter_val
119
+ ):
120
+ if not _match_operators(doc_val, filter_val):
121
+ return False
122
+ continue
123
+ if isinstance(filter_val, Pattern):
124
+ if not isinstance(doc_val, str) or not filter_val.search(doc_val):
125
+ return False
126
+ continue
127
+ if not _value_equal(doc_val, filter_val):
128
+ return False
129
+ return True
130
+
131
+
132
+ class MingleDB:
133
+ """
134
+ Lightweight file-based NoSQL database.
135
+ Uses BSON + zlib, optional schema validation, query operators ($gt, $gte, $in, $regex, etc.),
136
+ and basic authentication (register_user / login / logout).
137
+ """
138
+
139
+ def __init__(self, db_dir: str | Path = ".mgdb") -> None:
140
+ self._db_dir = Path(db_dir)
141
+ self._db_dir.mkdir(parents=True, exist_ok=True)
142
+ self._schemas: dict[str, dict[str, Any]] = {}
143
+ self._sessions: set[str] = set()
144
+ self._lock = threading.RLock()
145
+
146
+ def reset(self) -> None:
147
+ """Remove all .mgdb collection files and clear schemas and auth state."""
148
+ with self._lock:
149
+ if self._db_dir.exists():
150
+ for f in self._db_dir.iterdir():
151
+ if f.is_file() and f.suffix == EXTENSION:
152
+ f.unlink()
153
+ self._schemas.clear()
154
+ self._sessions.clear()
155
+
156
+ def _get_file_path(self, collection: str) -> Path:
157
+ return self._db_dir / f"{collection}{EXTENSION}"
158
+
159
+ def _init_collection_file(self, collection: str) -> None:
160
+ path = self._get_file_path(collection)
161
+ if path.exists():
162
+ return
163
+ meta = json.dumps({"collection": collection}).encode("utf-8")
164
+ meta_len = struct.pack("<I", len(meta))
165
+ path.write_bytes(HEADER + meta_len + meta)
166
+
167
+ def _hash_password(self, password: str) -> str:
168
+ return hashlib.sha256(password.encode("utf-8")).hexdigest()
169
+
170
+ def define_schema(self, collection: str, schema_definition: dict[str, Any]) -> None:
171
+ """Define schema for a collection. Rules: type ('string'|'number'), required, unique."""
172
+ with self._lock:
173
+ self._schemas[collection] = schema_definition
174
+
175
+ def _validate_schema(self, collection: str, doc: dict[str, Any]) -> None:
176
+ schema = self._schemas.get(collection)
177
+ if not schema:
178
+ return
179
+ all_docs = self._find_all_locked(collection)
180
+ for key, rule in schema.items():
181
+ if not isinstance(rule, dict):
182
+ continue
183
+ typ = rule.get("type")
184
+ required = rule.get("required", False)
185
+ unique = rule.get("unique", False)
186
+ value = doc.get(key)
187
+ if required and (value is None and key not in doc):
188
+ raise ValidationError(f'Field "{key}" is required.')
189
+ if value is not None and key in doc:
190
+ if typ == "string" and not isinstance(value, str):
191
+ raise ValidationError(f'Field "{key}" must be of type string.')
192
+ if typ == "number" and not isinstance(value, (int, float)):
193
+ raise ValidationError(f'Field "{key}" must be of type number.')
194
+ if unique:
195
+ for d in all_docs:
196
+ if d.get(key) == value:
197
+ raise ValidationError(
198
+ f'Duplicate value for unique field "{key}".'
199
+ )
200
+
201
+ def register_user(self, username: str, password: str) -> None:
202
+ """Register a user in _auth. Raises UsernameExistsError if username exists."""
203
+ with self._lock:
204
+ self._init_collection_file(AUTH_COLLECTION)
205
+ users = self._find_all_locked(AUTH_COLLECTION)
206
+ for u in users:
207
+ if u.get("username") == username:
208
+ raise UsernameExistsError("Username already exists.")
209
+ hashed = self._hash_password(password)
210
+ self._insert_one_locked(
211
+ AUTH_COLLECTION, {"username": username, "password": hashed}
212
+ )
213
+
214
+ def login(self, username: str, password: str) -> None:
215
+ """Authenticate user; adds to session. Raises AuthFailedError on failure."""
216
+ with self._lock:
217
+ users = self._find_all_locked(AUTH_COLLECTION)
218
+ user = None
219
+ for u in users:
220
+ if u.get("username") == username:
221
+ user = u
222
+ break
223
+ if user is None or user.get("password") != self._hash_password(password):
224
+ raise AuthFailedError("Authentication failed.")
225
+ self._sessions.add(username)
226
+
227
+ def is_authenticated(self, username: str) -> bool:
228
+ return username in self._sessions
229
+
230
+ def logout(self, username: str) -> None:
231
+ self._sessions.discard(username)
232
+
233
+ def _insert_one_locked(self, collection: str, doc: dict[str, Any]) -> None:
234
+ raw = BSON.encode(doc)
235
+ compressed = zlib.compress(raw)
236
+ length = struct.pack("<I", len(compressed))
237
+ path = self._get_file_path(collection)
238
+ with open(path, "ab") as f:
239
+ f.write(length + compressed)
240
+
241
+ def insert_one(self, collection: str, doc: dict[str, Any]) -> None:
242
+ """Append one document. Validates schema if defined."""
243
+ with self._lock:
244
+ self._init_collection_file(collection)
245
+ self._validate_schema(collection, doc)
246
+ self._insert_one_locked(collection, doc)
247
+
248
+ def _find_all_locked(self, collection: str) -> list[dict[str, Any]]:
249
+ path = self._get_file_path(collection)
250
+ if not path.exists():
251
+ return []
252
+ data = path.read_bytes()
253
+ if len(data) < len(HEADER) + 4:
254
+ return []
255
+ if data[: len(HEADER)] != HEADER:
256
+ raise MingleDBError("Invalid mingleDB file header.")
257
+ offset = len(HEADER)
258
+ (meta_len,) = struct.unpack("<I", data[offset : offset + 4])
259
+ offset += 4 + meta_len
260
+ docs = []
261
+ while offset + 4 <= len(data):
262
+ (doc_len,) = struct.unpack("<I", data[offset : offset + 4])
263
+ offset += 4
264
+ if offset + doc_len > len(data):
265
+ break
266
+ compressed = data[offset : offset + doc_len]
267
+ offset += doc_len
268
+ raw = zlib.decompress(compressed)
269
+ doc = BSON.decode(raw)
270
+ docs.append(doc)
271
+ return docs
272
+
273
+ def find_all(self, collection: str) -> list[dict[str, Any]]:
274
+ """Return all documents in the collection."""
275
+ with self._lock:
276
+ return self._find_all_locked(collection)
277
+
278
+ def find(
279
+ self,
280
+ collection: str,
281
+ filter: dict[str, Any] | None = None,
282
+ ) -> list[dict[str, Any]]:
283
+ """Return documents matching the filter. Empty filter matches all."""
284
+ filter = filter or {}
285
+ with self._lock:
286
+ docs = self._find_all_locked(collection)
287
+ return [d for d in docs if _match_query(d, filter)]
288
+
289
+ def find_one(
290
+ self,
291
+ collection: str,
292
+ filter: dict[str, Any] | None = None,
293
+ ) -> dict[str, Any] | None:
294
+ """Return the first document matching the filter, or None."""
295
+ found = self.find(collection, filter or {})
296
+ return found[0] if found else None
297
+
298
+ def _rewrite_collection_locked(
299
+ self, collection: str, docs: list[dict[str, Any]]
300
+ ) -> None:
301
+ meta = json.dumps({"collection": collection}).encode("utf-8")
302
+ meta_len = struct.pack("<I", len(meta))
303
+ body = bytearray()
304
+ for doc in docs:
305
+ raw = BSON.encode(doc)
306
+ compressed = zlib.compress(raw)
307
+ body.extend(struct.pack("<I", len(compressed)))
308
+ body.extend(compressed)
309
+ path = self._get_file_path(collection)
310
+ path.write_bytes(HEADER + meta_len + meta + bytes(body))
311
+
312
+ def update_one(
313
+ self,
314
+ collection: str,
315
+ query: dict[str, Any],
316
+ update: dict[str, Any],
317
+ ) -> bool:
318
+ """Update the first document matching query with update. Returns True if one was updated."""
319
+ with self._lock:
320
+ docs = self._find_all_locked(collection)
321
+ updated = False
322
+ for i, doc in enumerate(docs):
323
+ if not updated and _match_query(doc, query):
324
+ updated = True
325
+ docs[i] = {**doc, **update}
326
+ if updated:
327
+ self._rewrite_collection_locked(collection, docs)
328
+ return updated
329
+
330
+ def delete_one(self, collection: str, query: dict[str, Any]) -> bool:
331
+ """Remove the first document matching query. Returns True if one was deleted."""
332
+ with self._lock:
333
+ docs = self._find_all_locked(collection)
334
+ out = []
335
+ deleted = False
336
+ for doc in docs:
337
+ if not deleted and _match_query(doc, query):
338
+ deleted = True
339
+ continue
340
+ out.append(doc)
341
+ if deleted:
342
+ self._rewrite_collection_locked(collection, out)
343
+ return deleted
File without changes