sapling-db 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.3
2
+ Name: sapling-db
3
+ Version: 0.1.0
4
+ Summary: Add your description here
5
+ Author: Justin Chapman
6
+ Author-email: Justin Chapman <commonmodestudio@gmail.com>
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Typing :: Typed
15
+ Requires-Dist: pydantic>=2.12.5
16
+ Requires-Dist: pydantic-settings>=2.12.0
17
+ Requires-Python: >=3.12
18
+ Description-Content-Type: text/markdown
19
+
20
+ # sapling
21
+
22
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
23
+ [![PyPI](https://img.shields.io/pypi/v/sapling-db)](https://pypi.org/project/sapling-db/)
24
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
25
+ [![python](https://img.shields.io/badge/python-3.12%20%7C%203.13%20%7C%203.14-blue.svg)](https://python.org)
26
+ [![CI](https://github.com/thejchap/sapling/actions/workflows/ci.yml/badge.svg)](https://github.com/thejchap/sapling/actions/workflows/ci.yml)
27
+
28
+ ## installation
29
+
30
+ ```
31
+ pip install sapling-db
32
+ ```
33
+
34
+ ## overview
35
+
36
+ simple, zero-setup persistence for pydantic models
37
+
38
+ ```python
39
+ from pydantic import BaseModel
40
+ from sapling import Database
41
+
42
+
43
+ class User(BaseModel):
44
+ name: str
45
+ email: str
46
+
47
+
48
+ db = Database()
49
+
50
+ with db.transaction() as txn:
51
+ user = User(name="alice", email="alice@example.com")
52
+ doc = txn.put(User, "123", user)
53
+ txn.fetch(User, "123")
54
+ ```
55
+
56
+ **features:**
57
+
58
+ - **fully typed** - complete type safety with ide autocomplete and type checking
59
+ - **zero setup** - works out of the box with no configuration
60
+ - **pydantic native** - designed specifically for pydantic models
61
+ - **fastapi ready** - seamless dependency injection for request-scoped transactions
62
+ - **sqlite backed** - solid, battle-tested storage
@@ -0,0 +1,43 @@
1
+ # sapling
2
+
3
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
4
+ [![PyPI](https://img.shields.io/pypi/v/sapling-db)](https://pypi.org/project/sapling-db/)
5
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+ [![python](https://img.shields.io/badge/python-3.12%20%7C%203.13%20%7C%203.14-blue.svg)](https://python.org)
7
+ [![CI](https://github.com/thejchap/sapling/actions/workflows/ci.yml/badge.svg)](https://github.com/thejchap/sapling/actions/workflows/ci.yml)
8
+
9
+ ## installation
10
+
11
+ ```
12
+ pip install sapling-db
13
+ ```
14
+
15
+ ## overview
16
+
17
+ simple, zero-setup persistence for pydantic models
18
+
19
+ ```python
20
+ from pydantic import BaseModel
21
+ from sapling import Database
22
+
23
+
24
+ class User(BaseModel):
25
+ name: str
26
+ email: str
27
+
28
+
29
+ db = Database()
30
+
31
+ with db.transaction() as txn:
32
+ user = User(name="alice", email="alice@example.com")
33
+ doc = txn.put(User, "123", user)
34
+ txn.fetch(User, "123")
35
+ ```
36
+
37
+ **features:**
38
+
39
+ - **fully typed** - complete type safety with ide autocomplete and type checking
40
+ - **zero setup** - works out of the box with no configuration
41
+ - **pydantic native** - designed specifically for pydantic models
42
+ - **fastapi ready** - seamless dependency injection for request-scoped transactions
43
+ - **sqlite backed** - solid, battle-tested storage
@@ -0,0 +1,78 @@
1
+ [project]
2
+ name = "sapling-db"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ authors = [{ name = "Justin Chapman", email = "commonmodestudio@gmail.com" }]
7
+ requires-python = ">=3.12"
8
+ dependencies = ["pydantic>=2.12.5", "pydantic-settings>=2.12.0"]
9
+ classifiers = [
10
+ "Development Status :: 3 - Alpha",
11
+ "Intended Audience :: Developers",
12
+ "License :: OSI Approved :: MIT License",
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3.12",
15
+ "Programming Language :: Python :: 3.13",
16
+ "Programming Language :: Python :: 3.14",
17
+ "Typing :: Typed",
18
+ ]
19
+
20
+ [build-system]
21
+ requires = ["uv_build>=0.8.4,<0.9.0"]
22
+ build-backend = "uv_build"
23
+
24
+ [tool.uv.build-backend]
25
+ module-name = "sapling"
26
+
27
+ [dependency-groups]
28
+ dev = [
29
+ "fastapi>=0.125.0",
30
+ "httpx>=0.28.1",
31
+ "pre-commit>=4.0.1",
32
+ "tryke @ git+https://github.com/thejchap/tryke",
33
+ "ruff>=0.13.0",
34
+ "ty>=0.0.4",
35
+ ]
36
+
37
+ [tool.ruff]
38
+ line-length = 88
39
+ indent-width = 4
40
+ target-version = "py312"
41
+
42
+ [tool.ruff.lint.per-file-ignores]
43
+ "tests/*.py" = ["ANN201", "PLR2004"]
44
+ "src/sapling/backends/sqlite.py" = ["S608"]
45
+
46
+ [tool.ruff.lint.pycodestyle]
47
+ max-line-length = 88
48
+
49
+ [tool.ruff.lint]
50
+ select = ["ALL"]
51
+ ignore = [
52
+ # may cause issues with formatter
53
+ "COM812",
54
+ # incompatible with D213
55
+ "D212",
56
+ # incompatible with D211
57
+ "D203",
58
+ # missing docstring
59
+ "D100",
60
+ "D101",
61
+ "D102",
62
+ "D103",
63
+ "D104",
64
+ "D107",
65
+ # TODOs
66
+ "FIX002",
67
+ "FIX003",
68
+ "TD003",
69
+ "ERA001",
70
+ # too many arguments
71
+ "PLR0913",
72
+ # causes error
73
+ "ISC001",
74
+ ]
75
+ fixable = ["ALL"]
76
+
77
+ [tool.ruff.format]
78
+ docstring-code-format = true
@@ -0,0 +1,16 @@
1
+ """sapling - simple persistence for pydantic models."""
2
+
3
+ from .backends.memory import MemoryBackend
4
+ from .backends.sqlite import SQLiteBackend
5
+ from .database import Database
6
+ from .document import Document
7
+ from .settings import SaplingSettings, get_sapling_settings
8
+
9
+ __all__ = [
10
+ "Database",
11
+ "Document",
12
+ "MemoryBackend",
13
+ "SQLiteBackend",
14
+ "SaplingSettings",
15
+ "get_sapling_settings",
16
+ ]
@@ -0,0 +1,5 @@
1
+ from .base import Backend
2
+ from .memory import MemoryBackend
3
+ from .sqlite import SQLiteBackend
4
+
5
+ __all__ = ["Backend", "MemoryBackend", "SQLiteBackend"]
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import TYPE_CHECKING, Self
5
+
6
+ if TYPE_CHECKING:
7
+ from contextlib import AbstractContextManager
8
+
9
+ from pydantic import BaseModel
10
+
11
+ from sapling.database import Document
12
+
13
+
14
+ class Backend(ABC):
15
+ """abstract base class for storage backends."""
16
+
17
+ @abstractmethod
18
+ def get[T: BaseModel](
19
+ self, model_class: type[T], model_id: str
20
+ ) -> Document[T] | None: ...
21
+
22
+ @abstractmethod
23
+ def put[T: BaseModel](
24
+ self, model_class: type[T], model_id: str, model: T
25
+ ) -> Document[T]: ...
26
+
27
+ @abstractmethod
28
+ def fetch[T: BaseModel](
29
+ self, model_class: type[T], model_id: str
30
+ ) -> Document[T]: ...
31
+
32
+ @abstractmethod
33
+ def delete(self, model_class: type[BaseModel], model_id: str) -> None: ...
34
+
35
+ @abstractmethod
36
+ def all[T: BaseModel](self, model_class: type[T]) -> list[Document[T]]: ...
37
+
38
+ @abstractmethod
39
+ def get_many[T: BaseModel](
40
+ self, model_class: type[T], model_ids: list[str]
41
+ ) -> list[Document[T] | None]: ...
42
+
43
+ @abstractmethod
44
+ def delete_many(
45
+ self, model_class: type[BaseModel], model_ids: list[str]
46
+ ) -> None: ...
47
+
48
+ @abstractmethod
49
+ def put_many[T: BaseModel](
50
+ self, model_class: type[T], models: list[tuple[str, T]]
51
+ ) -> list[Document[T]]: ...
52
+
53
+ @abstractmethod
54
+ def initialize(self) -> None: ...
55
+
56
+ @abstractmethod
57
+ def transaction(self) -> AbstractContextManager[Self]: ...
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import contextmanager
4
+ from typing import TYPE_CHECKING, Self
5
+
6
+ from sapling.backends.base import Backend
7
+ from sapling.document import Document
8
+ from sapling.errors import NotFoundError
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Generator
12
+
13
+ from pydantic import BaseModel
14
+
15
+
16
+ class MemoryBackend(Backend):
17
+ """
18
+ in-memory storage backend.
19
+
20
+ stores documents in python dict, no persistence.
21
+ useful for testing and development.
22
+
23
+ Example:
24
+ ```python
25
+ backend = MemoryBackend()
26
+ db = Database(backend=backend)
27
+ ```
28
+
29
+ """
30
+
31
+ def __init__(self) -> None:
32
+ self._store: dict[tuple[str, str], dict] = {}
33
+
34
+ def initialize(self) -> None:
35
+ pass
36
+
37
+ @contextmanager
38
+ def transaction(self) -> Generator[Self]:
39
+ yield self
40
+
41
+ def get[T: BaseModel](
42
+ self, model_class: type[T], model_id: str
43
+ ) -> Document[T] | None:
44
+ key = (model_class.__name__, model_id)
45
+ if data := self._store.get(key):
46
+ model = model_class.model_validate(data["model"])
47
+ return Document(
48
+ model=model,
49
+ model_id=data["model_id"],
50
+ model_class=data["model_class"],
51
+ )
52
+ return None
53
+
54
+ def put[T: BaseModel](
55
+ self, model_class: type[T], model_id: str, model: T
56
+ ) -> Document[T]:
57
+ key = (model_class.__name__, model_id)
58
+ data = {
59
+ "model_class": model_class.__name__,
60
+ "model_id": model_id,
61
+ "model": model.model_dump(),
62
+ }
63
+ self._store[key] = data
64
+ return Document(
65
+ model=model,
66
+ model_id=model_id,
67
+ model_class=model_class.__name__,
68
+ )
69
+
70
+ def fetch[T: BaseModel](self, model_class: type[T], model_id: str) -> Document[T]:
71
+ if document := self.get(model_class=model_class, model_id=model_id):
72
+ return document
73
+ raise NotFoundError
74
+
75
+ def delete(self, model_class: type[BaseModel], model_id: str) -> None:
76
+ key = (model_class.__name__, model_id)
77
+ self._store.pop(key, None)
78
+
79
+ def all[T: BaseModel](self, model_class: type[T]) -> list[Document[T]]:
80
+ results = []
81
+ for (stored_class, _), data in self._store.items():
82
+ if stored_class == model_class.__name__:
83
+ model = model_class.model_validate(data["model"])
84
+ results.append(
85
+ Document(
86
+ model=model,
87
+ model_id=data["model_id"],
88
+ model_class=data["model_class"],
89
+ )
90
+ )
91
+ return results
92
+
93
+ def get_many[T: BaseModel](
94
+ self, model_class: type[T], model_ids: list[str]
95
+ ) -> list[Document[T] | None]:
96
+ return [self.get(model_class, model_id) for model_id in model_ids]
97
+
98
+ def delete_many(self, model_class: type[BaseModel], model_ids: list[str]) -> None:
99
+ for model_id in model_ids:
100
+ self.delete(model_class, model_id)
101
+
102
+ def put_many[T: BaseModel](
103
+ self, model_class: type[T], models: list[tuple[str, T]]
104
+ ) -> list[Document[T]]:
105
+ return [self.put(model_class, model_id, model) for model_id, model in models]
@@ -0,0 +1,282 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import sqlite3
5
+ import threading
6
+ from contextlib import contextmanager
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, Any, Self, override
9
+
10
+ from pydantic import BaseModel
11
+
12
+ from sapling.backends.base import Backend
13
+ from sapling.document import Document
14
+ from sapling.errors import NotFoundError
15
+ from sapling.settings import IsolationLevel, SaplingSettings, get_sapling_settings
16
+
17
+ if TYPE_CHECKING:
18
+ from collections.abc import Generator
19
+
20
+ logging.basicConfig(level=logging.INFO)
21
+ LOGGER = logging.getLogger(__name__)
22
+
23
+ _CONNECTION_NOT_INITIALIZED = "Connection not initialized"
24
+
25
+
26
+ class SQLiteBackend(Backend):
27
+ """
28
+ sqlite-based storage backend.
29
+
30
+ provides persistent storage using sqlite database.
31
+ supports both file-based and in-memory modes.
32
+
33
+ Args:
34
+ settings: sapling settings instance. if not provided, uses global settings
35
+ from environment variables.
36
+
37
+ Example:
38
+ ```python
39
+ # use global settings from environment
40
+ backend = SQLiteBackend()
41
+
42
+ # use custom settings
43
+ from sapling import SaplingSettings
44
+
45
+ settings = SaplingSettings(
46
+ sqlite_path="/path/to/db.sqlite",
47
+ sqlite_timeout=10.0,
48
+ )
49
+ backend = SQLiteBackend(settings=settings)
50
+ ```
51
+
52
+ """
53
+
54
+ def __init__(self, settings: SaplingSettings | None = None) -> None:
55
+ settings = settings if settings is not None else get_sapling_settings()
56
+ self.path: str = settings.sqlite_path
57
+ self.timeout: float = settings.sqlite_timeout
58
+ self.detect_types: int = settings.sqlite_detect_types
59
+ self.isolation_level: IsolationLevel = settings.sqlite_isolation_level
60
+ self.check_same_thread: bool = settings.sqlite_check_same_thread
61
+ self.cached_statements: int = settings.sqlite_cached_statements
62
+ self.uri: bool = settings.sqlite_uri
63
+ self._conn: sqlite3.Connection | None = None
64
+ self._initialized: bool = False
65
+ self._init_lock: threading.Lock = threading.Lock()
66
+
67
+ @override
68
+ def initialize(self) -> None:
69
+ with self._init_lock:
70
+ if self._initialized:
71
+ return
72
+ if self.path != ":memory:":
73
+ db_dir = Path(self.path).parent
74
+ db_dir.mkdir(parents=True, exist_ok=True)
75
+ self._conn = sqlite3.connect(
76
+ self.path,
77
+ timeout=self.timeout,
78
+ detect_types=self.detect_types,
79
+ isolation_level=self.isolation_level,
80
+ check_same_thread=self.check_same_thread,
81
+ cached_statements=self.cached_statements,
82
+ uri=self.uri,
83
+ )
84
+ self._init_schema()
85
+ self._initialized = True
86
+
87
+ @contextmanager
88
+ def transaction(self) -> Generator[Self]:
89
+ if not self._conn:
90
+ raise ValueError(_CONNECTION_NOT_INITIALIZED)
91
+ with self._conn:
92
+ yield self
93
+
94
+ def _init_schema(self) -> None:
95
+ if self._conn is None:
96
+ raise ValueError(_CONNECTION_NOT_INITIALIZED)
97
+ self._conn.set_trace_callback(LOGGER.debug)
98
+ with self._conn:
99
+ self._conn.execute(
100
+ """\
101
+ CREATE TABLE IF NOT EXISTS document (
102
+ model_class VARCHAR,
103
+ model_id CHARACTER(26),
104
+ model BLOB,
105
+ PRIMARY KEY (model_class, model_id)
106
+ );
107
+ """.strip()
108
+ )
109
+
110
+ def _row_to_document[T: BaseModel](
111
+ self, model_class: type[T], cursor: sqlite3.Cursor, row: tuple[Any]
112
+ ) -> Document[T]:
113
+ fields = [column[0] for column in cursor.description]
114
+ raw = dict(zip(fields, row, strict=False))
115
+ model = model_class.model_validate_json(raw["model"])
116
+ return Document(
117
+ model=model,
118
+ model_id=raw["model_id"],
119
+ model_class=raw["model_class"],
120
+ )
121
+
122
+ def get[T: BaseModel](
123
+ self, model_class: type[T], model_id: str
124
+ ) -> Document[T] | None:
125
+ if self._conn is None:
126
+ raise ValueError(_CONNECTION_NOT_INITIALIZED)
127
+ cursor = self._conn.execute(
128
+ """\
129
+ SELECT
130
+ model_class,
131
+ model_id,
132
+ model
133
+ FROM document
134
+ WHERE
135
+ model_class = :model_class
136
+ AND model_id = :model_id
137
+ LIMIT 1
138
+ ;
139
+ """.strip(),
140
+ {"model_class": model_class.__name__, "model_id": str(model_id)},
141
+ )
142
+ row = cursor.fetchone()
143
+ if row:
144
+ return self._row_to_document(model_class, cursor, row)
145
+ return None
146
+
147
+ def put[T: BaseModel](
148
+ self, model_class: type[T], model_id: str, model: T
149
+ ) -> Document[T]:
150
+ if self._conn is None:
151
+ raise ValueError(_CONNECTION_NOT_INITIALIZED)
152
+ cursor = self._conn.execute(
153
+ """\
154
+ INSERT OR REPLACE INTO document VALUES (
155
+ :model_class,
156
+ :model_id,
157
+ :model
158
+ ) RETURNING
159
+ model_class,
160
+ model_id,
161
+ model
162
+ ;
163
+ """.strip(),
164
+ {
165
+ "model_class": model_class.__name__,
166
+ "model_id": model_id,
167
+ "model": model.model_dump_json(),
168
+ },
169
+ )
170
+ row = cursor.fetchone()
171
+ return self._row_to_document(model_class, cursor, row)
172
+
173
+ def fetch[T: BaseModel](self, model_class: type[T], model_id: str) -> Document[T]:
174
+ if document := self.get(model_class=model_class, model_id=model_id):
175
+ return document
176
+ raise NotFoundError
177
+
178
+ def delete(self, model_class: type[BaseModel], model_id: str) -> None:
179
+ if self._conn is None:
180
+ raise ValueError(_CONNECTION_NOT_INITIALIZED)
181
+ self._conn.execute(
182
+ """\
183
+ DELETE
184
+ FROM document
185
+ WHERE
186
+ model_class = :model_class
187
+ AND model_id = :model_id
188
+ ;
189
+ """.strip(),
190
+ {"model_class": model_class.__name__, "model_id": str(model_id)},
191
+ )
192
+
193
+ def all[T: BaseModel](self, model_class: type[T]) -> list[Document[T]]:
194
+ if self._conn is None:
195
+ raise ValueError(_CONNECTION_NOT_INITIALIZED)
196
+ cursor = self._conn.execute(
197
+ """\
198
+ SELECT
199
+ model_class,
200
+ model_id,
201
+ model
202
+ FROM document
203
+ WHERE
204
+ model_class = :model_class
205
+ ;
206
+ """.strip(),
207
+ {"model_class": model_class.__name__},
208
+ )
209
+ return [
210
+ self._row_to_document(model_class, cursor, row) for row in cursor.fetchall()
211
+ ]
212
+
213
+ def get_many[T: BaseModel](
214
+ self, model_class: type[T], model_ids: list[str]
215
+ ) -> list[Document[T] | None]:
216
+ if not model_ids:
217
+ return []
218
+ if self._conn is None:
219
+ raise ValueError(_CONNECTION_NOT_INITIALIZED)
220
+ placeholders = ",".join("?" * len(model_ids))
221
+ cursor = self._conn.execute(
222
+ f"""\
223
+ SELECT
224
+ model_class,
225
+ model_id,
226
+ model
227
+ FROM document
228
+ WHERE
229
+ model_class = ?
230
+ AND model_id IN ({placeholders})
231
+ ;
232
+ """.strip(),
233
+ [model_class.__name__, *model_ids],
234
+ )
235
+ results_dict = {
236
+ row[1]: self._row_to_document(model_class, cursor, row)
237
+ for row in cursor.fetchall()
238
+ }
239
+ return [results_dict.get(model_id) for model_id in model_ids]
240
+
241
+ def delete_many(self, model_class: type[BaseModel], model_ids: list[str]) -> None:
242
+ if not model_ids:
243
+ return
244
+ if self._conn is None:
245
+ raise ValueError(_CONNECTION_NOT_INITIALIZED)
246
+ placeholders = ",".join("?" * len(model_ids))
247
+ self._conn.execute(
248
+ f"""\
249
+ DELETE
250
+ FROM document
251
+ WHERE
252
+ model_class = ?
253
+ AND model_id IN ({placeholders})
254
+ ;
255
+ """.strip(),
256
+ [model_class.__name__, *model_ids],
257
+ )
258
+
259
+ def put_many[T: BaseModel](
260
+ self, model_class: type[T], models: list[tuple[str, T]]
261
+ ) -> list[Document[T]]:
262
+ if not models:
263
+ return []
264
+ if self._conn is None:
265
+ raise ValueError(_CONNECTION_NOT_INITIALIZED)
266
+ values_placeholders = ",".join("(?, ?, ?)" * len(models))
267
+ flat_values = [
268
+ val
269
+ for model_id, model in models
270
+ for val in (model_class.__name__, model_id, model.model_dump_json())
271
+ ]
272
+ cursor = self._conn.execute(
273
+ f"""\
274
+ INSERT OR REPLACE INTO document VALUES {values_placeholders}
275
+ RETURNING model_class, model_id, model
276
+ ;
277
+ """.strip(),
278
+ flat_values,
279
+ )
280
+ return [
281
+ self._row_to_document(model_class, cursor, row) for row in cursor.fetchall()
282
+ ]
@@ -0,0 +1,394 @@
1
+ from __future__ import annotations
2
+
3
+ import tempfile
4
+ from pathlib import Path
5
+ from typing import TYPE_CHECKING
6
+
7
+ from pydantic import BaseModel
8
+ from tryke import describe, expect, test
9
+
10
+ from sapling import MemoryBackend, SQLiteBackend
11
+ from sapling.errors import NotFoundError
12
+ from sapling.settings import SaplingSettings
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Generator, Iterator
16
+ from contextlib import AbstractContextManager
17
+ from types import TracebackType
18
+
19
+ from sapling.backends.base import Backend
20
+ from sapling.document import Document
21
+
22
+
23
+ class _TransactionWrapper:
24
+ """
25
+ Wrapper for backend transactions.
26
+
27
+ works as both context managers and generators.
28
+ """
29
+
30
+ def __init__(self, backend_transaction_cm: AbstractContextManager[Backend]) -> None:
31
+ self._backend_txn_cm: AbstractContextManager[Backend] = backend_transaction_cm
32
+
33
+ def __enter__(self) -> Backend:
34
+ return self._backend_txn_cm.__enter__()
35
+
36
+ def __exit__(
37
+ self,
38
+ exc_type: type[BaseException] | None,
39
+ exc_value: BaseException | None,
40
+ traceback: TracebackType | None,
41
+ ) -> bool | None:
42
+ return self._backend_txn_cm.__exit__(exc_type, exc_value, traceback)
43
+
44
+ def __call__(self) -> Iterator[Backend]:
45
+ return iter(self)
46
+
47
+ def __iter__(self) -> Generator[Backend]:
48
+ with self._backend_txn_cm as txn:
49
+ yield txn
50
+
51
+
52
+ class Database:
53
+ """
54
+ main interface for sapling persistence.
55
+
56
+ provides crud operations with transaction management.
57
+
58
+ Args:
59
+ backend: storage backend (defaults to SQLiteBackend)
60
+ initialize: whether to initialize backend immediately
61
+
62
+ """
63
+
64
+ def __init__(
65
+ self, backend: Backend | None = None, *, initialize: bool = True
66
+ ) -> None:
67
+ self._backend: Backend = backend or SQLiteBackend()
68
+ if initialize:
69
+ self._backend.initialize()
70
+
71
+ def initialize(self) -> None:
72
+ """
73
+ Initialize backend (idempotent - safe to call multiple times).
74
+
75
+ use this when `initialize=False` was passed to Database constructor.
76
+ """
77
+ self._backend.initialize()
78
+
79
+ def transaction(self) -> _TransactionWrapper:
80
+ """
81
+ Create transaction context for multiple operations.
82
+
83
+ commits on success, rolls back on exception.
84
+
85
+ Returns:
86
+ context manager yielding backend instance
87
+
88
+ Example:
89
+ ```python
90
+ with db.transaction() as txn:
91
+ txn.put(User, "1", user1)
92
+ txn.put(User, "2", user2)
93
+ ```
94
+
95
+ """
96
+ return _TransactionWrapper(self._backend.transaction())
97
+
98
+ def transaction_dependency(self) -> Generator[Backend]:
99
+ """
100
+ Fastapi dependency for request-scoped transactions.
101
+
102
+ use with Depends() for automatic transaction management.
103
+
104
+ Yields:
105
+ backend instance for crud operations
106
+
107
+ Example:
108
+ ```python
109
+ @app.post("/users/{user_id}")
110
+ def create(
111
+ user_id: str,
112
+ user: User,
113
+ txn: Annotated[Backend, Depends(db.transaction_dependency)],
114
+ ):
115
+ return txn.put(User, user_id, user)
116
+ ```
117
+
118
+ """
119
+ with self._backend.transaction() as txn:
120
+ yield txn
121
+
122
+ def get[T: BaseModel](
123
+ self, model_class: type[T], model_id: str
124
+ ) -> Document[T] | None:
125
+ """
126
+ Retrieve document by id, returns None if not found.
127
+
128
+ Args:
129
+ model_class: pydantic model class
130
+ model_id: document identifier
131
+
132
+ Returns:
133
+ document if found, None otherwise
134
+
135
+ """
136
+ with self.transaction() as txn:
137
+ return txn.get(model_class, model_id)
138
+
139
+ def put[T: BaseModel](
140
+ self, model_class: type[T], model_id: str, model: T
141
+ ) -> Document[T]:
142
+ """
143
+ Insert or update document.
144
+
145
+ Args:
146
+ model_class: pydantic model class
147
+ model_id: document identifier
148
+ model: pydantic model instance
149
+
150
+ Returns:
151
+ persisted document
152
+
153
+ """
154
+ with self.transaction() as txn:
155
+ return txn.put(model_class, model_id, model)
156
+
157
+ def fetch[T: BaseModel](self, model_class: type[T], model_id: str) -> Document[T]:
158
+ """
159
+ Retrieve document by id, raises if not found.
160
+
161
+ Args:
162
+ model_class: pydantic model class
163
+ model_id: document identifier
164
+
165
+ Returns:
166
+ document
167
+
168
+ Raises:
169
+ NotFoundError: document does not exist
170
+
171
+ """
172
+ with self.transaction() as txn:
173
+ return txn.fetch(model_class, model_id)
174
+
175
+ def delete(self, model_class: type[BaseModel], model_id: str) -> None:
176
+ """
177
+ Delete document by id.
178
+
179
+ idempotent - no error if document doesn't exist.
180
+
181
+ Args:
182
+ model_class: pydantic model class
183
+ model_id: document identifier
184
+
185
+ """
186
+ with self.transaction() as txn:
187
+ return txn.delete(model_class, model_id)
188
+
189
+ def all[T: BaseModel](self, model_class: type[T]) -> list[Document[T]]:
190
+ """
191
+ Retrieve all documents of given model class.
192
+
193
+ Args:
194
+ model_class: pydantic model class
195
+
196
+ Returns:
197
+ list of documents (empty if none exist)
198
+
199
+ """
200
+ with self.transaction() as txn:
201
+ return txn.all(model_class)
202
+
203
+ def get_many[T: BaseModel](
204
+ self, model_class: type[T], model_ids: list[str]
205
+ ) -> list[Document[T] | None]:
206
+ """
207
+ Retrieve multiple documents by ids, preserving order.
208
+
209
+ Args:
210
+ model_class: pydantic model class
211
+ model_ids: list of document identifiers
212
+
213
+ Returns:
214
+ list of documents (None for missing ids)
215
+
216
+ """
217
+ with self.transaction() as txn:
218
+ return txn.get_many(model_class, model_ids)
219
+
220
+ def delete_many(self, model_class: type[BaseModel], model_ids: list[str]) -> None:
221
+ """
222
+ Delete multiple documents by ids.
223
+
224
+ idempotent - no error if documents don't exist.
225
+
226
+ Args:
227
+ model_class: pydantic model class
228
+ model_ids: list of document identifiers
229
+
230
+ """
231
+ with self.transaction() as txn:
232
+ return txn.delete_many(model_class, model_ids)
233
+
234
+ def put_many[T: BaseModel](
235
+ self, model_class: type[T], models: list[tuple[str, T]]
236
+ ) -> list[Document[T]]:
237
+ """
238
+ Insert or update multiple documents.
239
+
240
+ Args:
241
+ model_class: pydantic model class
242
+ models: list of (model_id, model) tuples
243
+
244
+ Returns:
245
+ list of persisted documents
246
+
247
+ """
248
+ with self.transaction() as txn:
249
+ return txn.put_many(model_class, models)
250
+
251
+
252
+ class _TestModel(BaseModel):
253
+ hello: str = "world"
254
+
255
+
256
+ @test
257
+ def test_basic() -> None:
258
+ db = Database()
259
+ hello = _TestModel()
260
+ with db.transaction() as txn:
261
+ pk = "hello"
262
+ record = txn.put(_TestModel, pk, hello)
263
+ expect(record.model_id).to_equal(pk)
264
+ maybe_record = txn.get(_TestModel, pk)
265
+ expect(maybe_record).to_be_truthy()
266
+ record = txn.fetch(_TestModel, pk)
267
+ txn.delete(_TestModel, pk)
268
+ expect(lambda: txn.fetch(_TestModel, pk)).to_raise(NotFoundError)
269
+
270
+
271
+ @test
272
+ def test_all_method() -> None:
273
+ db = Database()
274
+ with db.transaction() as txn:
275
+ txn.put(_TestModel, "1", _TestModel(hello="one"))
276
+ txn.put(_TestModel, "2", _TestModel(hello="two"))
277
+ txn.put(_TestModel, "3", _TestModel(hello="three"))
278
+
279
+ all_hellos = txn.all(_TestModel)
280
+ expect(all_hellos).to_have_length(3)
281
+ expect({h.model_id for h in all_hellos}).to_equal({"1", "2", "3"})
282
+ expect({h.model.hello for h in all_hellos}).to_equal({"one", "two", "three"})
283
+
284
+
285
+ @test
286
+ def test_all_empty() -> None:
287
+ db = Database()
288
+ with db.transaction() as txn:
289
+ all_hellos = txn.all(_TestModel)
290
+ expect(all_hellos).to_equal([])
291
+
292
+
293
+ @test
294
+ def test_backend_all_method() -> None:
295
+ backend = SQLiteBackend()
296
+ db = Database(backend=backend)
297
+
298
+ with db.transaction() as txn:
299
+ txn.put(_TestModel, "a", _TestModel(hello="alpha"))
300
+ txn.put(_TestModel, "b", _TestModel(hello="beta"))
301
+
302
+ all_docs = txn.all(_TestModel)
303
+ expect(all_docs).to_have_length(2)
304
+ expect({d.model_id for d in all_docs}).to_equal({"a", "b"})
305
+
306
+
307
+ @test
308
+ def test_memory_backend() -> None:
309
+ backend = MemoryBackend()
310
+ db = Database(backend=backend)
311
+
312
+ with db.transaction() as txn:
313
+ txn.put(_TestModel, "test", _TestModel(hello="world"))
314
+ doc = txn.fetch(_TestModel, "test")
315
+ expect(doc.model.hello).to_equal("world")
316
+
317
+ txn.put(_TestModel, "1", _TestModel(hello="one"))
318
+ txn.put(_TestModel, "2", _TestModel(hello="two"))
319
+
320
+ all_docs = txn.all(_TestModel)
321
+ expect(all_docs).to_have_length(3)
322
+ expect({d.model_id for d in all_docs}).to_equal({"test", "1", "2"})
323
+
324
+ txn.delete(_TestModel, "test")
325
+ expect(txn.get(_TestModel, "test")).to_be_none()
326
+
327
+ expect(lambda: txn.fetch(_TestModel, "test")).to_raise(NotFoundError)
328
+
329
+
330
+ with describe("sqlite"):
331
+
332
+ @test
333
+ def test_sqlite_backend_memory() -> None:
334
+ backend = SQLiteBackend()
335
+ db = Database(backend=backend)
336
+ with db.transaction() as txn:
337
+ txn.put(_TestModel, "test", _TestModel(hello="world"))
338
+ doc = txn.fetch(_TestModel, "test")
339
+ expect(doc.model.hello).to_equal("world")
340
+
341
+ @test
342
+ def test_sqlite_backend_file() -> None:
343
+ with tempfile.TemporaryDirectory() as tmp_dir:
344
+ db_path = Path(tmp_dir) / "test.db"
345
+ settings = SaplingSettings(sqlite_path=str(db_path))
346
+ backend = SQLiteBackend(settings=settings)
347
+ db = Database(backend=backend)
348
+
349
+ with db.transaction() as txn:
350
+ txn.put(_TestModel, "persistent", _TestModel(hello="saved"))
351
+
352
+ settings2 = SaplingSettings(sqlite_path=str(db_path))
353
+ db2 = Database(backend=SQLiteBackend(settings=settings2))
354
+ with db2.transaction() as txn:
355
+ doc = txn.fetch(_TestModel, "persistent")
356
+ expect(doc.model.hello).to_equal("saved")
357
+
358
+
359
+ with describe("initialization"):
360
+
361
+ @test
362
+ def test_deferred_initialization() -> None:
363
+ backend = SQLiteBackend()
364
+ db = Database(backend=backend, initialize=False)
365
+
366
+ db.initialize()
367
+
368
+ with db.transaction() as txn:
369
+ txn.put(_TestModel, "test", _TestModel(hello="world"))
370
+ doc = txn.fetch(_TestModel, "test")
371
+ expect(doc.model.hello).to_equal("world")
372
+
373
+ @test
374
+ def test_idempotent_initialization() -> None:
375
+ backend = SQLiteBackend()
376
+ db = Database(backend=backend, initialize=False)
377
+
378
+ db.initialize()
379
+ db.initialize()
380
+ db.initialize()
381
+
382
+ with db.transaction() as txn:
383
+ txn.put(_TestModel, "test", _TestModel(hello="world"))
384
+
385
+ @test
386
+ def test_uninitialized_error() -> None:
387
+ backend = SQLiteBackend()
388
+ db = Database(backend=backend, initialize=False)
389
+
390
+ def try_uninitialized() -> None:
391
+ with db.transaction() as txn:
392
+ txn.put(_TestModel, "test", _TestModel(hello="world"))
393
+
394
+ expect(try_uninitialized).to_raise(ValueError, match="not initialized")
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel, ConfigDict
4
+
5
+
6
+ class Document[T: BaseModel](BaseModel):
7
+ """
8
+ container for persisted pydantic models.
9
+
10
+ documents wrap model data with persistence metadata, keeping pydantic
11
+ models pure (no database-specific fields).
12
+
13
+ Attributes:
14
+ model: the pydantic model instance
15
+ model_id: unique identifier
16
+ model_class: fully qualified model class name
17
+
18
+ Example:
19
+ ```python
20
+ user = User(name="alice", email="alice@example.com")
21
+ doc = txn.put(User, "user_1", user)
22
+ reveal_type(doc) # Document[User]
23
+ print(document.model.name) # "alice"
24
+ ```
25
+
26
+ """
27
+
28
+ model_config: ConfigDict = ConfigDict(frozen=True) # pyright: ignore[reportIncompatibleVariableOverride]
29
+ model: T
30
+ model_id: str
31
+ model_class: str
@@ -0,0 +1,6 @@
1
+ class SaplingError(Exception):
2
+ """base exception for all sapling errors."""
3
+
4
+
5
+ class NotFoundError(SaplingError):
6
+ """raised when document not found by fetch operation."""
File without changes
@@ -0,0 +1,53 @@
1
+ from functools import cache
2
+ from typing import ClassVar, Literal
3
+
4
+ from pydantic import Field
5
+ from pydantic_settings import BaseSettings, SettingsConfigDict
6
+
7
+ type IsolationLevel = Literal["DEFERRED", "IMMEDIATE", "EXCLUSIVE"] | None
8
+
9
+
10
+ class SaplingSettings(BaseSettings):
11
+ """
12
+ sapling configuration settings.
13
+
14
+ all settings can be configured via environment variables with
15
+ the SAPLING_ prefix (e.g., SAPLING_SQLITE_PATH, SAPLING_SQLITE_TIMEOUT).
16
+ """
17
+
18
+ model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict(
19
+ env_prefix="SAPLING_",
20
+ )
21
+ sqlite_path: str = Field(
22
+ default=":memory:",
23
+ description='database file path, or ":memory:" for in-memory',
24
+ )
25
+ sqlite_timeout: float = Field(
26
+ default=5.0,
27
+ description="seconds to wait before raising exception if database is locked",
28
+ )
29
+ sqlite_detect_types: int = Field(
30
+ default=0,
31
+ description="control type detection for non-native sqlite types",
32
+ )
33
+ sqlite_isolation_level: IsolationLevel = Field(
34
+ default="DEFERRED",
35
+ description="transaction isolation: DEFERRED, IMMEDIATE, EXCLUSIVE, or None",
36
+ )
37
+ sqlite_check_same_thread: bool = Field(
38
+ default=False,
39
+ description="if True, only creating thread may use connection",
40
+ )
41
+ sqlite_cached_statements: int = Field(
42
+ default=128,
43
+ description="number of statements to cache internally",
44
+ )
45
+ sqlite_uri: bool = Field(
46
+ default=False,
47
+ description="if True, interpret path as URI with query string",
48
+ )
49
+
50
+
51
+ @cache
52
+ def get_sapling_settings() -> SaplingSettings:
53
+ return SaplingSettings()