tigrbl_engine_pandas 0.1.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,42 @@
1
+
2
+ .spyproject
3
+ .spyder
4
+ *.pyc
5
+ build
6
+ dist
7
+ *.egg-info
8
+ *.txt
9
+ env
10
+ .env
11
+ .env.*
12
+ /.vs
13
+ .pyirc
14
+ .venv/
15
+ .ipynb_checkpoints/
16
+ .env
17
+ .DS_STORE
18
+ /combined
19
+ .venv_core*
20
+ *.obj
21
+ pytest_results.json
22
+ pkgs/community/swarmauri_vectorstore_annoy/test_annoy.ann
23
+ /pkgs/standards/ptree_dag/pkgs
24
+ *secrets/*
25
+ peagen_artifacts/
26
+ pkgs/standards/peagen/peagen.zip
27
+ /pkgs/uv.lock
28
+ pkgs/standards/peagen/.pymon
29
+ *.pem
30
+ gateway.db
31
+ kms.db
32
+ *.asc
33
+ *.kid
34
+ *.so
35
+ *.db
36
+ target/
37
+ !.gitkeep # keep the empty dir in repo
38
+ pkgs/experimental/swarmakit/libs/svelte/.vscode/extensions.json
39
+ node_modules/
40
+ *.zip
41
+ .pymon
42
+ /.tmp_pydeps
@@ -0,0 +1,19 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ Copyright 2025 Tigrbl
8
+
9
+ Licensed under the Apache License, Version 2.0 (the "License");
10
+ you may not use this file except in compliance with the License.
11
+ You may obtain a copy of the License at
12
+
13
+ http://www.apache.org/licenses/LICENSE-2.0
14
+
15
+ Unless required by applicable law or agreed to in writing, software
16
+ distributed under the License is distributed on an "AS IS" BASIS,
17
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18
+ See the License for the specific language governing permissions and
19
+ limitations under the License.
@@ -0,0 +1,71 @@
1
+ Metadata-Version: 2.4
2
+ Name: tigrbl_engine_pandas
3
+ Version: 0.1.1
4
+ Summary: Tigrbl engine plugin providing transactional pandas DataFrame sessions.
5
+ Project-URL: Homepage, https://github.com/swarmauri/swarmauri-sdk
6
+ Project-URL: Repository, https://github.com/swarmauri/swarmauri-sdk/tree/master/pkgs/experimental/tigrbl_engine_pandas
7
+ Author-email: Jacob Stewart <jacob@swarmauri.com>
8
+ License: Apache License
9
+ Version 2.0, January 2004
10
+ http://www.apache.org/licenses/
11
+
12
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
13
+
14
+ Copyright 2025 Tigrbl
15
+
16
+ Licensed under the Apache License, Version 2.0 (the "License");
17
+ you may not use this file except in compliance with the License.
18
+ You may obtain a copy of the License at
19
+
20
+ http://www.apache.org/licenses/LICENSE-2.0
21
+
22
+ Unless required by applicable law or agreed to in writing, software
23
+ distributed under the License is distributed on an "AS IS" BASIS,
24
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
25
+ See the License for the specific language governing permissions and
26
+ limitations under the License.
27
+ License-File: LICENSE
28
+ Keywords: database,dataframe,engine,pandas,plugin,tigrbl
29
+ Classifier: Development Status :: 1 - Planning
30
+ Classifier: Environment :: Plugins
31
+ Classifier: Intended Audience :: Developers
32
+ Classifier: License :: OSI Approved :: Apache Software License
33
+ Classifier: Programming Language :: Python
34
+ Classifier: Programming Language :: Python :: 3
35
+ Classifier: Programming Language :: Python :: 3 :: Only
36
+ Classifier: Programming Language :: Python :: 3.10
37
+ Classifier: Programming Language :: Python :: 3.11
38
+ Classifier: Programming Language :: Python :: 3.12
39
+ Classifier: Topic :: Database
40
+ Classifier: Topic :: Software Development :: Libraries
41
+ Requires-Python: <3.13,>=3.10
42
+ Requires-Dist: pandas>=2.0
43
+ Requires-Dist: tigrbl
44
+ Description-Content-Type: text/markdown
45
+
46
+ # tigrbl_engine_pandas
47
+
48
+ A Tigrbl engine plugin that provides a **Pandas-backed** engine/session.
49
+
50
+ - **Native transactions** (`begin/commit/rollback`).
51
+ - **MVCC-style snapshots** for reads.
52
+ - Works with Tigrbl **core CRUD** via the small session surface.
53
+
54
+ ## Install
55
+
56
+ ```bash
57
+ pip install tigrbl_engine_pandas
58
+ ```
59
+
60
+ The plugin **auto-registers** via entry points under the group `tigrbl.engine`.
61
+
62
+ ## Usage
63
+
64
+ ```python
65
+ from tigrbl.engine.decorators import engine_ctx
66
+
67
+ # Bind by kind using the plugin's engine
68
+ @engine_ctx({"kind": "pandas", "async": True, "tables": {"widgets": df}, "pks": {"widgets": "id"}})
69
+ class API:
70
+ pass
71
+ ```
@@ -0,0 +1,26 @@
1
+ # tigrbl_engine_pandas
2
+
3
+ A Tigrbl engine plugin that provides a **Pandas-backed** engine/session.
4
+
5
+ - **Native transactions** (`begin/commit/rollback`).
6
+ - **MVCC-style snapshots** for reads.
7
+ - Works with Tigrbl **core CRUD** via the small session surface.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pip install tigrbl_engine_pandas
13
+ ```
14
+
15
+ The plugin **auto-registers** via entry points under the group `tigrbl.engine`.
16
+
17
+ ## Usage
18
+
19
+ ```python
20
+ from tigrbl.engine.decorators import engine_ctx
21
+
22
+ # Bind by kind using the plugin's engine
23
+ @engine_ctx({"kind": "pandas", "async": True, "tables": {"widgets": df}, "pks": {"widgets": "id"}})
24
+ class API:
25
+ pass
26
+ ```
@@ -0,0 +1 @@
1
+ *
@@ -0,0 +1,62 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.20"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "tigrbl_engine_pandas"
7
+ version = "0.1.1"
8
+ description = "Tigrbl engine plugin providing transactional pandas DataFrame sessions."
9
+ readme = "README.md"
10
+ license = { file = "LICENSE" }
11
+ authors = [{ name = "Jacob Stewart", email = "jacob@swarmauri.com" }]
12
+ requires-python = ">=3.10,<3.13"
13
+ classifiers = [
14
+ "Development Status :: 1 - Planning",
15
+ "License :: OSI Approved :: Apache Software License",
16
+ "Programming Language :: Python",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3 :: Only",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Environment :: Plugins",
23
+ "Intended Audience :: Developers",
24
+ "Topic :: Database",
25
+ "Topic :: Software Development :: Libraries",
26
+ ]
27
+ keywords = [
28
+ "tigrbl",
29
+ "engine",
30
+ "plugin",
31
+ "dataframe",
32
+ "pandas",
33
+ "database",
34
+ ]
35
+ dependencies = [
36
+ "pandas>=2.0",
37
+ "tigrbl",
38
+ ]
39
+
40
+ [project.urls]
41
+ Homepage = "https://github.com/swarmauri/swarmauri-sdk"
42
+ Repository = "https://github.com/swarmauri/swarmauri-sdk/tree/master/pkgs/experimental/tigrbl_engine_pandas"
43
+
44
+ [project.entry-points."tigrbl.engine"]
45
+ pandas = "tigrbl_engine_pandas:register"
46
+
47
+ [tool.hatch.build.targets.wheel]
48
+ packages = ["src/tigrbl_engine_pandas"]
49
+
50
+ [dependency-groups]
51
+ dev = [
52
+ "pytest>=8.0",
53
+ "pytest-asyncio>=0.24.0",
54
+ "pytest-xdist>=3.6.1",
55
+ "pytest-json-report>=1.5.0",
56
+ "python-dotenv",
57
+ "requests>=2.32.3",
58
+ "flake8>=7.0",
59
+ "pytest-timeout>=2.3.1",
60
+ "ruff>=0.9.9",
61
+ "pytest-benchmark>=4.0.0",
62
+ ]
@@ -0,0 +1,41 @@
1
+ """tigrbl_engine_pandas: DataFrame-backed Tigrbl engine"""
2
+
3
+ from .engine import pandas_engine, pandas_capabilities, DataFrameCatalog
4
+ from .session import TransactionalDataFrameSession
5
+
6
+ __all__ = [
7
+ "pandas_engine",
8
+ "pandas_capabilities",
9
+ "DataFrameCatalog",
10
+ "TransactionalDataFrameSession",
11
+ "register",
12
+ ]
13
+
14
+
15
+ def register() -> None:
16
+ """
17
+ Entry point target for group 'tigrbl.engine'. This function will be loaded
18
+ by Tigrbl's plugin system. It attempts to register the 'pandas' kind
19
+ with whatever registry is exposed by the installed Tigrbl version.
20
+ """
21
+ register_fn = None
22
+ try:
23
+ from tigrbl.engine.registry import register_engine as _reg
24
+
25
+ register_fn = _reg
26
+ except Exception:
27
+ try:
28
+ from tigrbl.engine.plugins import register_engine as _reg2
29
+
30
+ register_fn = _reg2
31
+ except Exception:
32
+ try:
33
+ from tigrbl.engine import register_engine as _reg3 # type: ignore
34
+
35
+ register_fn = _reg3
36
+ except Exception as exc:
37
+ raise RuntimeError(
38
+ "Could not locate Tigrbl engine registry to register plugin"
39
+ ) from exc
40
+
41
+ register_fn("pandas", pandas_engine, pandas_capabilities)
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Callable, Dict, Mapping, Optional, Tuple
5
+ import threading
6
+
7
+ import pandas as pd
8
+
9
+ try:
10
+ from tigrbl.session.spec import SessionSpec
11
+ except Exception:
12
+
13
+ class SessionSpec:
14
+ def __init__(self, isolation=None, read_only=None):
15
+ self.isolation = isolation
16
+ self.read_only = read_only
17
+
18
+
19
+ from .session import TransactionalDataFrameSession
20
+
21
+ # ---- Engine object: in-memory catalog of DataFrames + versions ----
22
+
23
+
24
+ @dataclass
25
+ class DataFrameCatalog:
26
+ tables: Dict[str, pd.DataFrame] = field(default_factory=dict) # name -> live frame
27
+ pks: Dict[str, str] = field(default_factory=dict) # name -> primary-key column
28
+ table_ver: Dict[str, int] = field(default_factory=dict) # name -> monotonic version
29
+ lock: threading.RLock = field(
30
+ default_factory=threading.RLock
31
+ ) # atomic apply on commit
32
+
33
+ def get_live(self, name: str) -> pd.DataFrame:
34
+ if name not in self.tables:
35
+ self.tables[name] = pd.DataFrame()
36
+ self.table_ver[name] = 0
37
+ self.table_ver.setdefault(name, 0)
38
+ return self.tables[name]
39
+
40
+ def bump(self, name: str) -> None:
41
+ self.table_ver[name] = self.table_ver.get(name, 0) + 1
42
+
43
+
44
+ # ---- Builder expected by Tigrbl EngineSpec (kind='pandas') ----
45
+
46
+
47
+ def pandas_engine(
48
+ *,
49
+ mapping: Optional[Mapping[str, object]] = None,
50
+ spec: Any = None,
51
+ dsn: Optional[str] = None,
52
+ ) -> Tuple[DataFrameCatalog, Callable[[], TransactionalDataFrameSession]]:
53
+ """
54
+ Return (engine, sessionmaker) for the 'pandas' kind.
55
+
56
+ EngineSpec(kind="pandas") calls this with:
57
+ - mapping: plugin-specific config (tables, pks)
58
+ - spec: the EngineSpec instance (not used here)
59
+ - dsn: optional DSN (not used here)
60
+ """
61
+ m = dict(mapping or {})
62
+ initial_tables = m.get("tables") or {}
63
+ pks = m.get("pks") or {}
64
+
65
+ if not isinstance(initial_tables, dict):
66
+ raise TypeError("mapping['tables'] must be a dict[str, pandas.DataFrame]")
67
+ if not isinstance(pks, dict):
68
+ raise TypeError("mapping['pks'] must be a dict[str, str]")
69
+
70
+ # Defensive copy of tables
71
+ tables = {
72
+ k: (v.copy() if isinstance(v, pd.DataFrame) else v)
73
+ for k, v in initial_tables.items()
74
+ }
75
+ cat = DataFrameCatalog(tables=tables, pks=dict(pks))
76
+
77
+ def mk() -> TransactionalDataFrameSession:
78
+ # A neutral SessionSpec is attached here; the effective SessionSpec from
79
+ # session_ctx is typically applied by the Tigrbl layer wrapping the sessionmaker.
80
+ return TransactionalDataFrameSession(cat, spec=SessionSpec())
81
+
82
+ return cat, mk
83
+
84
+
85
+ # ---- Capabilities (optional but useful for validation) ----
86
+
87
+
88
+ def pandas_capabilities() -> dict[str, object]:
89
+ """Report capabilities for the 'pandas' engine."""
90
+ return {
91
+ "transactional": True,
92
+ "read_only_enforced": True,
93
+ "isolation_levels": {
94
+ "read_committed",
95
+ "repeatable_read",
96
+ "snapshot",
97
+ "serializable",
98
+ },
99
+ }
@@ -0,0 +1,560 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Tuple
4
+
5
+ import pandas as pd
6
+
7
+ # Prefer Tigrbl's base; provide a minimal fallback if missing (package can still import)
8
+ try:
9
+ from tigrbl.session.base import TigrblSessionBase
10
+ except Exception: # fallback minimal ABC
11
+ from abc import ABC, abstractmethod
12
+
13
+ class TigrblSessionBase(ABC):
14
+ def __init__(self, spec=None):
15
+ self._spec = spec
16
+ self._open = False
17
+ self._dirty = False
18
+
19
+ async def begin(self):
20
+ self._open = True
21
+
22
+ async def commit(self):
23
+ self._open = False
24
+ self._dirty = False
25
+
26
+ async def rollback(self):
27
+ self._open = False
28
+ self._dirty = False
29
+
30
+ def in_transaction(self):
31
+ return self._open
32
+
33
+ async def run_sync(self, fn):
34
+ rv = fn(self)
35
+ return await rv if hasattr(rv, "__await__") else rv
36
+
37
+ def apply_spec(self, spec):
38
+ self._spec = spec
39
+
40
+ # abstract CRUD/lifecycle
41
+ @abstractmethod
42
+ def _add_impl(self, obj): ...
43
+ @abstractmethod
44
+ async def _delete_impl(self, obj): ...
45
+ @abstractmethod
46
+ async def _flush_impl(self): ...
47
+ @abstractmethod
48
+ async def _refresh_impl(self, obj): ...
49
+ @abstractmethod
50
+ async def _get_impl(self, model, ident): ...
51
+ @abstractmethod
52
+ async def _execute_impl(self, stmt): ...
53
+ @abstractmethod
54
+ async def _tx_begin_impl(self): ...
55
+ @abstractmethod
56
+ async def _tx_commit_impl(self): ...
57
+ @abstractmethod
58
+ async def _tx_rollback_impl(self): ...
59
+ @abstractmethod
60
+ async def _close_impl(self): ...
61
+ # public CRUD wrappers
62
+ def add(self, obj):
63
+ self._dirty = True
64
+ return self._add_impl(obj)
65
+
66
+ async def delete(self, obj):
67
+ self._dirty = True
68
+ return await self._delete_impl(obj)
69
+
70
+ async def flush(self):
71
+ return await self._flush_impl()
72
+
73
+ async def refresh(self, obj):
74
+ return await self._refresh_impl(obj)
75
+
76
+ async def get(self, model, ident):
77
+ return await self._get_impl(model, ident)
78
+
79
+ async def execute(self, stmt):
80
+ return await self._execute_impl(stmt)
81
+
82
+ async def close(self):
83
+ return await self._close_impl()
84
+
85
+
86
+ try:
87
+ from tigrbl.session.spec import SessionSpec
88
+ except Exception:
89
+
90
+ class SessionSpec:
91
+ def __init__(self, isolation=None, read_only=None):
92
+ self.isolation = isolation
93
+ self.read_only = read_only
94
+
95
+
96
+ try:
97
+ from tigrbl.core.crud.helpers.model import _single_pk_name, _model_columns
98
+ except Exception:
99
+ # Minimal fallbacks
100
+ def _single_pk_name(model):
101
+ return "id"
102
+
103
+ def _model_columns(model):
104
+ return getattr(model, "__annotations__", {}) or {"id": int}
105
+
106
+
107
+ try:
108
+ from tigrbl.core.crud.helpers import NoResultFound
109
+ except Exception:
110
+
111
+ class NoResultFound(Exception):
112
+ pass
113
+
114
+
115
+ from typing import TYPE_CHECKING
116
+
117
+ if TYPE_CHECKING:
118
+ from .engine import DataFrameCatalog
119
+
120
+ # ---- Result facades compatible with Tigrbl CRUD ----
121
+
122
+
123
+ class _ScalarResult:
124
+ def __init__(self, items: Sequence[Any]) -> None:
125
+ self._items = list(items)
126
+
127
+ def scalars(self) -> "_ScalarResult":
128
+ return self
129
+
130
+ def all(self) -> List[Any]:
131
+ return list(self._items)
132
+
133
+ def scalar_one(self) -> Any:
134
+ if len(self._items) != 1:
135
+ raise NoResultFound("expected exactly one row")
136
+ return self._items[0]
137
+
138
+
139
+ class _ExecuteResult(_ScalarResult):
140
+ rowcount: int = 0
141
+
142
+
143
+ # ---- Transactional DataFrame Session ----
144
+
145
+
146
+ class TransactionalDataFrameSession(TigrblSessionBase):
147
+ """Native-transaction session over pandas DataFrames."""
148
+
149
+ def __init__(
150
+ self, catalog: "DataFrameCatalog", spec: Optional[SessionSpec] = None
151
+ ) -> None:
152
+ super().__init__(spec)
153
+ self._cat = catalog
154
+ self._snap: Dict[str, pd.DataFrame] = {}
155
+ self._snap_ver: Dict[str, int] = {}
156
+ self._puts: Dict[Tuple[type, Any], Dict[str, Any]] = {}
157
+ self._dels: set[Tuple[type, Any]] = set()
158
+
159
+ # ---- lifecycle / async marker ----
160
+ async def run_sync(self, fn: Callable[[Any], Any]) -> Any:
161
+ out = fn(self)
162
+ return await out if hasattr(out, "__await__") else out
163
+
164
+ # ---- TX primitives ----
165
+ async def _tx_begin_impl(self) -> None:
166
+ self._snap.clear()
167
+ self._snap_ver.clear()
168
+ self._puts.clear()
169
+ self._dels.clear()
170
+
171
+ async def _tx_commit_impl(self) -> None:
172
+ iso = (self._spec.isolation if self._spec else None) or "read_committed"
173
+ # Conflict detection (coarse, per-table)
174
+ if iso in ("repeatable_read", "snapshot", "serializable"):
175
+ for tbl, ver in self._snap_ver.items():
176
+ if self._cat.table_ver.get(tbl, 0) != ver:
177
+ raise RuntimeError(f"transaction conflict on table '{tbl}'")
178
+
179
+ # Apply mutations atomically
180
+ with self._cat.lock:
181
+ # deletes
182
+ dels_by_tbl: Dict[str, List[Any]] = {}
183
+ for model, ident in self._dels:
184
+ tbl = self._table(model)
185
+ dels_by_tbl.setdefault(tbl, []).append(ident)
186
+ for tbl, idents in dels_by_tbl.items():
187
+ pk = self._pk_of(tbl)
188
+ live = self._cat.get_live(tbl)
189
+ if pk in live.columns and not live.empty:
190
+ self._cat.tables[tbl] = live[~live[pk].isin(idents)].copy()
191
+ self._cat.bump(tbl)
192
+
193
+ # upserts
194
+ puts_by_tbl: Dict[str, List[Dict[str, Any]]] = {}
195
+ for (model, _), row in self._puts.items():
196
+ puts_by_tbl.setdefault(self._table(model), []).append(row)
197
+ for tbl, rows in puts_by_tbl.items():
198
+ pk = self._pk_of(tbl)
199
+ live = self._cat.get_live(tbl)
200
+ df_new = pd.DataFrame(rows)
201
+ if df_new.empty:
202
+ continue
203
+ if pk not in df_new.columns:
204
+ raise RuntimeError(f"missing pk '{pk}' for table '{tbl}'")
205
+ if live.empty:
206
+ combined = df_new.copy()
207
+ else:
208
+ combined = live[~live[pk].isin(df_new[pk])].copy()
209
+ combined = pd.concat([combined, df_new], ignore_index=True)
210
+ self._cat.tables[tbl] = combined
211
+ self._cat.bump(tbl)
212
+
213
+ self._puts.clear()
214
+ self._dels.clear()
215
+
216
+ async def _tx_rollback_impl(self) -> None:
217
+ self._snap.clear()
218
+ self._snap_ver.clear()
219
+ self._puts.clear()
220
+ self._dels.clear()
221
+
222
+ # ---- CRUD primitives ----
223
+ @staticmethod
224
+ def _pk_default(model: type, pk: str) -> Any:
225
+ table = getattr(model, "__table__", None)
226
+ if table is None:
227
+ return None
228
+ try:
229
+ column = table.columns.get(pk)
230
+ except Exception:
231
+ return None
232
+ if column is None:
233
+ return None
234
+ default = getattr(column, "default", None)
235
+ if default is None:
236
+ return None
237
+ arg = getattr(default, "arg", None)
238
+ if callable(arg):
239
+ try:
240
+ return arg()
241
+ except TypeError:
242
+ # Some SQLAlchemy defaults accept a context parameter.
243
+ return arg(None)
244
+ return arg
245
+
246
+ def _add_impl(self, obj: Any) -> Any:
247
+ model = obj.__class__
248
+ pk = _single_pk_name(model)
249
+ ident = getattr(obj, pk)
250
+ if ident is None:
251
+ ident = self._pk_default(model, pk)
252
+ if ident is not None:
253
+ setattr(obj, pk, ident)
254
+ if ident is None:
255
+ raise ValueError(f"primary key {pk!r} must be set")
256
+ row = {c: getattr(obj, c, None) for c in _model_columns(model)}
257
+ self._puts[(model, ident)] = row
258
+ self._dels.discard((model, ident))
259
+ return None
260
+
261
+ async def _delete_impl(self, obj: Any) -> None:
262
+ model = obj.__class__
263
+ pk = _single_pk_name(model)
264
+ ident = getattr(obj, pk)
265
+ self._puts.pop((model, ident), None)
266
+ self._dels.add((model, ident))
267
+
268
+ async def _flush_impl(self) -> None:
269
+ return
270
+
271
+ async def _refresh_impl(self, obj: Any) -> None:
272
+ pk = _single_pk_name(obj.__class__)
273
+ ident = getattr(obj, pk)
274
+ fresh = await self._get_impl(obj.__class__, ident)
275
+ if fresh is None:
276
+ return
277
+ for c in _model_columns(obj.__class__):
278
+ setattr(obj, c, getattr(fresh, c, None))
279
+
280
+ async def _get_impl(self, model: type, ident: Any) -> Any | None:
281
+ row = self._puts.get((model, ident))
282
+ if row is not None:
283
+ return self._inflate(model, row)
284
+ if (model, ident) in self._dels:
285
+ return None
286
+ df = self._frame_for(model)
287
+ pk = _single_pk_name(model)
288
+ if pk not in df.columns or df.empty:
289
+ return None
290
+ m = df[df[pk] == ident]
291
+ if m.empty:
292
+ return None
293
+ return self._inflate(model, m.iloc[0].to_dict())
294
+
295
+ async def _execute_impl(self, stmt: Any) -> Any:
296
+ kind = type(stmt).__name__.lower()
297
+ if "select" in kind:
298
+ model, where, order, limit, offset = self._decompose_select(stmt)
299
+ items = self._scan_model(model)
300
+ items = [o for o in items if self._matches_obj(o, where)]
301
+ items = self._order_slice(items, order, limit, offset)
302
+ return _ExecuteResult(items)
303
+ if "delete" in kind:
304
+ model, where = self._decompose_delete(stmt)
305
+ items = self._scan_model(model)
306
+ items = [o for o in items if self._matches_obj(o, where)]
307
+ for o in items:
308
+ pk = _single_pk_name(model)
309
+ ident = getattr(o, pk)
310
+ self._puts.pop((model, ident), None)
311
+ self._dels.add((model, ident))
312
+ res = _ExecuteResult([])
313
+ res.rowcount = len(items)
314
+ return res
315
+ raise NotImplementedError(f"Unsupported statement: {type(stmt)}")
316
+
317
+ async def _close_impl(self) -> None:
318
+ return
319
+
320
+ # ---- helpers ----
321
+ def _table(self, model: type) -> str:
322
+ return getattr(model, "__tablename__", None) or model.__name__
323
+
324
+ def _pk_of(self, table: str) -> str:
325
+ if table in self._cat.pks:
326
+ return self._cat.pks[table]
327
+ live = self._cat.get_live(table)
328
+ if "id" in live.columns:
329
+ return "id"
330
+ raise RuntimeError(f"primary key for table '{table}' is unknown")
331
+
332
+ def _frame_for(self, model: type) -> pd.DataFrame:
333
+ table = self._table(model)
334
+ iso = (self._spec.isolation if self._spec else None) or "read_committed"
335
+ if (
336
+ iso in ("repeatable_read", "snapshot", "serializable")
337
+ and table not in self._snap
338
+ ):
339
+ live = self._cat.get_live(table)
340
+ self._snap[table] = live.copy(deep=True)
341
+ self._snap_ver[table] = self._cat.table_ver.get(table, 0)
342
+ return (
343
+ self._snap.get(table) if table in self._snap else self._cat.get_live(table)
344
+ )
345
+
346
+ def _inflate(self, model: type, data: Mapping[str, Any]) -> Any:
347
+ obj = model()
348
+ for c in _model_columns(model):
349
+ if c in data:
350
+ setattr(obj, c, data[c])
351
+ return obj
352
+
353
+ def _scan_model(self, model: type) -> List[Any]:
354
+ df = self._frame_for(model)
355
+ out: List[Any] = []
356
+ if not df.empty:
357
+ for _, row in df.iterrows():
358
+ out.append(self._inflate(model, row.to_dict()))
359
+ # overlay upserts / deletes
360
+ pk = _single_pk_name(model)
361
+ by_id = {getattr(o, pk): o for o in out}
362
+ for (m, ident), row in self._puts.items():
363
+ if m is model:
364
+ by_id[ident] = self._inflate(model, row)
365
+ for m, ident in list(self._dels):
366
+ if m is model:
367
+ by_id.pop(ident, None)
368
+ return list(by_id.values())
369
+
370
+ # ---- duck-typed stmt parsing (eq/IN/order/limit/offset) ----
371
+ def _decompose_select(
372
+ self, stmt: Any
373
+ ) -> Tuple[
374
+ type,
375
+ list[Tuple[str, str, Any]],
376
+ list[Tuple[str, str]],
377
+ Optional[int],
378
+ Optional[int],
379
+ ]:
380
+ model = self._extract_model(stmt)
381
+ where = self._extract_predicates(stmt)
382
+ order = self._extract_order_by(stmt)
383
+ limit = self._extract_int(stmt, ["_limit", "_limit_clause", "limit"])
384
+ offset = self._extract_int(stmt, ["_offset", "_offset_clause", "offset"])
385
+ return model, where, order, limit, offset
386
+
387
+ def _decompose_delete(self, stmt: Any) -> Tuple[type, list[Tuple[str, str, Any]]]:
388
+ model = self._extract_model(stmt)
389
+ where = self._extract_predicates(stmt)
390
+ return model, where
391
+
392
+ def _extract_model(self, stmt: Any) -> type:
393
+ descs = getattr(stmt, "column_descriptions", None) or []
394
+ for desc in descs:
395
+ entity = desc.get("entity") if isinstance(desc, dict) else None
396
+ if isinstance(entity, type):
397
+ return entity
398
+
399
+ def _all_subclasses(base: type) -> list[type]:
400
+ def _safe_subclasses(cls: type) -> list[type]:
401
+ try:
402
+ return list(cls.__subclasses__())
403
+ except TypeError:
404
+ # Some metaclass entries (e.g. ``type``) expose an unbound
405
+ # descriptor here; treat as leaf.
406
+ return []
407
+
408
+ out: list[type] = []
409
+ stack = _safe_subclasses(base)
410
+ while stack:
411
+ cls = stack.pop()
412
+ out.append(cls)
413
+ stack.extend(_safe_subclasses(cls))
414
+ return out
415
+
416
+ def _find_by_table(name: str) -> type | None:
417
+ for cls in _all_subclasses(object):
418
+ if getattr(cls, "__tablename__", None) == name:
419
+ return cls
420
+ return None
421
+
422
+ for a in ("_from_objects", "_froms", "froms"):
423
+ v = getattr(stmt, a, None)
424
+ if v is None:
425
+ continue
426
+ if isinstance(v, (list, tuple)) and not v:
427
+ continue
428
+ table = v[0] if isinstance(v, (list, tuple)) else v
429
+ name = getattr(table, "name", None)
430
+ if isinstance(name, str):
431
+ found = _find_by_table(name)
432
+ if found is not None:
433
+ return found
434
+
435
+ table = getattr(stmt, "table", None)
436
+ name = getattr(table, "name", None)
437
+ if isinstance(name, str):
438
+ found = _find_by_table(name)
439
+ if found is not None:
440
+ return found
441
+
442
+ rc = getattr(stmt, "_raw_columns", None) or getattr(stmt, "columns", None)
443
+ if rc is not None:
444
+ if isinstance(rc, (list, tuple)) and not rc:
445
+ raise RuntimeError("Cannot resolve model from statement")
446
+ entity = rc[0]
447
+ table = getattr(entity, "table", None)
448
+ name = getattr(table, "name", None)
449
+ if isinstance(name, str):
450
+ found = _find_by_table(name)
451
+ if found is not None:
452
+ return found
453
+
454
+ raise RuntimeError("Cannot resolve model from statement")
455
+
456
+ def _extract_predicates(self, stmt: Any) -> list[Tuple[str, str, Any]]:
457
+ where = getattr(stmt, "whereclause", None) or getattr(
458
+ stmt, "_whereclause", None
459
+ )
460
+ if where is None:
461
+ return []
462
+ parts = (
463
+ getattr(where, "clauses", None)
464
+ or getattr(where, "get_children", lambda: [])()
465
+ )
466
+ nodes = list(parts) if parts else [where]
467
+ out: list[Tuple[str, str, Any]] = []
468
+ for n in nodes:
469
+ left, right, op = (
470
+ getattr(n, "left", None),
471
+ getattr(n, "right", None),
472
+ getattr(n, "operator", None),
473
+ )
474
+ if left is None or right is None:
475
+ continue
476
+ name = getattr(left, "key", None) or getattr(left, "name", None)
477
+ if name is None:
478
+ continue
479
+ opname = getattr(op, "__name__", str(op))
480
+ if "eq" in opname:
481
+ val = (
482
+ getattr(right, "value", None)
483
+ if hasattr(right, "value")
484
+ else getattr(right, "literal", None)
485
+ )
486
+ out.append((str(name), "eq", val))
487
+ continue
488
+ rclauses = getattr(right, "clauses", None)
489
+ if rclauses is not None and "in" in opname:
490
+ vals = [
491
+ getattr(lit, "value", None)
492
+ if hasattr(lit, "value")
493
+ else getattr(lit, "literal", None)
494
+ for lit in rclauses
495
+ ]
496
+ out.append((str(name), "in", vals))
497
+ return out
498
+
499
+ def _extract_order_by(self, stmt: Any) -> list[Tuple[str, str]]:
500
+ order = getattr(stmt, "_order_by_clause", None)
501
+ if order is None:
502
+ order = getattr(stmt, "_order_by_clauses", None)
503
+ if order is None:
504
+ return []
505
+ clauses = getattr(order, "clauses", None) or order
506
+ clauses = clauses if isinstance(clauses, (list, tuple)) else [clauses]
507
+ for ob in clauses:
508
+ col = (
509
+ getattr(ob, "element", None)
510
+ or getattr(ob, "this", None)
511
+ or getattr(ob, "expr", None)
512
+ )
513
+ name = getattr(col, "key", None) or getattr(col, "name", None)
514
+ direction = "desc" if "desc" in type(ob).__name__.lower() else "asc"
515
+ if name:
516
+ return [(str(name), direction)]
517
+ return []
518
+
519
+ def _extract_int(self, stmt: Any, names: Sequence[str]) -> Optional[int]:
520
+ for n in names:
521
+ v = getattr(stmt, n, None)
522
+ if v is None:
523
+ continue
524
+ try:
525
+ return int(v)
526
+ except Exception:
527
+ val = getattr(v, "value", None)
528
+ if val is not None:
529
+ try:
530
+ return int(val)
531
+ except Exception:
532
+ pass
533
+ return None
534
+
535
+ def _matches_obj(self, obj: Any, where: list[Tuple[str, str, Any]]) -> bool:
536
+ for name, op, val in where:
537
+ dv = getattr(obj, name, None)
538
+ if op == "eq" and dv != val:
539
+ return False
540
+ if op == "in" and dv not in set(val):
541
+ return False
542
+ return True
543
+
544
+ def _order_slice(
545
+ self,
546
+ items: List[Any],
547
+ order: list[Tuple[str, str]],
548
+ limit: Optional[int],
549
+ offset: Optional[int],
550
+ ) -> List[Any]:
551
+ if order:
552
+ col, direction = order[0]
553
+ items.sort(
554
+ key=lambda o: getattr(o, col, None), reverse=(direction == "desc")
555
+ )
556
+ if isinstance(offset, int):
557
+ items = items[max(0, offset) :]
558
+ if isinstance(limit, int):
559
+ items = items[: max(0, limit)]
560
+ return items
@@ -0,0 +1,8 @@
1
+ from pathlib import Path
2
+
3
+
4
+ def test_package_assets_present() -> None:
5
+ package_dir = Path(__file__).resolve().parents[1]
6
+ assert (package_dir / "README.md").is_file()
7
+ assert (package_dir / "LICENSE").is_file()
8
+ assert (package_dir / "pyproject.toml").is_file()