oj-persistence 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ownjoo.org
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,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: oj-persistence
3
+ Version: 0.0.1
4
+ Summary: Thread-safe persistence management with pluggable storage backends
5
+ Author-email: Speedy <speedy@ownjoo.org>
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/ownjoo-org/persistence
8
+ Requires-Python: >=3.11
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: ijson
12
+ Dynamic: license-file
13
+
14
+ # oj-persistence
15
+
16
+ Thread-safe persistence management with pluggable storage backends.
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ pip install oj-persistence
22
+ ```
23
+
24
+ ## Overview
25
+
26
+ `oj-persistence` provides a singleton `PersistenceManager` that acts as the single I/O interface for all data operations. Callers register named stores with the manager and interact exclusively through it — stores are never touched directly for data operations.
27
+
28
+ Designed for use with multi-threaded applications and async pipelines (e.g. [`io_chains`](https://github.com/ownjoo-org/io_chains)).
29
+
30
+ ## Quick start
31
+
32
+ ```python
33
+ from oj_persistence import PersistenceManager, InMemoryStore, NdjsonFileStore
34
+
35
+ pm = PersistenceManager()
36
+
37
+ # Register stores by name
38
+ pm.get_or_create('cache', lambda: InMemoryStore())
39
+ pm.get_or_create('events', lambda: NdjsonFileStore('events.ndjson'))
40
+
41
+ # All data ops go through the manager
42
+ pm.create('cache', 'user:1', {'name': 'Alice'})
43
+ pm.upsert('events', 'evt:1', {'type': 'login', 'user': 'user:1'})
44
+
45
+ value = pm.read('cache', 'user:1') # {'name': 'Alice'}
46
+ pm.update('cache', 'user:1', {'name': 'Alice', 'role': 'admin'})
47
+ pm.delete('cache', 'user:1')
48
+
49
+ results = pm.list('cache', predicate=lambda v: v.get('role') == 'admin')
50
+ ```
51
+
52
+ ## CRUDL semantics
53
+
54
+ | Method | Behaviour |
55
+ |------------|------------------------------------------------|
56
+ | `create` | Raises `KeyError` if key already exists |
57
+ | `read` | Returns `None` if key is missing |
58
+ | `update` | Raises `KeyError` if key does not exist |
59
+ | `upsert` | Creates or overwrites — never raises |
60
+ | `delete` | No-op if key is missing |
61
+ | `list` | Returns all values, optionally filtered |
62
+
63
+ ## Relational joins
64
+
65
+ ```python
66
+ ON = lambda user, order: user['id'] == order['user_id']
67
+
68
+ results = pm.join('users', 'orders', on=ON, how='left',
69
+ where=lambda u, o: o['total'] > 100)
70
+ # returns list of (left, right) tuples; unmatched rows have None on the missing side
71
+ ```
72
+
73
+ Supported `how` values: `inner` (default), `left`, `right`, `outer`.
74
+
75
+ ## Storage backends
76
+
77
+ | Class | Format | Notes |
78
+ |-----------------|---------------------------|------------------------------------|
79
+ | `InMemoryStore` | In-process dict | Fastest; not persistent |
80
+ | `FlatFileStore` | JSON (full load/save) | Simple; loads entire file per op |
81
+ | `NdjsonFileStore` | NDJSON (one obj/line) | Append-only creates; O(1) writes |
82
+ | `IjsonFileStore` | Standard JSON (streaming) | Streaming reads via `ijson` |
83
+ | `CsvFileStore` | CSV | Fieldnames inferred or pre-specified |
84
+
85
+ All file-backed stores are thread-safe via a writer-preferring `ReadWriteLock`.
86
+
87
+ ## Custom stores
88
+
89
+ Implement `AbstractStore`:
90
+
91
+ ```python
92
+ from oj_persistence.store.base import AbstractStore
93
+
94
+ class MyStore(AbstractStore):
95
+ def create(self, key, value): ...
96
+ def read(self, key): ...
97
+ def update(self, key, value): ...
98
+ def upsert(self, key, value): ...
99
+ def delete(self, key): ...
100
+ def list(self, predicate=None): ...
101
+
102
+ pm.register('custom', MyStore())
103
+ ```
104
+
105
+ ## Requirements
106
+
107
+ - Python >= 3.11
108
+ - `ijson` (for `IjsonFileStore`)
@@ -0,0 +1,95 @@
1
+ # oj-persistence
2
+
3
+ Thread-safe persistence management with pluggable storage backends.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install oj-persistence
9
+ ```
10
+
11
+ ## Overview
12
+
13
+ `oj-persistence` provides a singleton `PersistenceManager` that acts as the single I/O interface for all data operations. Callers register named stores with the manager and interact exclusively through it — stores are never touched directly for data operations.
14
+
15
+ Designed for use with multi-threaded applications and async pipelines (e.g. [`io_chains`](https://github.com/ownjoo-org/io_chains)).
16
+
17
+ ## Quick start
18
+
19
+ ```python
20
+ from oj_persistence import PersistenceManager, InMemoryStore, NdjsonFileStore
21
+
22
+ pm = PersistenceManager()
23
+
24
+ # Register stores by name
25
+ pm.get_or_create('cache', lambda: InMemoryStore())
26
+ pm.get_or_create('events', lambda: NdjsonFileStore('events.ndjson'))
27
+
28
+ # All data ops go through the manager
29
+ pm.create('cache', 'user:1', {'name': 'Alice'})
30
+ pm.upsert('events', 'evt:1', {'type': 'login', 'user': 'user:1'})
31
+
32
+ value = pm.read('cache', 'user:1') # {'name': 'Alice'}
33
+ pm.update('cache', 'user:1', {'name': 'Alice', 'role': 'admin'})
34
+ pm.delete('cache', 'user:1')
35
+
36
+ results = pm.list('cache', predicate=lambda v: v.get('role') == 'admin')
37
+ ```
38
+
39
+ ## CRUDL semantics
40
+
41
+ | Method | Behaviour |
42
+ |------------|------------------------------------------------|
43
+ | `create` | Raises `KeyError` if key already exists |
44
+ | `read` | Returns `None` if key is missing |
45
+ | `update` | Raises `KeyError` if key does not exist |
46
+ | `upsert` | Creates or overwrites — never raises |
47
+ | `delete` | No-op if key is missing |
48
+ | `list` | Returns all values, optionally filtered |
49
+
50
+ ## Relational joins
51
+
52
+ ```python
53
+ ON = lambda user, order: user['id'] == order['user_id']
54
+
55
+ results = pm.join('users', 'orders', on=ON, how='left',
56
+ where=lambda u, o: o['total'] > 100)
57
+ # returns list of (left, right) tuples; unmatched rows have None on the missing side
58
+ ```
59
+
60
+ Supported `how` values: `inner` (default), `left`, `right`, `outer`.
61
+
62
+ ## Storage backends
63
+
64
+ | Class | Format | Notes |
65
+ |-----------------|---------------------------|------------------------------------|
66
+ | `InMemoryStore` | In-process dict | Fastest; not persistent |
67
+ | `FlatFileStore` | JSON (full load/save) | Simple; loads entire file per op |
68
+ | `NdjsonFileStore` | NDJSON (one obj/line) | Append-only creates; O(1) writes |
69
+ | `IjsonFileStore` | Standard JSON (streaming) | Streaming reads via `ijson` |
70
+ | `CsvFileStore` | CSV | Fieldnames inferred or pre-specified |
71
+
72
+ All file-backed stores are thread-safe via a writer-preferring `ReadWriteLock`.
73
+
74
+ ## Custom stores
75
+
76
+ Implement `AbstractStore`:
77
+
78
+ ```python
79
+ from oj_persistence.store.base import AbstractStore
80
+
81
+ class MyStore(AbstractStore):
82
+ def create(self, key, value): ...
83
+ def read(self, key): ...
84
+ def update(self, key, value): ...
85
+ def upsert(self, key, value): ...
86
+ def delete(self, key): ...
87
+ def list(self, predicate=None): ...
88
+
89
+ pm.register('custom', MyStore())
90
+ ```
91
+
92
+ ## Requirements
93
+
94
+ - Python >= 3.11
95
+ - `ijson` (for `IjsonFileStore`)
@@ -0,0 +1,17 @@
1
+ from oj_persistence.manager import PersistenceManager
2
+ from oj_persistence.store.base import AbstractStore
3
+ from oj_persistence.store.csv_file import CsvFileStore
4
+ from oj_persistence.store.flat_file import FlatFileStore
5
+ from oj_persistence.store.ijson_file import IjsonFileStore
6
+ from oj_persistence.store.in_memory import InMemoryStore
7
+ from oj_persistence.store.ndjson_file import NdjsonFileStore
8
+
9
+ __all__ = [
10
+ 'PersistenceManager',
11
+ 'AbstractStore',
12
+ 'CsvFileStore',
13
+ 'FlatFileStore',
14
+ 'IjsonFileStore',
15
+ 'InMemoryStore',
16
+ 'NdjsonFileStore',
17
+ ]
@@ -0,0 +1,194 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ from typing import Any, Callable, Optional
5
+
6
+ from oj_persistence.store.base import AbstractStore
7
+
8
+ _VALID_JOIN_TYPES = {'inner', 'left', 'right', 'outer'}
9
+
10
+
11
+ class PersistenceManager:
12
+ """
13
+ Singleton registry of named AbstractStore instances.
14
+
15
+ One instance per process — all threads share the same registry.
16
+ Thread safety is guaranteed by a class-level lock during construction
17
+ and an instance-level lock for registry mutations.
18
+
19
+ Correct usage — all data operations go through the manager:
20
+
21
+ pm = PersistenceManager()
22
+ pm.get_or_create('users', lambda: InMemoryStore()) # register once
23
+ pm.create('users', 'u1', {'name': 'Alice'})
24
+ pm.read('users', 'u1')
25
+ pm.update('users', 'u1', {'name': 'Bob'})
26
+ pm.upsert('users', 'u1', {'name': 'Charlie'})
27
+ pm.delete('users', 'u1')
28
+ pm.list('users')
29
+ pm.join('users', 'orders', on=lambda u, o: u['id'] == o['user_id'])
30
+
31
+ Stores are thread-safe independently (RWLock), but the correct usage model
32
+ is that threads share only the manager singleton — they never hold a
33
+ reference to a store and call its methods directly.
34
+ """
35
+
36
+ _instance: Optional['PersistenceManager'] = None
37
+ _init_lock: threading.Lock = threading.Lock()
38
+
39
+ def __new__(cls) -> 'PersistenceManager':
40
+ if cls._instance is None:
41
+ with cls._init_lock:
42
+ if cls._instance is None:
43
+ instance = super().__new__(cls)
44
+ instance._stores: dict[str, AbstractStore] = {}
45
+ instance._registry_lock = threading.Lock()
46
+ cls._instance = instance
47
+ return cls._instance
48
+
49
+ def get_or_create(self, name: str, factory: Callable[[], AbstractStore]) -> AbstractStore:
50
+ """
51
+ Return the store registered under name, creating it via factory if absent.
52
+
53
+ The factory is called at most once per name. Subsequent calls with the
54
+ same name return the existing store regardless of the factory provided.
55
+ Thread-safe: factory is never called concurrently for the same name.
56
+ """
57
+ store = self._stores.get(name)
58
+ if store is None:
59
+ with self._registry_lock:
60
+ store = self._stores.get(name)
61
+ if store is None:
62
+ store = factory()
63
+ self._stores[name] = store
64
+ return store
65
+
66
+ def register(self, name: str, store: AbstractStore) -> None:
67
+ """Register a store under name, replacing any existing entry."""
68
+ with self._registry_lock:
69
+ self._stores[name] = store
70
+
71
+ def get_store(self, name: str) -> Optional[AbstractStore]:
72
+ """Return the store registered under name, or None."""
73
+ return self._stores.get(name)
74
+
75
+ def unregister(self, name: str) -> None:
76
+ """Remove the store registered under name. No-op if not found."""
77
+ with self._registry_lock:
78
+ self._stores.pop(name, None)
79
+
80
+ # ------------------------------------------------------------------
81
+ # CRUDL — the primary data interface for callers
82
+ # ------------------------------------------------------------------
83
+
84
+ def _get_required(self, name: str) -> AbstractStore:
85
+ store = self._stores.get(name)
86
+ if store is None:
87
+ raise KeyError(name)
88
+ return store
89
+
90
+ def create(self, store_name: str, key: str, value: Any) -> None:
91
+ """Create a new entry in the named store. Raises KeyError if store or key already exists."""
92
+ self._get_required(store_name).create(key, value)
93
+
94
+ def read(self, store_name: str, key: str) -> Any:
95
+ """Return the value for key from the named store, or None if not found."""
96
+ return self._get_required(store_name).read(key)
97
+
98
+ def update(self, store_name: str, key: str, value: Any) -> None:
99
+ """Update an existing entry. Raises KeyError if store or key not found."""
100
+ self._get_required(store_name).update(key, value)
101
+
102
+ def upsert(self, store_name: str, key: str, value: Any) -> None:
103
+ """Create or overwrite an entry in the named store."""
104
+ self._get_required(store_name).upsert(key, value)
105
+
106
+ def delete(self, store_name: str, key: str) -> None:
107
+ """Remove an entry from the named store. No-op if key not found."""
108
+ self._get_required(store_name).delete(key)
109
+
110
+ def list(self, store_name: str, predicate: Optional[Callable[[Any], bool]] = None) -> list[Any]:
111
+ """Return all values from the named store, optionally filtered by predicate."""
112
+ return self._get_required(store_name).list(predicate)
113
+
114
+ def join(
115
+ self,
116
+ left: str,
117
+ right: str,
118
+ on: Callable[[Any, Any], bool],
119
+ how: str = 'inner',
120
+ where: Optional[Callable[[Any, Any], bool]] = None,
121
+ ) -> list[tuple[Any, Any]]:
122
+ """
123
+ Relational join across two registered stores.
124
+
125
+ Parameters
126
+ ----------
127
+ left, right : store names (must be registered)
128
+ on : join predicate — called as on(left_val, right_val)
129
+ how : 'inner' | 'left' | 'right' | 'outer'
130
+ where : optional filter applied only to matched pairs (both non-None);
131
+ unmatched rows produced by left/right/outer are always included
132
+
133
+ Returns
134
+ -------
135
+ list of (left_val, right_val) tuples. Unmatched sides are None.
136
+
137
+ Complexity
138
+ ----------
139
+ O(m × n) — naive nested loop over store.list() on both sides.
140
+ DB-backed stores (SQLite, Postgres, SQLAlchemy, …) should override
141
+ with a delegated query rather than relying on this implementation.
142
+ """
143
+ if how not in _VALID_JOIN_TYPES:
144
+ raise ValueError(f"Invalid how='{how}'. Expected one of: {sorted(_VALID_JOIN_TYPES)}")
145
+
146
+ left_vals = self._get_required(left).list()
147
+ right_vals = self._get_required(right).list()
148
+ results: list[tuple[Any, Any]] = []
149
+
150
+ if how == 'inner':
151
+ for lv in left_vals:
152
+ for rv in right_vals:
153
+ if on(lv, rv) and (where is None or where(lv, rv)):
154
+ results.append((lv, rv))
155
+
156
+ elif how == 'left':
157
+ for lv in left_vals:
158
+ has_on_match = False
159
+ for rv in right_vals:
160
+ if on(lv, rv):
161
+ has_on_match = True
162
+ if where is None or where(lv, rv):
163
+ results.append((lv, rv))
164
+ if not has_on_match:
165
+ results.append((lv, None))
166
+
167
+ elif how == 'right':
168
+ for rv in right_vals:
169
+ has_on_match = False
170
+ for lv in left_vals:
171
+ if on(lv, rv):
172
+ has_on_match = True
173
+ if where is None or where(lv, rv):
174
+ results.append((lv, rv))
175
+ if not has_on_match:
176
+ results.append((None, rv))
177
+
178
+ elif how == 'outer':
179
+ matched_right: set[int] = set()
180
+ for lv in left_vals:
181
+ has_on_match = False
182
+ for i, rv in enumerate(right_vals):
183
+ if on(lv, rv):
184
+ has_on_match = True
185
+ matched_right.add(i)
186
+ if where is None or where(lv, rv):
187
+ results.append((lv, rv))
188
+ if not has_on_match:
189
+ results.append((lv, None))
190
+ for i, rv in enumerate(right_vals):
191
+ if i not in matched_right:
192
+ results.append((None, rv))
193
+
194
+ return results
@@ -0,0 +1,15 @@
1
+ from oj_persistence.store.base import AbstractStore
2
+ from oj_persistence.store.csv_file import CsvFileStore
3
+ from oj_persistence.store.flat_file import FlatFileStore
4
+ from oj_persistence.store.ijson_file import IjsonFileStore
5
+ from oj_persistence.store.in_memory import InMemoryStore
6
+ from oj_persistence.store.ndjson_file import NdjsonFileStore
7
+
8
+ __all__ = [
9
+ 'AbstractStore',
10
+ 'CsvFileStore',
11
+ 'FlatFileStore',
12
+ 'IjsonFileStore',
13
+ 'InMemoryStore',
14
+ 'NdjsonFileStore',
15
+ ]
@@ -0,0 +1,44 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any, Callable, Optional
3
+
4
+
5
+ class AbstractStore(ABC):
6
+ """
7
+ CRUDL interface for all persistence backends.
8
+
9
+ Strict create/update semantics:
10
+ - create() raises KeyError if the key already exists
11
+ - update() raises KeyError if the key does not exist
12
+ - upsert() always succeeds (create or update)
13
+ - read() returns None for missing keys (no raise)
14
+ - delete() is a no-op for missing keys (no raise)
15
+ - list() returns all values, optionally filtered by predicate
16
+
17
+ Implementations must be thread-safe. Async variants are planned for a
18
+ future release to support io_chains integration as an async source or
19
+ subscriber.
20
+ """
21
+
22
+ @abstractmethod
23
+ def create(self, key: str, value: Any) -> None:
24
+ """Store value under key. Raises KeyError if key already exists."""
25
+
26
+ @abstractmethod
27
+ def read(self, key: str) -> Any:
28
+ """Return the value for key, or None if not found."""
29
+
30
+ @abstractmethod
31
+ def update(self, key: str, value: Any) -> None:
32
+ """Update value for an existing key. Raises KeyError if not found."""
33
+
34
+ @abstractmethod
35
+ def upsert(self, key: str, value: Any) -> None:
36
+ """Store value under key, creating or overwriting as needed."""
37
+
38
+ @abstractmethod
39
+ def delete(self, key: str) -> None:
40
+ """Remove the entry for key. No-op if key does not exist."""
41
+
42
+ @abstractmethod
43
+ def list(self, predicate: Optional[Callable[[Any], bool]] = None) -> list[Any]:
44
+ """Return all values, or only those for which predicate(value) is True."""