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 +23 -0
- wtinydb/cli/__init__.py +1 -0
- wtinydb/cli/main.py +60 -0
- wtinydb/core/__init__.py +1 -0
- wtinydb/core/async_db.py +106 -0
- wtinydb/core/database.py +410 -0
- wtinydb/core/query.py +91 -0
- wtinydb/exceptions.py +29 -0
- wtinydb/models.py +44 -0
- wtinydb-0.1.0.dist-info/METADATA +93 -0
- wtinydb-0.1.0.dist-info/RECORD +15 -0
- wtinydb-0.1.0.dist-info/WHEEL +5 -0
- wtinydb-0.1.0.dist-info/entry_points.txt +2 -0
- wtinydb-0.1.0.dist-info/licenses/LICENSE +21 -0
- wtinydb-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
]
|
wtinydb/cli/__init__.py
ADDED
|
@@ -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()
|
wtinydb/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core modules for WTinyDB."""
|
wtinydb/core/async_db.py
ADDED
|
@@ -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()
|
wtinydb/core/database.py
ADDED
|
@@ -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,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
|