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.
- tigrbl_engine_pandas-0.1.1/.gitignore +42 -0
- tigrbl_engine_pandas-0.1.1/LICENSE +19 -0
- tigrbl_engine_pandas-0.1.1/PKG-INFO +71 -0
- tigrbl_engine_pandas-0.1.1/README.md +26 -0
- tigrbl_engine_pandas-0.1.1/distout/.gitignore +1 -0
- tigrbl_engine_pandas-0.1.1/pyproject.toml +62 -0
- tigrbl_engine_pandas-0.1.1/src/tigrbl_engine_pandas/__init__.py +41 -0
- tigrbl_engine_pandas-0.1.1/src/tigrbl_engine_pandas/engine.py +99 -0
- tigrbl_engine_pandas-0.1.1/src/tigrbl_engine_pandas/session.py +560 -0
- tigrbl_engine_pandas-0.1.1/tests/test_smoke.py +8 -0
|
@@ -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()
|