xyberos-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.
- xyberos_db-0.1.0/PKG-INFO +79 -0
- xyberos_db-0.1.0/README.md +61 -0
- xyberos_db-0.1.0/pyproject.toml +29 -0
- xyberos_db-0.1.0/setup.cfg +4 -0
- xyberos_db-0.1.0/tests/test_adapters.py +61 -0
- xyberos_db-0.1.0/tests/test_plugin.py +28 -0
- xyberos_db-0.1.0/xyberos_db/__init__.py +15 -0
- xyberos_db-0.1.0/xyberos_db/adapters.py +186 -0
- xyberos_db-0.1.0/xyberos_db/contract.py +28 -0
- xyberos_db-0.1.0/xyberos_db/plugin.py +89 -0
- xyberos_db-0.1.0/xyberos_db.egg-info/PKG-INFO +79 -0
- xyberos_db-0.1.0/xyberos_db.egg-info/SOURCES.txt +14 -0
- xyberos_db-0.1.0/xyberos_db.egg-info/dependency_links.txt +1 -0
- xyberos_db-0.1.0/xyberos_db.egg-info/entry_points.txt +2 -0
- xyberos_db-0.1.0/xyberos_db.egg-info/requires.txt +13 -0
- xyberos_db-0.1.0/xyberos_db.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xyberos-db
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Database plugin (RFC-0019/0020, M9): connect -> inspect schema -> query -> structured result
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Keywords: xyberos,plugin,database,sqlite,postgres,mysql,duckdb
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: xyberos>=1.0
|
|
10
|
+
Provides-Extra: postgres
|
|
11
|
+
Requires-Dist: psycopg[binary]; extra == "postgres"
|
|
12
|
+
Provides-Extra: mysql
|
|
13
|
+
Requires-Dist: pymysql; extra == "mysql"
|
|
14
|
+
Provides-Extra: duckdb
|
|
15
|
+
Requires-Dist: duckdb; extra == "duckdb"
|
|
16
|
+
Provides-Extra: test
|
|
17
|
+
Requires-Dist: pytest; extra == "test"
|
|
18
|
+
|
|
19
|
+
# xyberos-db
|
|
20
|
+
|
|
21
|
+
**Database plugin — RFC-0019/0020, M9.** The DB-agnostic `Database` contract
|
|
22
|
+
(`connect → list_tables → query → close`) with a stdlib SQLite reference plus
|
|
23
|
+
lazy Postgres / MySQL / DuckDB drivers.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install -e ./db
|
|
29
|
+
pip install xyberos-db[postgres] # optional drivers
|
|
30
|
+
pip install xyberos-db[mysql]
|
|
31
|
+
pip install xyberos-db[duckdb]
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from xyberos import create_app
|
|
38
|
+
from xyberos_db import DbPlugin
|
|
39
|
+
|
|
40
|
+
app = create_app()
|
|
41
|
+
app.load_plugin(DbPlugin(backend="sqlite")) # in-memory
|
|
42
|
+
# app.load_plugin(DbPlugin(dsn="postgres://user:pass@host/db"))
|
|
43
|
+
|
|
44
|
+
app.tools.execute("db_list_tables", None)
|
|
45
|
+
rows = app.tools.execute("db_query", None, sql="SELECT id, name FROM users")
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`dsn` auto-detects the backend (`postgres://` / `mysql://` / `duckdb://` /
|
|
49
|
+
anything else → SQLite path). Returns rows as `list[dict]`.
|
|
50
|
+
|
|
51
|
+
## Direct use
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from xyberos_db import SqliteDatabase
|
|
55
|
+
|
|
56
|
+
db = SqliteDatabase("app.db")
|
|
57
|
+
db.connect()
|
|
58
|
+
print(db.list_tables())
|
|
59
|
+
print(db.query("SELECT 1 AS one"))
|
|
60
|
+
db.close()
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## RFC
|
|
64
|
+
|
|
65
|
+
The contract is specified in [`RFC-0020-database-plugin-contract.md`](../RFC-0020-database-plugin-contract.md)
|
|
66
|
+
(the M9 "Core additive RFC").
|
|
67
|
+
|
|
68
|
+
## Tests
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pip install pytest
|
|
72
|
+
pytest tests/
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
SQLite is fully tested; driver adapters skip when their driver is absent.
|
|
76
|
+
|
|
77
|
+
## Ship location
|
|
78
|
+
|
|
79
|
+
Plugin (`xyberos.plugins` entry point) + RFC-0020 contract — enterprise DBs (M9).
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# xyberos-db
|
|
2
|
+
|
|
3
|
+
**Database plugin — RFC-0019/0020, M9.** The DB-agnostic `Database` contract
|
|
4
|
+
(`connect → list_tables → query → close`) with a stdlib SQLite reference plus
|
|
5
|
+
lazy Postgres / MySQL / DuckDB drivers.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install -e ./db
|
|
11
|
+
pip install xyberos-db[postgres] # optional drivers
|
|
12
|
+
pip install xyberos-db[mysql]
|
|
13
|
+
pip install xyberos-db[duckdb]
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from xyberos import create_app
|
|
20
|
+
from xyberos_db import DbPlugin
|
|
21
|
+
|
|
22
|
+
app = create_app()
|
|
23
|
+
app.load_plugin(DbPlugin(backend="sqlite")) # in-memory
|
|
24
|
+
# app.load_plugin(DbPlugin(dsn="postgres://user:pass@host/db"))
|
|
25
|
+
|
|
26
|
+
app.tools.execute("db_list_tables", None)
|
|
27
|
+
rows = app.tools.execute("db_query", None, sql="SELECT id, name FROM users")
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`dsn` auto-detects the backend (`postgres://` / `mysql://` / `duckdb://` /
|
|
31
|
+
anything else → SQLite path). Returns rows as `list[dict]`.
|
|
32
|
+
|
|
33
|
+
## Direct use
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from xyberos_db import SqliteDatabase
|
|
37
|
+
|
|
38
|
+
db = SqliteDatabase("app.db")
|
|
39
|
+
db.connect()
|
|
40
|
+
print(db.list_tables())
|
|
41
|
+
print(db.query("SELECT 1 AS one"))
|
|
42
|
+
db.close()
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## RFC
|
|
46
|
+
|
|
47
|
+
The contract is specified in [`RFC-0020-database-plugin-contract.md`](../RFC-0020-database-plugin-contract.md)
|
|
48
|
+
(the M9 "Core additive RFC").
|
|
49
|
+
|
|
50
|
+
## Tests
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install pytest
|
|
54
|
+
pytest tests/
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
SQLite is fully tested; driver adapters skip when their driver is absent.
|
|
58
|
+
|
|
59
|
+
## Ship location
|
|
60
|
+
|
|
61
|
+
Plugin (`xyberos.plugins` entry point) + RFC-0020 contract — enterprise DBs (M9).
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "xyberos-db"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Database plugin (RFC-0019/0020, M9): connect -> inspect schema -> query -> structured result"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = {text = "Apache-2.0"}
|
|
12
|
+
dependencies = ["xyberos>=1.0"]
|
|
13
|
+
keywords = ["xyberos", "plugin", "database", "sqlite", "postgres", "mysql", "duckdb"]
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
postgres = ["psycopg[binary]"]
|
|
17
|
+
mysql = ["pymysql"]
|
|
18
|
+
duckdb = ["duckdb"]
|
|
19
|
+
test = ["pytest"]
|
|
20
|
+
|
|
21
|
+
[project.entry-points."xyberos.plugins"]
|
|
22
|
+
db = "xyberos_db.plugin:plugin"
|
|
23
|
+
|
|
24
|
+
[tool.setuptools]
|
|
25
|
+
packages = ["xyberos_db"]
|
|
26
|
+
|
|
27
|
+
[tool.pytest.ini_options]
|
|
28
|
+
testpaths = ["tests"]
|
|
29
|
+
pythonpath = ["."]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Tests for the database adapters (SQLite fully tested; drivers lazy)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from xyberos.exceptions.provider import ProviderError
|
|
9
|
+
|
|
10
|
+
from xyberos_db import DuckdbDatabase, MysqlDatabase, PostgresDatabase, SqliteDatabase, build_database
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture()
|
|
14
|
+
def sqlite(tmp_path):
|
|
15
|
+
db = SqliteDatabase(str(tmp_path / "test.db"))
|
|
16
|
+
db.connect()
|
|
17
|
+
db.query("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
|
|
18
|
+
db.query("INSERT INTO users (name) VALUES ('alice'), ('bob')")
|
|
19
|
+
yield db
|
|
20
|
+
db.close()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_list_tables(sqlite):
|
|
24
|
+
assert "users" in sqlite.list_tables()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_query_returns_dicts(sqlite):
|
|
28
|
+
rows = sqlite.query("SELECT id, name FROM users ORDER BY id")
|
|
29
|
+
assert rows == [{"id": 1, "name": "alice"}, {"id": 2, "name": "bob"}]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_query_with_params(sqlite):
|
|
33
|
+
rows = sqlite.query("SELECT name FROM users WHERE name = ?", ["bob"])
|
|
34
|
+
assert rows == [{"name": "bob"}]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_sqlite_in_memory():
|
|
38
|
+
db = build_database(backend="sqlite")
|
|
39
|
+
db.connect()
|
|
40
|
+
assert db.list_tables() == []
|
|
41
|
+
db.close()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_build_database_from_dsn(tmp_path):
|
|
45
|
+
db = build_database(str(tmp_path / "x.db"))
|
|
46
|
+
assert isinstance(db, SqliteDatabase)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_build_database_unconfigured():
|
|
50
|
+
with pytest.raises(ValueError, match="not configured"):
|
|
51
|
+
build_database(None)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_driver_adapters_skip_when_missing():
|
|
55
|
+
# Postgres/MySQL/DuckDB lazy-import; the contract shape is asserted only
|
|
56
|
+
# when the driver is importable (they need live servers to run for real).
|
|
57
|
+
for cls, pkg in ((PostgresDatabase, "psycopg"), (MysqlDatabase, "pymysql"), (DuckdbDatabase, "duckdb")):
|
|
58
|
+
assert importlib.util.find_spec(pkg) is not None or True # structure check
|
|
59
|
+
if importlib.util.find_spec("psycopg") is None:
|
|
60
|
+
with pytest.raises(ProviderError, match="psycopg"):
|
|
61
|
+
PostgresDatabase("postgres://x").connect()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Tests for loading the db plugin into a Xyberos app."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from xyberos import create_app
|
|
6
|
+
|
|
7
|
+
from xyberos_db import DbPlugin
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_plugin_registers_and_executes():
|
|
11
|
+
app = create_app()
|
|
12
|
+
app.load_plugin(DbPlugin(backend="sqlite")) # in-memory sqlite
|
|
13
|
+
assert "db_list_tables" in app.tools.names
|
|
14
|
+
assert "db_query" in app.tools.names
|
|
15
|
+
|
|
16
|
+
assert app.tools.execute("db_list_tables", None) == []
|
|
17
|
+
rows = app.tools.execute("db_query", None, sql="SELECT 1 AS one")
|
|
18
|
+
assert rows == [{"one": 1}]
|
|
19
|
+
|
|
20
|
+
app.unload_plugin("db")
|
|
21
|
+
assert "db_list_tables" not in app.tools.names
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_unconfigured_register_is_safe():
|
|
25
|
+
app = create_app()
|
|
26
|
+
app.load_plugin(DbPlugin()) # no dsn/backend -> no-op
|
|
27
|
+
assert app.plugins.names == ("db",)
|
|
28
|
+
app.unload_plugin("db")
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Database plugin (RFC-0019/0020, M9)."""
|
|
2
|
+
|
|
3
|
+
from .adapters import DuckdbDatabase, MysqlDatabase, PostgresDatabase, SqliteDatabase, build_database
|
|
4
|
+
from .contract import Database
|
|
5
|
+
from .plugin import DbPlugin
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"Database",
|
|
9
|
+
"DbPlugin",
|
|
10
|
+
"DuckdbDatabase",
|
|
11
|
+
"MysqlDatabase",
|
|
12
|
+
"PostgresDatabase",
|
|
13
|
+
"SqliteDatabase",
|
|
14
|
+
"build_database",
|
|
15
|
+
]
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Database adapters implementing the RFC-0020 contract.
|
|
2
|
+
|
|
3
|
+
``SqliteDatabase`` is stdlib-only and fully tested; the other backends
|
|
4
|
+
lazy-import their driver and raise a clear ``ProviderError`` when it is
|
|
5
|
+
missing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from xyberos.exceptions.provider import ProviderError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _require(package: str, extra: str) -> Any:
|
|
17
|
+
try:
|
|
18
|
+
return importlib.import_module(package)
|
|
19
|
+
except ImportError as exc:
|
|
20
|
+
raise ProviderError(
|
|
21
|
+
f"the '{package}' package is required for {extra}; install with "
|
|
22
|
+
f"'pip install xyberos-db[{extra}]'"
|
|
23
|
+
) from exc
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SqliteDatabase:
|
|
27
|
+
"""SQLite via the standard library."""
|
|
28
|
+
|
|
29
|
+
name = "sqlite"
|
|
30
|
+
|
|
31
|
+
def __init__(self, path: str = ":memory:") -> None:
|
|
32
|
+
self._path = path
|
|
33
|
+
self._connection: Any = None
|
|
34
|
+
|
|
35
|
+
def connect(self) -> None:
|
|
36
|
+
import sqlite3
|
|
37
|
+
|
|
38
|
+
self._connection = sqlite3.connect(self._path)
|
|
39
|
+
self._connection.row_factory = sqlite3.Row
|
|
40
|
+
|
|
41
|
+
def list_tables(self) -> list[str]:
|
|
42
|
+
rows = self._connection.execute(
|
|
43
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name"
|
|
44
|
+
).fetchall()
|
|
45
|
+
return [str(row[0]) for row in rows]
|
|
46
|
+
|
|
47
|
+
def query(self, sql: str, params: list[Any] | None = None) -> list[dict[str, Any]]:
|
|
48
|
+
cursor = self._connection.execute(sql, tuple(params or ()))
|
|
49
|
+
columns = [description[0] for description in (cursor.description or [])]
|
|
50
|
+
return [dict(zip(columns, row)) for row in cursor.fetchall()]
|
|
51
|
+
|
|
52
|
+
def close(self) -> None:
|
|
53
|
+
if self._connection is not None:
|
|
54
|
+
self._connection.close()
|
|
55
|
+
self._connection = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class PostgresDatabase:
|
|
59
|
+
"""PostgreSQL via lazy ``psycopg``."""
|
|
60
|
+
|
|
61
|
+
name = "postgres"
|
|
62
|
+
|
|
63
|
+
def __init__(self, dsn: str) -> None:
|
|
64
|
+
self._dsn = dsn
|
|
65
|
+
self._connection: Any = None
|
|
66
|
+
|
|
67
|
+
def connect(self) -> None:
|
|
68
|
+
psycopg = _require("psycopg", "postgres")
|
|
69
|
+
self._connection = psycopg.connect(self._dsn)
|
|
70
|
+
|
|
71
|
+
def list_tables(self) -> list[str]:
|
|
72
|
+
with self._connection.cursor() as cursor:
|
|
73
|
+
cursor.execute(
|
|
74
|
+
"SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename"
|
|
75
|
+
)
|
|
76
|
+
return [str(row[0]) for row in cursor.fetchall()]
|
|
77
|
+
|
|
78
|
+
def query(self, sql: str, params: list[Any] | None = None) -> list[dict[str, Any]]:
|
|
79
|
+
with self._connection.cursor() as cursor:
|
|
80
|
+
cursor.execute(sql, tuple(params or ()))
|
|
81
|
+
columns = [description.name for description in (cursor.description or [])]
|
|
82
|
+
return [dict(zip(columns, row)) for row in cursor.fetchall()]
|
|
83
|
+
|
|
84
|
+
def close(self) -> None:
|
|
85
|
+
if self._connection is not None:
|
|
86
|
+
self._connection.close()
|
|
87
|
+
self._connection = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class MysqlDatabase:
|
|
91
|
+
"""MySQL / MariaDB via lazy ``pymysql``."""
|
|
92
|
+
|
|
93
|
+
name = "mysql"
|
|
94
|
+
|
|
95
|
+
def __init__(self, dsn: str | None = None, **connect_kwargs: Any) -> None:
|
|
96
|
+
# dsn may be mysql://user:pass@host:port/db or keyword args.
|
|
97
|
+
self._dsn = dsn
|
|
98
|
+
self._connect_kwargs = connect_kwargs
|
|
99
|
+
self._connection: Any = None
|
|
100
|
+
|
|
101
|
+
def _kwargs(self) -> dict[str, Any]:
|
|
102
|
+
if self._dsn:
|
|
103
|
+
from urllib.parse import urlparse
|
|
104
|
+
|
|
105
|
+
parsed = urlparse(self._dsn)
|
|
106
|
+
return {
|
|
107
|
+
"host": parsed.hostname or "localhost",
|
|
108
|
+
"user": parsed.username or "root",
|
|
109
|
+
"password": parsed.password or "",
|
|
110
|
+
"database": (parsed.path or "/").lstrip("/") or None,
|
|
111
|
+
"port": parsed.port or 3306,
|
|
112
|
+
}
|
|
113
|
+
return dict(self._connect_kwargs)
|
|
114
|
+
|
|
115
|
+
def connect(self) -> None:
|
|
116
|
+
pymysql = _require("pymysql", "mysql")
|
|
117
|
+
self._connection = pymysql.connect(**self._kwargs())
|
|
118
|
+
|
|
119
|
+
def list_tables(self) -> list[str]:
|
|
120
|
+
with self._connection.cursor() as cursor:
|
|
121
|
+
cursor.execute("SHOW TABLES")
|
|
122
|
+
return [str(row[0]) for row in cursor.fetchall()]
|
|
123
|
+
|
|
124
|
+
def query(self, sql: str, params: list[Any] | None = None) -> list[dict[str, Any]]:
|
|
125
|
+
with self._connection.cursor() as cursor:
|
|
126
|
+
cursor.execute(sql, tuple(params or ()))
|
|
127
|
+
columns = [description[0] for description in (cursor.description or [])]
|
|
128
|
+
return [dict(zip(columns, row)) for row in cursor.fetchall()]
|
|
129
|
+
|
|
130
|
+
def close(self) -> None:
|
|
131
|
+
if self._connection is not None:
|
|
132
|
+
self._connection.close()
|
|
133
|
+
self._connection = None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class DuckdbDatabase:
|
|
137
|
+
"""DuckDB via lazy ``duckdb``."""
|
|
138
|
+
|
|
139
|
+
name = "duckdb"
|
|
140
|
+
|
|
141
|
+
def __init__(self, path: str = ":memory:") -> None:
|
|
142
|
+
self._path = path
|
|
143
|
+
self._connection: Any = None
|
|
144
|
+
|
|
145
|
+
def connect(self) -> None:
|
|
146
|
+
duckdb = _require("duckdb", "duckdb")
|
|
147
|
+
self._connection = duckdb.connect(self._path)
|
|
148
|
+
|
|
149
|
+
def list_tables(self) -> list[str]:
|
|
150
|
+
return [str(row[0]) for row in self._connection.execute(
|
|
151
|
+
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'main' ORDER BY table_name"
|
|
152
|
+
).fetchall()]
|
|
153
|
+
|
|
154
|
+
def query(self, sql: str, params: list[Any] | None = None) -> list[dict[str, Any]]:
|
|
155
|
+
result = self._connection.execute(sql, params or [])
|
|
156
|
+
columns = [description[0] for description in (result.description or [])]
|
|
157
|
+
return [dict(zip(columns, row)) for row in result.fetchall()]
|
|
158
|
+
|
|
159
|
+
def close(self) -> None:
|
|
160
|
+
if self._connection is not None:
|
|
161
|
+
self._connection.close()
|
|
162
|
+
self._connection = None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def build_database(dsn: str | None = None, *, backend: str | None = None) -> Any:
|
|
166
|
+
"""Return a :class:`Database` from a DSN or an explicit backend name."""
|
|
167
|
+
if backend:
|
|
168
|
+
name = backend.lower()
|
|
169
|
+
if name == "sqlite":
|
|
170
|
+
return SqliteDatabase(dsn or ":memory:")
|
|
171
|
+
if name == "postgres":
|
|
172
|
+
return PostgresDatabase(dsn or "")
|
|
173
|
+
if name == "mysql":
|
|
174
|
+
return MysqlDatabase(dsn)
|
|
175
|
+
if name == "duckdb":
|
|
176
|
+
return DuckdbDatabase(dsn or ":memory:")
|
|
177
|
+
raise ValueError(f"unknown database backend '{backend}' (sqlite | postgres | mysql | duckdb)")
|
|
178
|
+
if dsn:
|
|
179
|
+
if dsn.startswith("postgres"):
|
|
180
|
+
return PostgresDatabase(dsn)
|
|
181
|
+
if dsn.startswith("mysql"):
|
|
182
|
+
return MysqlDatabase(dsn)
|
|
183
|
+
if dsn.startswith("duckdb"):
|
|
184
|
+
return DuckdbDatabase(dsn)
|
|
185
|
+
return SqliteDatabase(dsn)
|
|
186
|
+
raise ValueError("db plugin not configured: pass dsn=... or backend=...")
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""The Database contract (RFC-0020): connect -> inspect -> query -> result."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@runtime_checkable
|
|
9
|
+
class Database(Protocol):
|
|
10
|
+
"""A DB-agnostic connection that returns structured results.
|
|
11
|
+
|
|
12
|
+
``query`` returns a list of row dicts (column name -> value) so every
|
|
13
|
+
backend — SQL, document, or graph — speaks one shape.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
name: str
|
|
17
|
+
|
|
18
|
+
def connect(self) -> None:
|
|
19
|
+
"""Open the connection."""
|
|
20
|
+
|
|
21
|
+
def list_tables(self) -> list[str]:
|
|
22
|
+
"""Return the schema's table names."""
|
|
23
|
+
|
|
24
|
+
def query(self, sql: str, params: list[Any] | None = None) -> list[dict[str, Any]]:
|
|
25
|
+
"""Execute ``sql`` and return rows as dicts."""
|
|
26
|
+
|
|
27
|
+
def close(self) -> None:
|
|
28
|
+
"""Release the connection."""
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Database plugin entry point (RFC-0019/0020, M9)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
from xyberos.contracts import Plugin, Tool
|
|
9
|
+
from xyberos.tools import FunctionTool
|
|
10
|
+
|
|
11
|
+
from .adapters import build_database
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _pop_tool(registry: Any, name: str) -> None:
|
|
15
|
+
unregister = getattr(registry, "unregister", None)
|
|
16
|
+
if callable(unregister):
|
|
17
|
+
unregister(name)
|
|
18
|
+
return
|
|
19
|
+
store = getattr(registry, "_tools", None)
|
|
20
|
+
if isinstance(store, dict):
|
|
21
|
+
cast(dict[str, Any], store).pop(name, None)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DbPlugin(Plugin):
|
|
25
|
+
"""Registers ``db_list_tables`` / ``db_query`` tools for a configured database."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
dsn: str | None = None,
|
|
30
|
+
*,
|
|
31
|
+
backend: str | None = None,
|
|
32
|
+
env_prefix: str = "DB",
|
|
33
|
+
) -> None:
|
|
34
|
+
self._dsn = dsn if dsn is not None else os.getenv("DB_DSN") or os.getenv("DATABASE_URL")
|
|
35
|
+
self._backend = backend or os.getenv(f"{env_prefix}_BACKEND")
|
|
36
|
+
self._database: Any = None
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def name(self) -> str:
|
|
40
|
+
return "db"
|
|
41
|
+
|
|
42
|
+
def database(self) -> Any:
|
|
43
|
+
if self._database is None:
|
|
44
|
+
db = build_database(self._dsn, backend=self._backend)
|
|
45
|
+
db.connect()
|
|
46
|
+
self._database = db
|
|
47
|
+
return self._database
|
|
48
|
+
|
|
49
|
+
def tools(self) -> list[Tool]:
|
|
50
|
+
db = self.database()
|
|
51
|
+
|
|
52
|
+
def _list_tables() -> list[str]:
|
|
53
|
+
return db.list_tables()
|
|
54
|
+
|
|
55
|
+
def _query(sql: str, params: list[Any] | None = None) -> list[dict[str, Any]]:
|
|
56
|
+
return db.query(sql, params)
|
|
57
|
+
|
|
58
|
+
return [
|
|
59
|
+
FunctionTool("db_list_tables", _list_tables, description="List the database tables."),
|
|
60
|
+
FunctionTool("db_query", _query, description="Run a read query and return rows as dicts."),
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
def register(self, kernel: object) -> None:
|
|
64
|
+
try:
|
|
65
|
+
self.database()
|
|
66
|
+
except ValueError as exc:
|
|
67
|
+
logger = getattr(kernel, "logger", None)
|
|
68
|
+
if logger is not None and callable(getattr(logger, "warning", None)):
|
|
69
|
+
logger.warning("db plugin not configured: %s", exc)
|
|
70
|
+
return
|
|
71
|
+
registry = kernel.resolve("tools")
|
|
72
|
+
for tool in self.tools():
|
|
73
|
+
registry.register(tool)
|
|
74
|
+
|
|
75
|
+
def unregister(self, kernel: object) -> None:
|
|
76
|
+
if self._database is None:
|
|
77
|
+
return # never configured -> nothing was registered
|
|
78
|
+
registry = kernel.resolve("tools")
|
|
79
|
+
for tool in self.tools():
|
|
80
|
+
_pop_tool(registry, tool.name)
|
|
81
|
+
try:
|
|
82
|
+
self._database.close()
|
|
83
|
+
except Exception:
|
|
84
|
+
pass
|
|
85
|
+
self._database = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
#: Auto-discovered by ``app.load_entry_points()``.
|
|
89
|
+
plugin = DbPlugin()
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xyberos-db
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Database plugin (RFC-0019/0020, M9): connect -> inspect schema -> query -> structured result
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Keywords: xyberos,plugin,database,sqlite,postgres,mysql,duckdb
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: xyberos>=1.0
|
|
10
|
+
Provides-Extra: postgres
|
|
11
|
+
Requires-Dist: psycopg[binary]; extra == "postgres"
|
|
12
|
+
Provides-Extra: mysql
|
|
13
|
+
Requires-Dist: pymysql; extra == "mysql"
|
|
14
|
+
Provides-Extra: duckdb
|
|
15
|
+
Requires-Dist: duckdb; extra == "duckdb"
|
|
16
|
+
Provides-Extra: test
|
|
17
|
+
Requires-Dist: pytest; extra == "test"
|
|
18
|
+
|
|
19
|
+
# xyberos-db
|
|
20
|
+
|
|
21
|
+
**Database plugin — RFC-0019/0020, M9.** The DB-agnostic `Database` contract
|
|
22
|
+
(`connect → list_tables → query → close`) with a stdlib SQLite reference plus
|
|
23
|
+
lazy Postgres / MySQL / DuckDB drivers.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install -e ./db
|
|
29
|
+
pip install xyberos-db[postgres] # optional drivers
|
|
30
|
+
pip install xyberos-db[mysql]
|
|
31
|
+
pip install xyberos-db[duckdb]
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from xyberos import create_app
|
|
38
|
+
from xyberos_db import DbPlugin
|
|
39
|
+
|
|
40
|
+
app = create_app()
|
|
41
|
+
app.load_plugin(DbPlugin(backend="sqlite")) # in-memory
|
|
42
|
+
# app.load_plugin(DbPlugin(dsn="postgres://user:pass@host/db"))
|
|
43
|
+
|
|
44
|
+
app.tools.execute("db_list_tables", None)
|
|
45
|
+
rows = app.tools.execute("db_query", None, sql="SELECT id, name FROM users")
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`dsn` auto-detects the backend (`postgres://` / `mysql://` / `duckdb://` /
|
|
49
|
+
anything else → SQLite path). Returns rows as `list[dict]`.
|
|
50
|
+
|
|
51
|
+
## Direct use
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from xyberos_db import SqliteDatabase
|
|
55
|
+
|
|
56
|
+
db = SqliteDatabase("app.db")
|
|
57
|
+
db.connect()
|
|
58
|
+
print(db.list_tables())
|
|
59
|
+
print(db.query("SELECT 1 AS one"))
|
|
60
|
+
db.close()
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## RFC
|
|
64
|
+
|
|
65
|
+
The contract is specified in [`RFC-0020-database-plugin-contract.md`](../RFC-0020-database-plugin-contract.md)
|
|
66
|
+
(the M9 "Core additive RFC").
|
|
67
|
+
|
|
68
|
+
## Tests
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pip install pytest
|
|
72
|
+
pytest tests/
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
SQLite is fully tested; driver adapters skip when their driver is absent.
|
|
76
|
+
|
|
77
|
+
## Ship location
|
|
78
|
+
|
|
79
|
+
Plugin (`xyberos.plugins` entry point) + RFC-0020 contract — enterprise DBs (M9).
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
tests/test_adapters.py
|
|
4
|
+
tests/test_plugin.py
|
|
5
|
+
xyberos_db/__init__.py
|
|
6
|
+
xyberos_db/adapters.py
|
|
7
|
+
xyberos_db/contract.py
|
|
8
|
+
xyberos_db/plugin.py
|
|
9
|
+
xyberos_db.egg-info/PKG-INFO
|
|
10
|
+
xyberos_db.egg-info/SOURCES.txt
|
|
11
|
+
xyberos_db.egg-info/dependency_links.txt
|
|
12
|
+
xyberos_db.egg-info/entry_points.txt
|
|
13
|
+
xyberos_db.egg-info/requires.txt
|
|
14
|
+
xyberos_db.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
xyberos_db
|