wtinydb 0.1.0__py3-none-any.whl

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.
wtinydb/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """WTinyDB package initialization."""
2
+
3
+ from wtinydb.core.async_db import AsyncWTinyDB
4
+ from wtinydb.core.database import WTinyDB
5
+ from wtinydb.core.query import Q, QueryBuilder
6
+ from wtinydb.exceptions import DocumentNotFoundError, StorageError, ValidationError, WTinyDBError
7
+ from wtinydb.models import AuditMixin, SoftDeleteMixin, TimestampMixin
8
+
9
+ __version__ = "0.1.0"
10
+
11
+ __all__ = [
12
+ "WTinyDB",
13
+ "AsyncWTinyDB",
14
+ "QueryBuilder",
15
+ "Q",
16
+ "TimestampMixin",
17
+ "SoftDeleteMixin",
18
+ "AuditMixin",
19
+ "WTinyDBError",
20
+ "DocumentNotFoundError",
21
+ "ValidationError",
22
+ "StorageError",
23
+ ]
@@ -0,0 +1 @@
1
+ """CLI package for WTinyDB."""
wtinydb/cli/main.py ADDED
@@ -0,0 +1,60 @@
1
+ """WTinyDB Command Line Interface (CLI)."""
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from tinydb import TinyDB
7
+
8
+
9
+ def main():
10
+ """Main CLI entrypoint for WTinyDB commands."""
11
+ parser = argparse.ArgumentParser(description="WTinyDB CLI Manager")
12
+ subparsers = parser.add_subparsers(dest="command", help="Subcommand to run")
13
+
14
+ # Command: inspect
15
+ inspect_parser = subparsers.add_parser("inspect", help="Inspect tables in a TinyDB JSON database file")
16
+ inspect_parser.add_argument("db_path", help="Path to TinyDB JSON file")
17
+
18
+ # Command: count
19
+ count_parser = subparsers.add_parser("count", help="Count documents in a table")
20
+ count_parser.add_argument("db_path", help="Path to TinyDB JSON file")
21
+ count_parser.add_argument("--table", default="_default", help="Table name (default: _default)")
22
+
23
+ # Command: export
24
+ export_parser = subparsers.add_parser("export", help="Export table content as formatted JSON")
25
+ export_parser.add_argument("db_path", help="Path to TinyDB JSON file")
26
+ export_parser.add_argument("--table", default="_default", help="Table name")
27
+
28
+ args = parser.parse_args()
29
+
30
+ if not args.command:
31
+ parser.print_help()
32
+ sys.exit(0)
33
+
34
+ try:
35
+ db = TinyDB(args.db_path)
36
+ if args.command == "inspect":
37
+ tables = list(db.tables())
38
+ print(f"Database: {args.db_path}")
39
+ print(f"Tables found: {len(tables)}")
40
+ for t in tables:
41
+ table_obj = db.table(t)
42
+ print(f" - Table '{t}': {len(table_obj)} document(s)")
43
+
44
+ elif args.command == "count":
45
+ table_obj = db.table(args.table)
46
+ print(f"Table '{args.table}' contains {len(table_obj)} document(s).")
47
+
48
+ elif args.command == "export":
49
+ table_obj = db.table(args.table)
50
+ docs = table_obj.all()
51
+ print(json.dumps(docs, indent=2))
52
+
53
+ db.close()
54
+ except Exception as e:
55
+ print(f"Error: {e}", file=sys.stderr)
56
+ sys.exit(1)
57
+
58
+
59
+ if __name__ == "__main__":
60
+ main()
@@ -0,0 +1 @@
1
+ """Core modules for WTinyDB."""
@@ -0,0 +1,106 @@
1
+ """Asynchronous WTinyDB wrapper matching WMongoAsync interface."""
2
+
3
+ import asyncio
4
+ from concurrent.futures import ThreadPoolExecutor
5
+ from typing import Any, Callable, Dict, Generic, List, Optional, Type, TypeVar, Union
6
+ from pydantic import BaseModel
7
+ from tinydb import Query
8
+
9
+ from wtinydb.core.database import WTinyDB
10
+
11
+ T = TypeVar("T", bound=BaseModel)
12
+
13
+
14
+ class AsyncWTinyDB(Generic[T]):
15
+ """Asynchronous wrapper for WTinyDB operations using asyncio thread executor."""
16
+
17
+ QUEUE_NAME = "wtinydb:notifications:changes"
18
+
19
+ def __init__(
20
+ self,
21
+ sync_db: Optional[WTinyDB[T]] = None,
22
+ executor: Optional[ThreadPoolExecutor] = None,
23
+ **kwargs: Any,
24
+ ):
25
+ """Initialize AsyncWTinyDB wrapping a synchronous WTinyDB instance.
26
+
27
+ If sync_db is not provided, passes kwargs to construct a default WTinyDB instance.
28
+ """
29
+ if sync_db is None:
30
+ sync_db = WTinyDB(**kwargs)
31
+ self.sync_db = sync_db
32
+ self._executor = executor
33
+
34
+ async def _run(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
35
+ """Run synchronous function in thread executor."""
36
+ loop = asyncio.get_running_loop()
37
+ return await loop.run_in_executor(self._executor, lambda: func(*args, **kwargs))
38
+
39
+ async def insert(
40
+ self,
41
+ collection_or_instance: Union[str, T],
42
+ document: Optional[Dict[str, Any]] = None,
43
+ ) -> Any:
44
+ """Async insert single model instance or collection document."""
45
+ return await self._run(self.sync_db.insert, collection_or_instance, document)
46
+
47
+ async def insert_many(self, instances: List[T]) -> List[T]:
48
+ """Async insert multiple model instances."""
49
+ return await self._run(self.sync_db.insert_many, instances)
50
+
51
+ async def get(self, doc_id: int) -> T:
52
+ """Async get model instance by doc_id."""
53
+ return await self._run(self.sync_db.get, doc_id)
54
+
55
+ async def get_by_field(self, field_name: str, value: Any) -> Optional[T]:
56
+ """Async get document by field value."""
57
+ return await self._run(self.sync_db.get_by_field, field_name, value)
58
+
59
+ async def get_all(self, include_deleted: bool = False) -> List[T]:
60
+ """Async get all documents."""
61
+ return await self._run(self.sync_db.get_all, include_deleted=include_deleted)
62
+
63
+ async def find(
64
+ self,
65
+ collection_or_cond: Union[str, Query, Callable[[Dict[str, Any]], bool]],
66
+ query: Optional[Dict[str, Any]] = None,
67
+ include_deleted: bool = False,
68
+ ) -> List[Any]:
69
+ """Async find documents matching condition or collection query."""
70
+ return await self._run(self.sync_db.find, collection_or_cond, query=query, include_deleted=include_deleted)
71
+
72
+ async def update(
73
+ self,
74
+ collection_or_id: Union[str, int],
75
+ query_or_data: Union[Dict[str, Any], T],
76
+ update_values: Optional[Dict[str, Any]] = None,
77
+ ) -> Any:
78
+ """Async update document by doc_id or collection query."""
79
+ return await self._run(self.sync_db.update, collection_or_id, query_or_data, update_values=update_values)
80
+
81
+ async def delete(
82
+ self,
83
+ collection_or_id: Union[str, int],
84
+ query: Optional[Dict[str, Any]] = None,
85
+ hard: bool = False,
86
+ ) -> Any:
87
+ """Async delete document by doc_id or collection query."""
88
+ return await self._run(self.sync_db.delete, collection_or_id, query=query, hard=hard)
89
+
90
+ async def count(self, include_deleted: bool = False) -> int:
91
+ """Async count total documents."""
92
+ return await self._run(self.sync_db.count, include_deleted=include_deleted)
93
+
94
+ async def clear(self) -> None:
95
+ """Async purge table documents."""
96
+ await self._run(self.sync_db.clear)
97
+
98
+ async def close(self) -> None:
99
+ """Async close database storage."""
100
+ await self._run(self.sync_db.close)
101
+
102
+ async def __aenter__(self) -> "AsyncWTinyDB":
103
+ return self
104
+
105
+ async def __aexit__(self, exc_type, exc_value, traceback) -> None:
106
+ await self.close()
@@ -0,0 +1,410 @@
1
+ """Synchronous WTinyDB client matching WMongo interface with Pydantic support, caching, and notifications."""
2
+
3
+ import json
4
+ import os
5
+ import threading
6
+ import time
7
+ from typing import Any, Callable, Dict, Generic, List, Optional, Type, TypeVar, Union
8
+ from cryptography.fernet import Fernet
9
+ from pydantic import BaseModel, ValidationError as PydanticValidationError
10
+ from tinydb import Query, TinyDB, where
11
+ from tinydb.storages import JSONStorage, MemoryStorage
12
+
13
+ from wtinydb.exceptions import DocumentNotFoundError, StorageError, ValidationError
14
+ from wtinydb.models import SoftDeleteMixin
15
+
16
+ try:
17
+ import redis
18
+ except ImportError:
19
+ redis = None
20
+
21
+ try:
22
+ from wredis.queue import RedisQueueManager
23
+ except ImportError:
24
+ RedisQueueManager = None
25
+
26
+ T = TypeVar("T", bound=BaseModel)
27
+
28
+ # 🔐 Encryption Key
29
+ ENCRYPTION_KEY = Fernet.generate_key()
30
+ cipher_suite = Fernet(ENCRYPTION_KEY)
31
+
32
+
33
+ def _extract_id(target: Any) -> int:
34
+ """Extract integer document ID from integer or Pydantic model instance."""
35
+ if isinstance(target, int):
36
+ return target
37
+ if isinstance(target, BaseModel):
38
+ for attr in ("doc_id", "id", "_doc_id", "_id"):
39
+ val = getattr(target, attr, None)
40
+ if isinstance(val, int):
41
+ return val
42
+ try:
43
+ return int(target)
44
+ except (ValueError, TypeError):
45
+ raise ValueError(f"Could not extract document ID from target: {target}")
46
+
47
+
48
+ class WTinyDB(Generic[T]):
49
+ """Synchronous WTinyDB client providing WMongo-compatible collection CRUD, Redis cache, notifications, and Pydantic models."""
50
+
51
+ QUEUE_NAME = "wtinydb:notifications:changes"
52
+
53
+ def __init__(
54
+ self,
55
+ model_class: Optional[Type[T]] = None,
56
+ db_path: str = "wtinydb.json",
57
+ table_name: Optional[str] = None,
58
+ verbose: bool = False,
59
+ read_only: bool = False,
60
+ enable_notifications: bool = False,
61
+ enable_notification_receiver: bool = False,
62
+ notification_callback: Optional[Callable[[str], None]] = None,
63
+ redis_host: Optional[str] = None,
64
+ redis_port: Optional[int] = None,
65
+ redis_db: Optional[int] = None,
66
+ in_memory: bool = False,
67
+ storage: Type = JSONStorage,
68
+ **storage_kwargs: Any,
69
+ ):
70
+ """Initialize WTinyDB instance with disk persistence by default."""
71
+ self.model_class = model_class
72
+ self.table_name = table_name or (model_class.__name__.lower() if model_class else "default")
73
+ self.verbose = verbose
74
+ self.read_only = read_only
75
+ self.enable_notifications = enable_notifications
76
+ self.enable_notification_receiver = enable_notification_receiver
77
+ self.notification_callback = notification_callback
78
+ self._lock = threading.RLock()
79
+
80
+ if in_memory:
81
+ self.db = TinyDB(storage=MemoryStorage)
82
+ else:
83
+ db_dir = os.path.dirname(db_path)
84
+ if db_dir:
85
+ os.makedirs(db_dir, exist_ok=True)
86
+ self.db = TinyDB(db_path, storage=storage, **storage_kwargs)
87
+
88
+ self.table = self.db.table(self.table_name)
89
+ self.is_soft_delete_model = issubclass(model_class, SoftDeleteMixin) if model_class else False
90
+
91
+ # Configure Redis Cache
92
+ self.use_cache = False
93
+ self.redis_client = None
94
+ if redis and redis_host and redis_port is not None and redis_db is not None:
95
+ try:
96
+ self.redis_client = redis.Redis(
97
+ host=redis_host, port=redis_port, db=redis_db, decode_responses=True
98
+ )
99
+ self.redis_client.ping()
100
+ self.use_cache = True
101
+ except Exception:
102
+ self.use_cache = False
103
+
104
+ # Configure Redis Queue Manager
105
+ self.redis_queue_manager = None
106
+ if (self.enable_notifications or self.enable_notification_receiver) and RedisQueueManager and redis_host:
107
+ try:
108
+ self.redis_queue_manager = RedisQueueManager(
109
+ host=redis_host, port=redis_port, db=redis_db, verbose=self.verbose
110
+ )
111
+ except Exception:
112
+ self.redis_queue_manager = None
113
+
114
+ def _to_doc(self, instance: T) -> Dict[str, Any]:
115
+ """Serialize Pydantic model instance to dict."""
116
+ return instance.model_dump(mode="json")
117
+
118
+ def _to_model(self, doc: Dict[str, Any], doc_id: int) -> T:
119
+ """Deserialize TinyDB doc dict into Pydantic model instance with doc_id and id attributes."""
120
+ if not self.model_class:
121
+ return doc
122
+ try:
123
+ doc_copy = dict(doc)
124
+ if "doc_id" not in doc_copy:
125
+ doc_copy["doc_id"] = doc_id
126
+ if "id" not in doc_copy:
127
+ doc_copy["id"] = doc_id
128
+
129
+ model = self.model_class.model_validate(doc_copy)
130
+
131
+ # Ensure doc_id, id, and _doc_id are accessible directly on model instance
132
+ object.__setattr__(model, "doc_id", doc_id)
133
+ object.__setattr__(model, "id", doc_id)
134
+ object.__setattr__(model, "_doc_id", doc_id)
135
+ return model
136
+ except PydanticValidationError as e:
137
+ raise ValidationError(f"Failed to validate document doc_id={doc_id}: {e}") from e
138
+
139
+ def insert(
140
+ self,
141
+ collection_or_instance: Union[str, T],
142
+ document: Optional[Dict[str, Any]] = None,
143
+ ) -> Any:
144
+ """Insert document or Pydantic model. Accepts WMongo style `insert('users', doc)` or `insert(model_instance)`."""
145
+ if self.read_only:
146
+ raise PermissionError("Database is in read-only mode!")
147
+
148
+ with self._lock:
149
+ if isinstance(collection_or_instance, str):
150
+ collection_name = collection_or_instance
151
+ doc_data = document or {}
152
+ tbl = self.db.table(collection_name)
153
+ doc_id = tbl.insert(doc_data)
154
+
155
+ if self.use_cache and self.redis_client:
156
+ doc_data["_id"] = str(doc_id)
157
+ self.redis_client.set(f"cache:{collection_name}:{doc_id}", json.dumps(doc_data), ex=300)
158
+
159
+ if self.enable_notifications:
160
+ self._send_notification({
161
+ "database": self.table_name,
162
+ "collection": collection_name,
163
+ "action": "insert",
164
+ "document": doc_data,
165
+ "timestamp": time.time(),
166
+ "id": str(doc_id),
167
+ })
168
+ return doc_id
169
+ else:
170
+ instance = collection_or_instance
171
+ doc_data = self._to_doc(instance)
172
+ doc_id = self.table.insert(doc_data)
173
+ return self._to_model(doc_data, doc_id)
174
+
175
+ def find(
176
+ self,
177
+ collection_or_cond: Union[str, Query, Callable[[Dict[str, Any]], bool]],
178
+ query: Optional[Dict[str, Any]] = None,
179
+ include_deleted: bool = False,
180
+ ) -> List[Any]:
181
+ """Find documents. Accepts WMongo style `find('users', {'name': 'Alice'})` or Query objects."""
182
+ with self._lock:
183
+ if isinstance(collection_or_cond, dict):
184
+ query_dict = collection_or_cond
185
+ results = self.table.search(
186
+ lambda doc: all(doc.get(k) == v for k, v in query_dict.items())
187
+ )
188
+ models = []
189
+ for doc in results:
190
+ model = self._to_model(doc, doc.doc_id)
191
+ if not include_deleted and self.is_soft_delete_model and getattr(model, "is_deleted", False):
192
+ continue
193
+ models.append(model)
194
+ return models
195
+ elif isinstance(collection_or_cond, str):
196
+ collection_name = collection_or_cond
197
+ query_dict = query or {}
198
+ tbl = self.db.table(collection_name)
199
+
200
+ if not query_dict:
201
+ results = tbl.all()
202
+ else:
203
+ results = tbl.search(
204
+ lambda doc: all(doc.get(k) == v for k, v in query_dict.items())
205
+ )
206
+
207
+ for doc in results:
208
+ if "_id" not in doc and hasattr(doc, "doc_id"):
209
+ doc["_id"] = str(doc.doc_id)
210
+ return results
211
+ else:
212
+ cond = collection_or_cond
213
+ results = self.table.search(cond)
214
+ models = []
215
+ for doc in results:
216
+ model = self._to_model(doc, doc.doc_id)
217
+ if not include_deleted and self.is_soft_delete_model and getattr(model, "is_deleted", False):
218
+ continue
219
+ models.append(model)
220
+ return models
221
+
222
+ def update(
223
+ self,
224
+ collection_or_id_or_model: Union[str, int, T],
225
+ query_or_data: Union[Dict[str, Any], T],
226
+ update_values: Optional[Dict[str, Any]] = None,
227
+ ) -> Any:
228
+ """Update documents. Accepts WMongo style `update('users', query, update_values)` or `update(model_instance_or_id, data)`."""
229
+ if self.read_only:
230
+ raise PermissionError("Database is in read-only mode!")
231
+
232
+ with self._lock:
233
+ if isinstance(collection_or_id_or_model, (Query, Callable)):
234
+ cond = collection_or_id_or_model
235
+ update_dict = query_or_data if isinstance(query_or_data, dict) else self._to_doc(query_or_data)
236
+ return self.table.update(update_dict, cond)
237
+ elif isinstance(collection_or_id_or_model, str):
238
+ collection_name = collection_or_id_or_model
239
+ query_dict = query_or_data if isinstance(query_or_data, dict) else {}
240
+ vals = update_values or {}
241
+ tbl = self.db.table(collection_name)
242
+
243
+ updated_ids = tbl.update(
244
+ vals,
245
+ cond=lambda doc: all(doc.get(k) == v for k, v in query_dict.items()) if query_dict else True,
246
+ )
247
+ updated_count = len(updated_ids)
248
+
249
+ if self.enable_notifications and updated_count > 0:
250
+ self._send_notification({
251
+ "database": self.table_name,
252
+ "collection": collection_name,
253
+ "action": "update",
254
+ "query": query_dict,
255
+ "update_values": vals,
256
+ "timestamp": time.time(),
257
+ "modified_count": updated_count,
258
+ })
259
+ return updated_count
260
+ else:
261
+ doc_id = _extract_id(collection_or_id_or_model)
262
+ if not self.table.contains(doc_id=doc_id):
263
+ raise DocumentNotFoundError(doc_id=doc_id, table_name=self.table_name)
264
+ update_dict = self._to_doc(query_or_data) if isinstance(query_or_data, BaseModel) else query_or_data
265
+ self.table.update(update_dict, doc_ids=[doc_id])
266
+ return self.get(doc_id)
267
+
268
+ def delete(
269
+ self,
270
+ collection_or_id_or_model: Union[str, int, Query, T],
271
+ query: Optional[Dict[str, Any]] = None,
272
+ hard: bool = False,
273
+ ) -> Any:
274
+ """Delete documents. Accepts WMongo style `delete('users', query)` or `delete(model_instance_or_id)` or `delete(Query)`."""
275
+ if self.read_only:
276
+ raise PermissionError("Database is in read-only mode!")
277
+
278
+ with self._lock:
279
+ if isinstance(collection_or_id_or_model, (Query, Callable)):
280
+ cond = collection_or_id_or_model
281
+ return self.table.remove(cond)
282
+ elif isinstance(collection_or_id_or_model, str):
283
+ collection_name = collection_or_id_or_model
284
+ query_dict = query or {}
285
+ tbl = self.db.table(collection_name)
286
+
287
+ deleted_ids = tbl.remove(
288
+ cond=lambda doc: all(doc.get(k) == v for k, v in query_dict.items()) if query_dict else True
289
+ )
290
+ deleted_count = len(deleted_ids)
291
+
292
+ if self.enable_notifications and deleted_count > 0:
293
+ self._send_notification({
294
+ "database": self.table_name,
295
+ "collection": collection_name,
296
+ "action": "delete",
297
+ "query": query_dict,
298
+ "timestamp": time.time(),
299
+ "deleted_count": deleted_count,
300
+ })
301
+ return deleted_count
302
+ else:
303
+ doc_id = _extract_id(collection_or_id_or_model)
304
+ if not self.table.contains(doc_id=doc_id):
305
+ raise DocumentNotFoundError(doc_id=doc_id, table_name=self.table_name)
306
+
307
+ if self.is_soft_delete_model and not hard:
308
+ from datetime import datetime, timezone
309
+ self.table.update({"is_deleted": True, "deleted_at": datetime.now(timezone.utc).isoformat()}, doc_ids=[doc_id])
310
+ return True
311
+ else:
312
+ self.table.remove(doc_ids=[doc_id])
313
+ return True
314
+
315
+ def insert_many(self, instances: List[T]) -> List[T]:
316
+ """Insert multiple Pydantic model instances."""
317
+ if self.read_only:
318
+ raise PermissionError("Database is in read-only mode!")
319
+ with self._lock:
320
+ docs = [self._to_doc(inst) for inst in instances]
321
+ doc_ids = self.table.insert_multiple(docs)
322
+ return [self._to_model(doc, doc_id) for doc, doc_id in zip(docs, doc_ids)]
323
+
324
+ def get(self, target: Union[int, T]) -> T:
325
+ """Retrieve a document by TinyDB doc_id or Pydantic model instance."""
326
+ doc_id = _extract_id(target)
327
+ with self._lock:
328
+ doc = self.table.get(doc_id=doc_id)
329
+ if doc is None:
330
+ raise DocumentNotFoundError(doc_id=doc_id, table_name=self.table_name)
331
+ model = self._to_model(doc, doc_id)
332
+ if self.is_soft_delete_model and getattr(model, "is_deleted", False):
333
+ raise DocumentNotFoundError(doc_id=doc_id, table_name=self.table_name)
334
+ return model
335
+
336
+ def get_by_field(self, field_name: str, value: Any) -> Optional[T]:
337
+ """Retrieve first document matching field_name == value."""
338
+ with self._lock:
339
+ results = self.find(where(field_name) == value)
340
+ return results[0] if results else None
341
+
342
+ def get_all(self, include_deleted: bool = False) -> List[T]:
343
+ """Retrieve all documents in the primary table."""
344
+ with self._lock:
345
+ models = []
346
+ for doc in self.table.all():
347
+ model = self._to_model(doc, doc.doc_id)
348
+ if not include_deleted and self.is_soft_delete_model and getattr(model, "is_deleted", False):
349
+ continue
350
+ models.append(model)
351
+ return models
352
+
353
+ def count(self, include_deleted: bool = False) -> int:
354
+ """Return total document count."""
355
+ with self._lock:
356
+ if not self.is_soft_delete_model or include_deleted:
357
+ return len(self.table)
358
+ return len(self.get_all(include_deleted=False))
359
+
360
+ def clear(self) -> None:
361
+ """Purge all documents from primary table."""
362
+ if self.read_only:
363
+ raise PermissionError("Database is in read-only mode!")
364
+ with self._lock:
365
+ self.table.truncate()
366
+
367
+ def _send_notification(self, message: Dict[str, Any]) -> None:
368
+ """Send notification via Redis Queue Manager."""
369
+ if self.redis_queue_manager:
370
+ try:
371
+ self.redis_queue_manager.publish(self.QUEUE_NAME, message)
372
+ except Exception:
373
+ pass
374
+
375
+ def listen_notifications(self) -> None:
376
+ """Start listening for change notifications using Redis Queue Manager."""
377
+ if self.redis_queue_manager and self.notification_callback:
378
+ @self.redis_queue_manager.on_message(self.QUEUE_NAME)
379
+ def handle_message(record: str) -> None:
380
+ self.notification_callback(record)
381
+
382
+ self.redis_queue_manager.start()
383
+ self.redis_queue_manager.wait()
384
+
385
+ def has_permission(self, user_id: str, collection: str) -> bool:
386
+ """Check user permission for target collection."""
387
+ roles_tbl = self.db.table("roles")
388
+ roles = roles_tbl.search(where("user_id") == user_id)
389
+ if not roles:
390
+ return False
391
+ return collection in roles[0].get("collections", [])
392
+
393
+ def encrypt(self, data: str) -> str:
394
+ """Encrypt sensitive string data."""
395
+ return cipher_suite.encrypt(data.encode()).decode()
396
+
397
+ def decrypt(self, data: str) -> str:
398
+ """Decrypt encrypted string data."""
399
+ return cipher_suite.decrypt(data.encode()).decode()
400
+
401
+ def close(self) -> None:
402
+ """Close TinyDB instance and liberate resources."""
403
+ with self._lock:
404
+ self.db.close()
405
+
406
+ def __enter__(self) -> "WTinyDB":
407
+ return self
408
+
409
+ def __exit__(self, exc_type, exc_value, traceback) -> None:
410
+ self.close()
wtinydb/core/query.py ADDED
@@ -0,0 +1,91 @@
1
+ """Fluent Query Builder for WTinyDB operations with N-level nested JSON support."""
2
+
3
+ import re
4
+ from typing import Any, Callable, Dict, List, Optional
5
+ from tinydb import Query, where
6
+
7
+
8
+ def _resolve_query(field: str) -> Query:
9
+ """Resolve field path into a TinyDB Query object, supporting N-level nested dotted notation."""
10
+ if "." not in field:
11
+ return where(field)
12
+ parts = field.split(".")
13
+ q = Query()
14
+ for part in parts:
15
+ q = q[part]
16
+ return q
17
+
18
+
19
+ class QueryBuilder:
20
+ """Fluent Builder for constructing complex TinyDB query conditions with nested field support."""
21
+
22
+ def __init__(self, field_name: Optional[str] = None):
23
+ """Initialize QueryBuilder, optionally specifying target field."""
24
+ self.field_name = field_name
25
+
26
+ def _get_target_and_val(self, arg1: Any, arg2: Any = None) -> tuple[str, Any]:
27
+ """Resolve target field name and comparison value."""
28
+ if arg2 is None:
29
+ if not self.field_name:
30
+ raise ValueError("Target field name must be specified in Q('field') or method call.")
31
+ return self.field_name, arg1
32
+ return str(arg1), arg2
33
+
34
+ def eq(self, arg1: Any, arg2: Any = None) -> Any:
35
+ """Field equals value. Accepts `Q('field').eq(val)` or `Q().eq('field', val)`."""
36
+ field, val = self._get_target_and_val(arg1, arg2)
37
+ return _resolve_query(field) == val
38
+
39
+ def neq(self, arg1: Any, arg2: Any = None) -> Any:
40
+ """Field does not equal value."""
41
+ field, val = self._get_target_and_val(arg1, arg2)
42
+ return _resolve_query(field) != val
43
+
44
+ def gt(self, arg1: Any, arg2: Any = None) -> Any:
45
+ """Field greater than value."""
46
+ field, val = self._get_target_and_val(arg1, arg2)
47
+ return _resolve_query(field) > val
48
+
49
+ def gte(self, arg1: Any, arg2: Any = None) -> Any:
50
+ """Field greater than or equal to value."""
51
+ field, val = self._get_target_and_val(arg1, arg2)
52
+ return _resolve_query(field) >= val
53
+
54
+ def lt(self, arg1: Any, arg2: Any = None) -> Any:
55
+ """Field less than value."""
56
+ field, val = self._get_target_and_val(arg1, arg2)
57
+ return _resolve_query(field) < val
58
+
59
+ def lte(self, arg1: Any, arg2: Any = None) -> Any:
60
+ """Field less than or equal to value."""
61
+ field, val = self._get_target_and_val(arg1, arg2)
62
+ return _resolve_query(field) <= val
63
+
64
+ def in_list(self, arg1: Any, arg2: Any = None) -> Any:
65
+ """Field value is in list of values."""
66
+ field, val = self._get_target_and_val(arg1, arg2)
67
+ return _resolve_query(field).one_of(val)
68
+
69
+ def matches(self, arg1: Any, arg2: Any = None, flags: int = 0) -> Any:
70
+ """Field matches regular expression pattern."""
71
+ field, pattern = self._get_target_and_val(arg1, arg2)
72
+ return _resolve_query(field).matches(pattern, flags=flags)
73
+
74
+ def exists(self, field: Optional[str] = None) -> Any:
75
+ """Field exists in document."""
76
+ target = field or self.field_name
77
+ if not target:
78
+ raise ValueError("Target field name must be specified.")
79
+ return _resolve_query(target).exists()
80
+
81
+ def search_text(self, arg1: Any, arg2: Any = None, case_sensitive: bool = False) -> Any:
82
+ """Field contains text substring."""
83
+ field, substring = self._get_target_and_val(arg1, arg2)
84
+ if case_sensitive:
85
+ return _resolve_query(field).search(re.escape(str(substring)))
86
+ return _resolve_query(field).search(re.escape(str(substring)), flags=re.IGNORECASE)
87
+
88
+
89
+ def Q(field: str) -> QueryBuilder:
90
+ """Factory helper for QueryBuilder."""
91
+ return QueryBuilder(field)
wtinydb/exceptions.py ADDED
@@ -0,0 +1,29 @@
1
+ """WTinyDB custom exception definitions."""
2
+
3
+
4
+ class WTinyDBError(Exception):
5
+ """Base exception class for all WTinyDB errors."""
6
+
7
+ pass
8
+
9
+
10
+ class DocumentNotFoundError(WTinyDBError):
11
+ """Raised when a requested document is not found in the database collection."""
12
+
13
+ def __init__(self, doc_id: str or int, table_name: str = "default"):
14
+ """Initialize DocumentNotFoundError with doc_id and table_name."""
15
+ super().__init__(f"Document with ID '{doc_id}' not found in table '{table_name}'.")
16
+ self.doc_id = doc_id
17
+ self.table_name = table_name
18
+
19
+
20
+ class ValidationError(WTinyDBError):
21
+ """Raised when document data fails Pydantic schema validation."""
22
+
23
+ pass
24
+
25
+
26
+ class StorageError(WTinyDBError):
27
+ """Raised when a underlying storage or IO error occurs."""
28
+
29
+ pass
wtinydb/models.py ADDED
@@ -0,0 +1,44 @@
1
+ """WTinyDB Base Mixins and Document models."""
2
+
3
+ from datetime import datetime, timezone
4
+ from typing import Optional
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ def _utc_now() -> datetime:
9
+ """Helper returning current UTC datetime."""
10
+ return datetime.now(timezone.utc)
11
+
12
+
13
+ class TimestampMixin(BaseModel):
14
+ """Mixin for models requiring created_at and updated_at timestamps."""
15
+
16
+ created_at: datetime = Field(default_factory=_utc_now, description="Creation timestamp")
17
+ updated_at: datetime = Field(default_factory=_utc_now, description="Last update timestamp")
18
+
19
+ def touch(self) -> None:
20
+ """Update updated_at timestamp to current UTC time."""
21
+ self.updated_at = _utc_now()
22
+
23
+
24
+ class SoftDeleteMixin(BaseModel):
25
+ """Mixin for models implementing logical (soft) deletion."""
26
+
27
+ is_deleted: bool = Field(default=False, description="Flag indicating soft deletion status")
28
+ deleted_at: Optional[datetime] = Field(default=None, description="Timestamp when deleted soft")
29
+
30
+ def soft_delete(self) -> None:
31
+ """Mark object as soft-deleted."""
32
+ self.is_deleted = True
33
+ self.deleted_at = _utc_now()
34
+
35
+ def restore(self) -> None:
36
+ """Restore a soft-deleted object."""
37
+ self.is_deleted = False
38
+ self.deleted_at = None
39
+
40
+
41
+ class AuditMixin(TimestampMixin, SoftDeleteMixin):
42
+ """Combined mixin providing timestamps and soft-delete audit features."""
43
+
44
+ pass
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: wtinydb
3
+ Version: 0.1.0
4
+ Summary: Pydantic-powered document database abstraction layer on top of TinyDB
5
+ Author-email: "WILLIAM R." <william@ecapturedtech.com>
6
+ License: MIT
7
+ Keywords: tinydb,pydantic,nosql,document-database,orm,database
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: tinydb>=4.8.0
12
+ Requires-Dist: pydantic>=2.0.0
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
15
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
16
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
17
+ Dynamic: license-file
18
+
19
+ # WTinyDB: Lightweight Pydantic Document Database Engine
20
+
21
+ `wtinydb` is a high-level, document-oriented NoSQL database abstraction built on top of **TinyDB** and **Pydantic**. It provides schema-validated CRUD operations, fluent query builders, async support, audit mixins, and command-line management for embedded Python applications.
22
+
23
+ ## Key Technologies & Libraries
24
+
25
+ - **[TinyDB](https://tinydb.readthedocs.io/)**: Pure Python document-oriented database storing records as JSON.
26
+ - **[Pydantic v2](https://docs.pydantic.dev/)**: Data validation, settings management, and JSON-schema document serialization.
27
+ - **[Pytest](https://docs.pytest.org/)**: Modern Python testing framework.
28
+ - **[Pytest-Cov](https://pytest-cov.readthedocs.io/)**: Code coverage measurement for pytest.
29
+ - **[Docker](https://www.docker.com/)**: Containerization for reproducible testing environments.
30
+
31
+ ---
32
+
33
+ ## Features
34
+
35
+ - **Pydantic Model Mapping**: Automatic serialization, deserialization, and schema validation.
36
+ - **Fluent Query Builder (`Q`)**: Intuitive query building with comparison operators, regex matching, and text search.
37
+ - **Async Support (`AsyncWTinyDB`)**: Non-blocking thread-pool execution for FastAPI and Starlette apps.
38
+ - **Soft Delete & Audit Mixins**: Built-in support for `created_at`, `updated_at`, `is_deleted`, and `deleted_at`.
39
+ - **Thread Safety**: Integrated reentrant locks for safe multi-threaded access.
40
+ - **CLI Utility**: Command-line tool `wtinydb` to inspect, count, and export JSON tables.
41
+
42
+ ---
43
+
44
+ ## Quickstart Example
45
+
46
+ ```python
47
+ from pydantic import BaseModel, Field
48
+ from wtinydb import WTinyDB, Q, SoftDeleteMixin
49
+
50
+ # Define document schema
51
+ class User(SoftDeleteMixin, BaseModel):
52
+ name: str
53
+ email: str = Field(description="User email")
54
+ age: int = 18
55
+
56
+ # Initialize WTinyDB repository (in-memory or file-backed)
57
+ db = WTinyDB(User, in_memory=True)
58
+
59
+ # 1. Insert
60
+ user = db.insert(User(name="Alice", email="alice@example.com", age=30))
61
+
62
+ # 2. Query
63
+ results = db.find(Q("age").gte("age", 25))
64
+
65
+ # 3. Soft Delete & Restore
66
+ db.delete(1, hard=False)
67
+ ```
68
+
69
+ ---
70
+
71
+ ## Running Unit Tests & Coverage
72
+
73
+ ### Local Pytest Execution
74
+
75
+ ```bash
76
+ # Run pytest test suite
77
+ pytest tests/
78
+
79
+ # Calculate code coverage
80
+ ./scripts/run_coverage.sh
81
+ ```
82
+
83
+ ### Docker Containerized Test Execution
84
+
85
+ All unit tests can be executed inside an isolated Docker container:
86
+
87
+ ```bash
88
+ ./scripts/run_tests_docker.sh
89
+ ```
90
+
91
+ ---
92
+
93
+ *Part of the wisrovi SUITE ecosystem.*
@@ -0,0 +1,15 @@
1
+ wtinydb/__init__.py,sha256=wRpXe7VSFOjizzWDU2sHJdnCm2eZlI74tCKzZycRJ5s,601
2
+ wtinydb/exceptions.py,sha256=QA417CjTjIEDI059Mve-ZuldV4P4bgbXj0mPChhb3Ls,806
3
+ wtinydb/models.py,sha256=3Sanp1lofMbD1bU8eJIhHsB5rRDF51mHuCcpv0-xi7w,1427
4
+ wtinydb/cli/__init__.py,sha256=Jo2NO8371QnDWToQEfEKaHowjz48hEEPvZEWMPOHyi8,31
5
+ wtinydb/cli/main.py,sha256=A0BUMWUSNP_mBhUrNrTlD4dqIIUn8jbVFlzy-kfqCa8,2046
6
+ wtinydb/core/__init__.py,sha256=tTh3bG7xkveyKlExYftWdNEbN_Kfcn_dkrvZmidgYlI,32
7
+ wtinydb/core/async_db.py,sha256=RCPPaMTlELYdze-p1JlOQ3g8fbq94bvqhF_ZketWCgY,4058
8
+ wtinydb/core/database.py,sha256=1WKZDwqw6BWN8CsyIwWu2AzEwh9QjWQZvTOuWMsOmWY,17205
9
+ wtinydb/core/query.py,sha256=jiQqM73TIyX0S7oCRhV9nPVvdLLO5NPuXzYkRwtzflQ,3671
10
+ wtinydb-0.1.0.dist-info/licenses/LICENSE,sha256=Oyqf3mzTxsLVAFwTwTpmcfEsdCeJnA4jMu5Dv-Jq_Ag,1074
11
+ wtinydb-0.1.0.dist-info/METADATA,sha256=5zG652_X3bbaAbYfIQJa2i4I7w4a6Ot4c_67PHPD3dA,3027
12
+ wtinydb-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
13
+ wtinydb-0.1.0.dist-info/entry_points.txt,sha256=RxgMIDaYlqWGmeRLoshQdLjhWplo6HwdZfa6v60BY0E,50
14
+ wtinydb-0.1.0.dist-info/top_level.txt,sha256=2MRK53HEeDm9zW3cY6jj1SnN6gqEmHBCVKRzswGVv0o,8
15
+ wtinydb-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ wtinydb = wtinydb.cli.main:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 William Rodriguez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ wtinydb