tinymongo 1.0.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.
tinymongo/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ try:
2
+ from tinymongo.tinymongo import * # noqa
3
+ except ImportError:
4
+ from tinymongo import * # noqa
tinymongo/errors.py ADDED
@@ -0,0 +1,61 @@
1
+ """Exceptions raised by TinyMongo."""
2
+
3
+
4
+ class TinyMongoError(Exception):
5
+ """Base class for all TinyMongo exceptions."""
6
+
7
+
8
+ class ConnectionFailure(TinyMongoError):
9
+ """Raised when a connection to the database file cannot be made or is lost.
10
+ """
11
+
12
+
13
+ class ConfigurationError(TinyMongoError):
14
+ """Raised when something is incorrectly configured.
15
+ """
16
+
17
+
18
+ class OperationFailure(TinyMongoError):
19
+ """Raised when a database operation fails.
20
+ """
21
+
22
+ def __init__(self, error, code=None, details=None):
23
+ self.__code = code
24
+ self.__details = details
25
+ TinyMongoError.__init__(self, error)
26
+
27
+ @property
28
+ def code(self):
29
+ """The error code returned by the server, if any.
30
+ """
31
+ return self.__code
32
+
33
+ @property
34
+ def details(self):
35
+ """The complete error document returned by the server.
36
+
37
+ Depending on the error that occurred, the error document
38
+ may include useful information beyond just the error
39
+ message. When connected to a mongos the error document
40
+ may contain one or more subdocuments if errors occurred
41
+ on multiple shards.
42
+ """
43
+ return self.__details
44
+
45
+
46
+ class CursorNotFound(OperationFailure):
47
+ """Raised while iterating query results if the cursor is
48
+ invalidated on the server.
49
+ """
50
+
51
+
52
+ class WriteError(OperationFailure):
53
+ """Base exception type for errors raised during write operations."""
54
+
55
+
56
+ class DuplicateKeyError(WriteError):
57
+ """Raised when an insert or update fails due to a duplicate key error."""
58
+
59
+
60
+ class InvalidOperation(TinyMongoError):
61
+ """Raised when a client attempts to perform an invalid operation."""
@@ -0,0 +1,256 @@
1
+ import json
2
+ import os
3
+ import tempfile
4
+ from tinydb.storages import Storage
5
+ import threading
6
+
7
+ try:
8
+ import pyarrow as pa
9
+ import pyarrow.parquet as pq
10
+ except Exception: # pragma: no cover - graceful fallback
11
+ pa = None
12
+ pq = None
13
+
14
+ try:
15
+ import portalocker
16
+ except Exception: # pragma: no cover
17
+ portalocker = None
18
+
19
+
20
+ # In-process reentrant locks per lock path to avoid nested portalocker
21
+ # acquisitions causing AlreadyLocked errors when the same process/thread
22
+ # re-enters storage write paths.
23
+ _local_rlocks = {}
24
+
25
+
26
+ def _fsync_dir(path):
27
+ try:
28
+ fd = os.open(path, os.O_RDONLY)
29
+ try:
30
+ os.fsync(fd)
31
+ finally:
32
+ os.close(fd)
33
+ except Exception:
34
+ pass
35
+
36
+
37
+ def _acquire_rlock(rlock):
38
+ try:
39
+ if rlock._is_owned():
40
+ rlock.acquire()
41
+ return False
42
+ except Exception:
43
+ pass
44
+
45
+ first_acquire = rlock.acquire(blocking=False)
46
+ if not first_acquire:
47
+ rlock.acquire()
48
+ return False
49
+ return True
50
+
51
+
52
+ class ParquetStorage(Storage):
53
+ """TinyDB Storage that persists the entire DB as a single Parquet row
54
+
55
+ The DB dict is serialized to JSON and stored in a single column named
56
+ 'data'. To coordinate concurrent writers we use a folder-scoped lock
57
+ file named '.tinymongo.lock'."""
58
+
59
+ def __init__(self, path):
60
+ self.path = path
61
+
62
+ def read(self):
63
+ if not os.path.exists(self.path):
64
+ return {}
65
+
66
+ dname = os.path.dirname(self.path) or "."
67
+ lock_path = os.path.join(dname, ".tinymongo.lock")
68
+
69
+ rlock = _local_rlocks.setdefault(lock_path, threading.RLock())
70
+ first_acquire = _acquire_rlock(rlock)
71
+
72
+ portalocker_lock = None
73
+ try:
74
+ if first_acquire and portalocker is not None:
75
+ portalocker_lock = portalocker.Lock(lock_path, timeout=30)
76
+ portalocker_lock.acquire()
77
+
78
+ if pq is None:
79
+ try:
80
+ with open(self.path, "r", encoding="utf8") as f:
81
+ return json.load(f)
82
+ except Exception:
83
+ return {}
84
+
85
+ try:
86
+ table = pq.read_table(self.path)
87
+ if "data" not in table.column_names:
88
+ return {}
89
+ data_arr = table.column("data").to_pylist()
90
+ if not data_arr:
91
+ return {}
92
+ return json.loads(data_arr[0])
93
+ except Exception:
94
+ return {}
95
+ finally:
96
+ if portalocker_lock is not None:
97
+ try:
98
+ portalocker_lock.release()
99
+ except Exception:
100
+ pass
101
+ try:
102
+ rlock.release()
103
+ except Exception:
104
+ pass
105
+
106
+ def write(self, data):
107
+ # serialize the entire TinyDB dict to a JSON string
108
+ json_str = json.dumps(data or {}, ensure_ascii=False)
109
+
110
+ # ensure parent dir exists
111
+ dname = os.path.dirname(self.path) or "."
112
+ os.makedirs(dname, exist_ok=True)
113
+
114
+ lock_path = os.path.join(dname, ".tinymongo.lock")
115
+
116
+ # Acquire an in-process reentrant lock to allow nested calls inside
117
+ # the same process to proceed without re-acquiring the OS-level
118
+ # portalocker lock. Only the first acquirer in the process will
119
+ # perform the portalocker acquisition for inter-process safety.
120
+ rlock = _local_rlocks.setdefault(lock_path, threading.RLock())
121
+ first_acquire = _acquire_rlock(rlock)
122
+
123
+ portalocker_lock = None
124
+ try:
125
+ if pq is None:
126
+ # No pyarrow — merge into existing JSON storage
127
+ existing = {}
128
+ if os.path.exists(self.path):
129
+ try:
130
+ with open(self.path, "r", encoding="utf8") as f:
131
+ existing = json.load(f) or {}
132
+ except Exception:
133
+ existing = {}
134
+
135
+ # Merge incoming into existing, matching on logical `_id`.
136
+ merged = {}
137
+ incoming = data or {}
138
+ for tname, table_data in existing.items():
139
+ merged[str(tname)] = {str(k): v for k, v in (table_data or {}).items()}
140
+
141
+ for tname, table_data in incoming.items():
142
+ t = str(tname)
143
+ incoming_table = {str(k): v for k, v in (table_data or {}).items()}
144
+ existing_table = merged.get(t, {})
145
+
146
+ # map existing _id -> eid
147
+ id_to_eid = {v.get('_id'): k for k, v in existing_table.items() if isinstance(v, dict) and '_id' in v}
148
+ try:
149
+ next_eid = max(int(k) for k in existing_table.keys()) + 1
150
+ except Exception:
151
+ next_eid = 1
152
+
153
+ for k, v in incoming_table.items():
154
+ doc_id = v.get('_id') if isinstance(v, dict) else None
155
+ if doc_id is not None and doc_id in id_to_eid:
156
+ existing_table[id_to_eid[doc_id]] = v
157
+ else:
158
+ existing_table[str(next_eid)] = v
159
+ next_eid += 1
160
+
161
+ merged[t] = existing_table
162
+
163
+ json_str = json.dumps(merged, ensure_ascii=False)
164
+ fd, tmp = tempfile.mkstemp(prefix="tmp", dir=dname)
165
+ try:
166
+ with os.fdopen(fd, "w", encoding="utf8") as f:
167
+ f.write(json_str)
168
+ f.flush()
169
+ os.fsync(f.fileno())
170
+ os.replace(tmp, self.path)
171
+ _fsync_dir(dname)
172
+ finally:
173
+ if os.path.exists(tmp):
174
+ try:
175
+ os.remove(tmp)
176
+ except Exception:
177
+ pass
178
+ return
179
+
180
+ # read existing parquet into dict
181
+ existing = {}
182
+ if os.path.exists(self.path):
183
+ try:
184
+ table = pq.read_table(self.path)
185
+ if "data" in table.column_names:
186
+ data_arr = table.column("data").to_pylist()
187
+ if data_arr:
188
+ existing = json.loads(data_arr[0])
189
+ except Exception:
190
+ existing = {}
191
+
192
+ # Merge incoming into existing, matching on logical `_id`.
193
+ merged = {}
194
+ incoming = data or {}
195
+ for tname, table_data in existing.items():
196
+ merged[str(tname)] = {str(k): v for k, v in (table_data or {}).items()}
197
+
198
+ for tname, table_data in incoming.items():
199
+ t = str(tname)
200
+ incoming_table = {str(k): v for k, v in (table_data or {}).items()}
201
+ existing_table = merged.get(t, {})
202
+
203
+ id_to_eid = {v.get('_id'): k for k, v in existing_table.items() if isinstance(v, dict) and '_id' in v}
204
+ try:
205
+ next_eid = max(int(k) for k in existing_table.keys()) + 1
206
+ except Exception:
207
+ next_eid = 1
208
+
209
+ for k, v in incoming_table.items():
210
+ doc_id = v.get('_id') if isinstance(v, dict) else None
211
+ if doc_id is not None and doc_id in id_to_eid:
212
+ existing_table[id_to_eid[doc_id]] = v
213
+ else:
214
+ existing_table[str(next_eid)] = v
215
+ next_eid += 1
216
+
217
+ merged[t] = existing_table
218
+
219
+ json_str = json.dumps(merged, ensure_ascii=False)
220
+
221
+ # write Parquet with single column 'data'
222
+ arr = pa.array([json_str], type=pa.string())
223
+ table = pa.table({"data": arr})
224
+
225
+ fd, tmp = tempfile.mkstemp(prefix="tmp", dir=dname)
226
+ os.close(fd)
227
+ try:
228
+ # write with Parquet v2 layout
229
+ pq.write_table(table, tmp, version="2.6")
230
+ # fsync file
231
+ try:
232
+ with open(tmp, "rb") as f:
233
+ f.flush()
234
+ os.fsync(f.fileno())
235
+ except Exception:
236
+ pass
237
+ os.replace(tmp, self.path)
238
+ _fsync_dir(dname)
239
+ finally:
240
+ if os.path.exists(tmp):
241
+ try:
242
+ os.remove(tmp)
243
+ except Exception:
244
+ pass
245
+ finally:
246
+ # Release portalocker only if we acquired it here.
247
+ if 'portalocker_lock' in locals() and portalocker_lock is not None:
248
+ try:
249
+ portalocker_lock.release()
250
+ except Exception:
251
+ pass
252
+ # Release in-process reentrant lock once.
253
+ try:
254
+ rlock.release()
255
+ except Exception:
256
+ pass
tinymongo/results.py ADDED
@@ -0,0 +1,115 @@
1
+ """Result class definitions."""
2
+
3
+
4
+ class _WriteResult(object):
5
+ """Base class for write result classes."""
6
+
7
+ def __init__(self, acknowledged=True):
8
+ self.acknowledged = acknowledged # here only to PyMongo compat
9
+
10
+
11
+ class InsertOneResult(_WriteResult):
12
+ """The return type for :meth:`~tinymongo.TinyMongoCollection.insert_one`.
13
+ """
14
+
15
+ __slots__ = ("__inserted_id", "__acknowledged", "__eid")
16
+
17
+ def __init__(self, eid, inserted_id, acknowledged=True):
18
+ self.__eid = eid
19
+ self.__inserted_id = inserted_id
20
+ super(InsertOneResult, self).__init__(acknowledged)
21
+
22
+ @property
23
+ def inserted_id(self):
24
+ """The inserted document's _id."""
25
+ return self.__inserted_id
26
+
27
+ @property
28
+ def eid(self):
29
+ """The inserted document's tinyDB eid."""
30
+ return self.__eid
31
+
32
+
33
+ class InsertManyResult(_WriteResult):
34
+ """The return type for :meth:`~tinymongo.TinyMongoCollection.insert_many`.
35
+ """
36
+
37
+ __slots__ = ("__inserted_ids", "__acknowledged", "__eids")
38
+
39
+ def __init__(self, eids, inserted_ids, acknowledged=True):
40
+ self.__eids = eids
41
+ self.__inserted_ids = inserted_ids
42
+ super(InsertManyResult, self).__init__(acknowledged)
43
+
44
+ @property
45
+ def inserted_ids(self):
46
+ """A list of _ids of the inserted documents, in the order provided."""
47
+ return self.__inserted_ids
48
+
49
+ @property
50
+ def eids(self):
51
+ """A list of _ids of the inserted documents, in the order provided."""
52
+ return self.__eids
53
+
54
+
55
+ class UpdateResult(_WriteResult):
56
+ """The return type for :meth:`~tinymongo.TinyMongoCollection.update_one`,
57
+ :meth:`~tinymongo.TinyMongoCollection.update_many`, and
58
+ :meth:`~tinymongo.TinyMongoCollection.replace_one`.
59
+ """
60
+
61
+ __slots__ = ("__raw_result", "__acknowledged")
62
+
63
+ def __init__(self, raw_result, acknowledged=True):
64
+ self.__raw_result = raw_result
65
+ super(UpdateResult, self).__init__(acknowledged)
66
+
67
+ @property
68
+ def raw_result(self):
69
+ """The raw result document returned by the server."""
70
+ return self.__raw_result
71
+
72
+ @property
73
+ def matched_count(self):
74
+ """The number of documents matched for this update."""
75
+ if isinstance(self.raw_result, list):
76
+ return len(self.raw_result)
77
+ return 0
78
+
79
+ @property
80
+ def modified_count(self):
81
+ """The number of documents modified."""
82
+ if isinstance(self.raw_result, list):
83
+ return len(self.raw_result)
84
+ return 0
85
+
86
+ @property
87
+ def upserted_id(self):
88
+ """The _id of the inserted document if an upsert took place. Otherwise
89
+ ``None``.
90
+ """
91
+ return None
92
+
93
+
94
+ class DeleteResult(_WriteResult):
95
+ """The return type for :meth:`~tinymongo.TinyMongoCollection.delete_one`
96
+ and :meth:`~tinymongo.TinyMongoCollection.delete_many`"""
97
+
98
+ __slots__ = ("__raw_result", "__acknowledged")
99
+
100
+ def __init__(self, raw_result, acknowledged=True):
101
+ self.__raw_result = raw_result
102
+ super(DeleteResult, self).__init__(acknowledged)
103
+
104
+ @property
105
+ def raw_result(self):
106
+ """The raw result document returned by the server."""
107
+ return self.__raw_result
108
+
109
+ @property
110
+ def deleted_count(self):
111
+ """The number of documents deleted."""
112
+ if isinstance(self.raw_result, list):
113
+ return len(self.raw_result)
114
+ else:
115
+ return self.raw_result
@@ -0,0 +1,22 @@
1
+ from datetime import datetime
2
+ try:
3
+ from tinydb_serialization import Serializer
4
+ except ImportError:
5
+ raise RuntimeError(
6
+ u'Cannot import tinydb_serialization due to {0} '
7
+ u'you need to run `pip install tinydb_serialization`'
8
+ )
9
+
10
+
11
+ class DateTimeSerializer(Serializer):
12
+ OBJ_CLASS = datetime
13
+
14
+ def __init__(self, dateformat='%Y-%m-%dT%H:%M:%S', *args, **kwargs):
15
+ # super(DateTimeSerializer, self).__init__(*args, **kwargs)
16
+ self._format = dateformat
17
+
18
+ def encode(self, obj):
19
+ return obj.strftime(self._format)
20
+
21
+ def decode(self, s):
22
+ return self.OBJ_CLASS.strptime(s, self._format)
@@ -0,0 +1,174 @@
1
+ import json
2
+ import os
3
+ import sqlite3
4
+ import tempfile
5
+ from tinydb.storages import Storage
6
+
7
+ try:
8
+ import duckdb
9
+ except Exception:
10
+ duckdb = None
11
+
12
+
13
+ def _fsync_dir(path):
14
+ try:
15
+ fd = os.open(path, os.O_RDONLY)
16
+ try:
17
+ os.fsync(fd)
18
+ finally:
19
+ os.close(fd)
20
+ except Exception:
21
+ pass
22
+
23
+
24
+ class SQLiteStorage(Storage):
25
+ """TinyDB storage backend using SQLite as a single-row JSON store."""
26
+
27
+ def __init__(self, path):
28
+ self.path = path
29
+
30
+ def read(self):
31
+ if not os.path.exists(self.path):
32
+ return {}
33
+
34
+ try:
35
+ conn = sqlite3.connect(self.path)
36
+ cursor = conn.cursor()
37
+ cursor.execute(
38
+ "CREATE TABLE IF NOT EXISTS tinydb(id INTEGER PRIMARY KEY, data TEXT)"
39
+ )
40
+ cursor.execute("SELECT data FROM tinydb WHERE id = 1")
41
+ row = cursor.fetchone()
42
+ conn.close()
43
+ if row is None or row[0] is None:
44
+ return {}
45
+ return json.loads(row[0])
46
+ except Exception:
47
+ return {}
48
+
49
+ def write(self, data):
50
+ json_str = json.dumps(data or {}, ensure_ascii=False)
51
+ dname = os.path.dirname(self.path) or "."
52
+ os.makedirs(dname, exist_ok=True)
53
+
54
+ fd, tmp = tempfile.mkstemp(prefix="tmp", dir=dname)
55
+ os.close(fd)
56
+ try:
57
+ conn = sqlite3.connect(tmp)
58
+ cursor = conn.cursor()
59
+ cursor.execute(
60
+ "PRAGMA journal_mode = OFF"
61
+ )
62
+ cursor.execute(
63
+ "CREATE TABLE IF NOT EXISTS tinydb(id INTEGER PRIMARY KEY, data TEXT)"
64
+ )
65
+ cursor.execute(
66
+ "INSERT OR REPLACE INTO tinydb(id, data) VALUES(1, ?)"
67
+ , (json_str,)
68
+ )
69
+ conn.commit()
70
+ conn.close()
71
+ os.replace(tmp, self.path)
72
+ _fsync_dir(dname)
73
+ finally:
74
+ if os.path.exists(tmp):
75
+ try:
76
+ os.remove(tmp)
77
+ except Exception:
78
+ pass
79
+
80
+
81
+ class DuckDBStorage(Storage):
82
+ """TinyDB storage backend using DuckDB as a single-row JSON store."""
83
+
84
+ def __init__(self, path):
85
+ if duckdb is None:
86
+ raise ImportError(
87
+ "duckdb backend requires the duckdb package to be installed"
88
+ )
89
+ self.path = path
90
+
91
+ def read(self):
92
+ if not os.path.exists(self.path):
93
+ return {}
94
+
95
+ try:
96
+ conn = duckdb.connect(self.path)
97
+ result = conn.execute("SELECT data FROM tinydb WHERE id = 1").fetchone()
98
+ conn.close()
99
+ if result is None or result[0] is None:
100
+ return {}
101
+ return json.loads(result[0])
102
+ except Exception:
103
+ return {}
104
+
105
+ def write(self, data):
106
+ json_str = json.dumps(data or {}, ensure_ascii=False)
107
+ dname = os.path.dirname(self.path) or "."
108
+ os.makedirs(dname, exist_ok=True)
109
+
110
+ fd, tmp = tempfile.mkstemp(prefix="tmp", dir=dname)
111
+ os.close(fd)
112
+ try:
113
+ conn = duckdb.connect(tmp)
114
+ conn.execute("CREATE TABLE IF NOT EXISTS tinydb(id INTEGER PRIMARY KEY, data TEXT)")
115
+ conn.execute(
116
+ "INSERT OR REPLACE INTO tinydb(id, data) VALUES(1, ?)",
117
+ (json_str,)
118
+ )
119
+ conn.close()
120
+ os.replace(tmp, self.path)
121
+ _fsync_dir(dname)
122
+ finally:
123
+ if os.path.exists(tmp):
124
+ try:
125
+ os.remove(tmp)
126
+ except Exception:
127
+ pass
128
+
129
+
130
+ def get_storage_class(name):
131
+ if name is None:
132
+ name = "tinydb"
133
+
134
+ if isinstance(name, type):
135
+ return name
136
+
137
+ backend = str(name).lower()
138
+
139
+ if backend in ("tinydb", "json"):
140
+ from tinydb import TinyDB
141
+
142
+ # TinyDB 3.x exposes DEFAULT_STORAGE instead of default_storage_class.
143
+ return getattr(TinyDB, "default_storage_class", TinyDB.DEFAULT_STORAGE)
144
+ if backend in ("parquet", "parquetv2"):
145
+ from .parquet_storage import ParquetStorage
146
+
147
+ return ParquetStorage
148
+ if backend == "sqlite":
149
+ return SQLiteStorage
150
+ if backend == "duckdb":
151
+ return DuckDBStorage
152
+
153
+ raise ValueError(
154
+ "Unsupported backend '{0}'. Supported backends: tinydb, parquet, parquetv2, sqlite, duckdb.".format(
155
+ name
156
+ )
157
+ )
158
+
159
+
160
+ def storage_extension(name):
161
+ backend = str(name).lower()
162
+ if backend in ("tinydb", "json"):
163
+ return ".json"
164
+ if backend in ("parquet", "parquetv2"):
165
+ return ".parquet"
166
+ if backend == "sqlite":
167
+ return ".sqlite"
168
+ if backend == "duckdb":
169
+ return ".duckdb"
170
+ raise ValueError(
171
+ "Unsupported backend '{0}'. Supported backends: tinydb, parquet, parquetv2, sqlite, duckdb.".format(
172
+ name
173
+ )
174
+ )