muscles-data-sqlalchemy 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.
- muscles_data_sqlalchemy-0.1.0/PKG-INFO +80 -0
- muscles_data_sqlalchemy-0.1.0/README.md +68 -0
- muscles_data_sqlalchemy-0.1.0/pyproject.toml +32 -0
- muscles_data_sqlalchemy-0.1.0/setup.cfg +4 -0
- muscles_data_sqlalchemy-0.1.0/src/muscles_data_sqlalchemy/__init__.py +20 -0
- muscles_data_sqlalchemy-0.1.0/src/muscles_data_sqlalchemy/adapter.py +215 -0
- muscles_data_sqlalchemy-0.1.0/src/muscles_data_sqlalchemy.egg-info/PKG-INFO +80 -0
- muscles_data_sqlalchemy-0.1.0/src/muscles_data_sqlalchemy.egg-info/SOURCES.txt +11 -0
- muscles_data_sqlalchemy-0.1.0/src/muscles_data_sqlalchemy.egg-info/dependency_links.txt +1 -0
- muscles_data_sqlalchemy-0.1.0/src/muscles_data_sqlalchemy.egg-info/requires.txt +5 -0
- muscles_data_sqlalchemy-0.1.0/src/muscles_data_sqlalchemy.egg-info/top_level.txt +1 -0
- muscles_data_sqlalchemy-0.1.0/tests/test_integration.py +75 -0
- muscles_data_sqlalchemy-0.1.0/tests/test_sqlalchemy_adapter.py +80 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: muscles-data-sqlalchemy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: SQLAlchemy SqlResourcePort adapter for muscles-data
|
|
5
|
+
Author-email: "Denis B." <denis@butko.info>
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: muscles-data<1.0.0,>=0.1.0
|
|
9
|
+
Requires-Dist: SQLAlchemy<3.0,>=2.0
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest<9.0,>=8.0; extra == "dev"
|
|
12
|
+
|
|
13
|
+
# muscles-data-sqlalchemy
|
|
14
|
+
|
|
15
|
+
SQLAlchemy adapter package for `muscles-data`.
|
|
16
|
+
|
|
17
|
+
This package is intentionally separate from `muscles-data`: the core package
|
|
18
|
+
owns typed ports, resource runtime and diagnostics, while this package owns the
|
|
19
|
+
SQLAlchemy-backed `SqlResourcePort` adapter.
|
|
20
|
+
|
|
21
|
+
Use this package when a project wants direct SQLAlchemy sessions through the
|
|
22
|
+
same data resource runtime without wiring `muscles-sql` first.
|
|
23
|
+
|
|
24
|
+
## Related packages
|
|
25
|
+
|
|
26
|
+
- Core runtime and port contracts:
|
|
27
|
+
[`muscles-data`](https://github.com/butkoden/muscles-data)
|
|
28
|
+
- Elasticsearch search adapter:
|
|
29
|
+
[`muscles-data-elasticsearch`](https://github.com/butkoden/muscles-data-elasticsearch)
|
|
30
|
+
- OpenSearch search adapter:
|
|
31
|
+
[`muscles-data-opensearch`](https://github.com/butkoden/muscles-data-opensearch)
|
|
32
|
+
- Redis key-value/lock/stream adapter:
|
|
33
|
+
[`muscles-data-redis`](https://github.com/butkoden/muscles-data-redis)
|
|
34
|
+
- Qdrant vector adapter:
|
|
35
|
+
[`muscles-data-qdrant`](https://github.com/butkoden/muscles-data-qdrant)
|
|
36
|
+
- MongoDB document-store adapter:
|
|
37
|
+
[`muscles-data-mongodb`](https://github.com/butkoden/muscles-data-mongodb)
|
|
38
|
+
- S3 object-store adapter:
|
|
39
|
+
[`muscles-data-s3`](https://github.com/butkoden/muscles-data-s3)
|
|
40
|
+
- Executable example:
|
|
41
|
+
[`example_data_sqlalchemy_1`](https://github.com/butkoden/muscular-example/tree/master/example_data_sqlalchemy_1)
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
Register the factory in the project composition root:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from muscles_data.catalog import DataAdapterCatalog
|
|
49
|
+
from muscles_data.ports import SqlResourcePort
|
|
50
|
+
from muscles_data.runtime import DataRuntime
|
|
51
|
+
from muscles_data_sqlalchemy import SqlAlchemySqlResourceFactory
|
|
52
|
+
|
|
53
|
+
catalog = DataAdapterCatalog.with_defaults()
|
|
54
|
+
catalog.register(SqlAlchemySqlResourceFactory())
|
|
55
|
+
|
|
56
|
+
runtime = DataRuntime(config=config, catalog=catalog)
|
|
57
|
+
sql = runtime.require_port("sql.local", SqlResourcePort)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Resource config stays in the project:
|
|
61
|
+
|
|
62
|
+
```yaml
|
|
63
|
+
data:
|
|
64
|
+
resources:
|
|
65
|
+
sql.local:
|
|
66
|
+
type: sqlalchemy
|
|
67
|
+
url: sqlite:///:memory:
|
|
68
|
+
name: local_sqlite
|
|
69
|
+
pool_pre_ping: true
|
|
70
|
+
native_client: false
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`data.resources.list` does not create the engine. The engine/session factory is
|
|
74
|
+
created lazily by `session()`, `session_factory()`, explicit native access or
|
|
75
|
+
`data.doctor`.
|
|
76
|
+
|
|
77
|
+
Application code should use `SqlResourcePort`; direct engine/session-factory
|
|
78
|
+
access is only an advanced escape hatch with `native_client: true`.
|
|
79
|
+
|
|
80
|
+
See `muscular-example/example_data_sqlalchemy_1` for an executable example.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# muscles-data-sqlalchemy
|
|
2
|
+
|
|
3
|
+
SQLAlchemy adapter package for `muscles-data`.
|
|
4
|
+
|
|
5
|
+
This package is intentionally separate from `muscles-data`: the core package
|
|
6
|
+
owns typed ports, resource runtime and diagnostics, while this package owns the
|
|
7
|
+
SQLAlchemy-backed `SqlResourcePort` adapter.
|
|
8
|
+
|
|
9
|
+
Use this package when a project wants direct SQLAlchemy sessions through the
|
|
10
|
+
same data resource runtime without wiring `muscles-sql` first.
|
|
11
|
+
|
|
12
|
+
## Related packages
|
|
13
|
+
|
|
14
|
+
- Core runtime and port contracts:
|
|
15
|
+
[`muscles-data`](https://github.com/butkoden/muscles-data)
|
|
16
|
+
- Elasticsearch search adapter:
|
|
17
|
+
[`muscles-data-elasticsearch`](https://github.com/butkoden/muscles-data-elasticsearch)
|
|
18
|
+
- OpenSearch search adapter:
|
|
19
|
+
[`muscles-data-opensearch`](https://github.com/butkoden/muscles-data-opensearch)
|
|
20
|
+
- Redis key-value/lock/stream adapter:
|
|
21
|
+
[`muscles-data-redis`](https://github.com/butkoden/muscles-data-redis)
|
|
22
|
+
- Qdrant vector adapter:
|
|
23
|
+
[`muscles-data-qdrant`](https://github.com/butkoden/muscles-data-qdrant)
|
|
24
|
+
- MongoDB document-store adapter:
|
|
25
|
+
[`muscles-data-mongodb`](https://github.com/butkoden/muscles-data-mongodb)
|
|
26
|
+
- S3 object-store adapter:
|
|
27
|
+
[`muscles-data-s3`](https://github.com/butkoden/muscles-data-s3)
|
|
28
|
+
- Executable example:
|
|
29
|
+
[`example_data_sqlalchemy_1`](https://github.com/butkoden/muscular-example/tree/master/example_data_sqlalchemy_1)
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
Register the factory in the project composition root:
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from muscles_data.catalog import DataAdapterCatalog
|
|
37
|
+
from muscles_data.ports import SqlResourcePort
|
|
38
|
+
from muscles_data.runtime import DataRuntime
|
|
39
|
+
from muscles_data_sqlalchemy import SqlAlchemySqlResourceFactory
|
|
40
|
+
|
|
41
|
+
catalog = DataAdapterCatalog.with_defaults()
|
|
42
|
+
catalog.register(SqlAlchemySqlResourceFactory())
|
|
43
|
+
|
|
44
|
+
runtime = DataRuntime(config=config, catalog=catalog)
|
|
45
|
+
sql = runtime.require_port("sql.local", SqlResourcePort)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Resource config stays in the project:
|
|
49
|
+
|
|
50
|
+
```yaml
|
|
51
|
+
data:
|
|
52
|
+
resources:
|
|
53
|
+
sql.local:
|
|
54
|
+
type: sqlalchemy
|
|
55
|
+
url: sqlite:///:memory:
|
|
56
|
+
name: local_sqlite
|
|
57
|
+
pool_pre_ping: true
|
|
58
|
+
native_client: false
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`data.resources.list` does not create the engine. The engine/session factory is
|
|
62
|
+
created lazily by `session()`, `session_factory()`, explicit native access or
|
|
63
|
+
`data.doctor`.
|
|
64
|
+
|
|
65
|
+
Application code should use `SqlResourcePort`; direct engine/session-factory
|
|
66
|
+
access is only an advanced escape hatch with `native_client: true`.
|
|
67
|
+
|
|
68
|
+
See `muscular-example/example_data_sqlalchemy_1` for an executable example.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "muscles-data-sqlalchemy"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "SQLAlchemy SqlResourcePort adapter for muscles-data"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
authors = [{ name = "Denis B.", email = "denis@butko.info" }]
|
|
12
|
+
dependencies = [
|
|
13
|
+
"muscles-data>=0.1.0,<1.0.0",
|
|
14
|
+
"SQLAlchemy>=2.0,<3.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
dev = [
|
|
19
|
+
"pytest>=8.0,<9.0",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[tool.pytest.ini_options]
|
|
23
|
+
testpaths = ["tests"]
|
|
24
|
+
markers = [
|
|
25
|
+
"integration: tests that require the local backend integration stack",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[tool.setuptools]
|
|
29
|
+
package-dir = {"" = "src"}
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.packages.find]
|
|
32
|
+
where = ["src"]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .adapter import (
|
|
4
|
+
SqlAlchemyAdapterError,
|
|
5
|
+
SqlAlchemyClientMissingError,
|
|
6
|
+
SqlAlchemyConfigError,
|
|
7
|
+
SqlAlchemyConnectionError,
|
|
8
|
+
SqlAlchemySqlResourceAdapter,
|
|
9
|
+
SqlAlchemySqlResourceFactory,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"SqlAlchemyAdapterError",
|
|
15
|
+
"SqlAlchemyClientMissingError",
|
|
16
|
+
"SqlAlchemyConfigError",
|
|
17
|
+
"SqlAlchemyConnectionError",
|
|
18
|
+
"SqlAlchemySqlResourceAdapter",
|
|
19
|
+
"SqlAlchemySqlResourceFactory",
|
|
20
|
+
]
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import threading
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import asdict
|
|
7
|
+
from typing import Any, Mapping
|
|
8
|
+
|
|
9
|
+
from muscles_data.config import DataResourceConfig
|
|
10
|
+
from muscles_data.errors import DataError
|
|
11
|
+
from muscles_data.models import DataCapability, HealthResult, InspectResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_ENGINE_UNSET = object()
|
|
15
|
+
_SESSION_FACTORY_UNSET = object()
|
|
16
|
+
_ALLOWED_OPTIONS = {
|
|
17
|
+
"url",
|
|
18
|
+
"url_env",
|
|
19
|
+
"name",
|
|
20
|
+
"native_client",
|
|
21
|
+
"echo",
|
|
22
|
+
"pool_pre_ping",
|
|
23
|
+
"pool_size",
|
|
24
|
+
"max_overflow",
|
|
25
|
+
"connect_args",
|
|
26
|
+
"future",
|
|
27
|
+
}
|
|
28
|
+
_ENGINE_OPTIONS = {"echo", "pool_pre_ping", "pool_size", "max_overflow", "connect_args", "future"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SqlAlchemyAdapterError(DataError):
|
|
32
|
+
"""Base error for direct SQLAlchemy resource adapter failures."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SqlAlchemyConfigError(ValueError, SqlAlchemyAdapterError):
|
|
36
|
+
"""Raised when a SQLAlchemy resource config cannot be mapped safely."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SqlAlchemyClientMissingError(SqlAlchemyAdapterError):
|
|
40
|
+
"""Raised when SQLAlchemy is not installed for a direct SQLAlchemy resource."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SqlAlchemyConnectionError(SqlAlchemyAdapterError):
|
|
44
|
+
"""Raised when a SQLAlchemy engine/session operation fails."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class SqlAlchemySqlResourceAdapter:
|
|
48
|
+
resource_type = "sqlalchemy"
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
config: DataResourceConfig,
|
|
53
|
+
*,
|
|
54
|
+
sqlalchemy_provider: Callable[[], Any] | None = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
self.config = config
|
|
57
|
+
self._sqlalchemy_provider = sqlalchemy_provider
|
|
58
|
+
self._sqlalchemy: Any | None = None
|
|
59
|
+
self._engine: Any = _ENGINE_UNSET
|
|
60
|
+
self._session_factory: Any = _SESSION_FACTORY_UNSET
|
|
61
|
+
self._lock = threading.RLock()
|
|
62
|
+
self.closed = False
|
|
63
|
+
|
|
64
|
+
def connection_name(self) -> str:
|
|
65
|
+
return str(self.config.options.get("name", self.config.name))
|
|
66
|
+
|
|
67
|
+
def session(self):
|
|
68
|
+
try:
|
|
69
|
+
return self.session_factory()()
|
|
70
|
+
except (SqlAlchemyClientMissingError, SqlAlchemyConfigError, SqlAlchemyConnectionError):
|
|
71
|
+
raise
|
|
72
|
+
except Exception as exc:
|
|
73
|
+
raise SqlAlchemyConnectionError(self._safe_error(exc)) from exc
|
|
74
|
+
|
|
75
|
+
def session_factory(self):
|
|
76
|
+
if self._session_factory is _SESSION_FACTORY_UNSET:
|
|
77
|
+
with self._lock:
|
|
78
|
+
if self._session_factory is _SESSION_FACTORY_UNSET:
|
|
79
|
+
self._sqlalchemy_module()
|
|
80
|
+
try:
|
|
81
|
+
orm = importlib.import_module("sqlalchemy.orm")
|
|
82
|
+
self._session_factory = orm.sessionmaker(bind=self._engine_instance())
|
|
83
|
+
except (SqlAlchemyClientMissingError, SqlAlchemyConfigError, SqlAlchemyConnectionError):
|
|
84
|
+
raise
|
|
85
|
+
except Exception as exc:
|
|
86
|
+
raise SqlAlchemyConnectionError(self._safe_error(exc)) from exc
|
|
87
|
+
return self._session_factory
|
|
88
|
+
|
|
89
|
+
def inspect(self) -> Mapping[str, Any]:
|
|
90
|
+
return asdict(
|
|
91
|
+
InspectResult(
|
|
92
|
+
name=self.config.name,
|
|
93
|
+
type=self.config.type,
|
|
94
|
+
capabilities=[],
|
|
95
|
+
initialized=self._engine is not _ENGINE_UNSET,
|
|
96
|
+
status="ok",
|
|
97
|
+
options=self.config.safe_options(),
|
|
98
|
+
details={
|
|
99
|
+
"backend": "sqlalchemy",
|
|
100
|
+
"connection": self.connection_name(),
|
|
101
|
+
"engine_initialized": self._engine is not _ENGINE_UNSET,
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def doctor(self) -> Mapping[str, Any]:
|
|
107
|
+
try:
|
|
108
|
+
sqlalchemy = self._sqlalchemy_module()
|
|
109
|
+
with self._engine_instance().connect() as connection:
|
|
110
|
+
connection.execute(sqlalchemy.text("SELECT 1"))
|
|
111
|
+
except Exception as exc:
|
|
112
|
+
return {
|
|
113
|
+
"status": "failed",
|
|
114
|
+
"message": self._safe_error(exc),
|
|
115
|
+
"details": {"backend": "sqlalchemy", "connection": self.connection_name()},
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
"status": "ok",
|
|
119
|
+
"message": "SQLAlchemy connection is available",
|
|
120
|
+
"details": {"backend": "sqlalchemy", "connection": self.connection_name()},
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
def health(self) -> HealthResult:
|
|
124
|
+
doctor = self.doctor()
|
|
125
|
+
return HealthResult(
|
|
126
|
+
status=str(doctor.get("status", "failed")),
|
|
127
|
+
message=doctor.get("message"),
|
|
128
|
+
details=dict(doctor.get("details", {}) or {}),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def close(self) -> None:
|
|
132
|
+
if self._engine is not _ENGINE_UNSET:
|
|
133
|
+
dispose = getattr(self._engine, "dispose", None)
|
|
134
|
+
if callable(dispose):
|
|
135
|
+
dispose()
|
|
136
|
+
self.closed = True
|
|
137
|
+
|
|
138
|
+
def native_client(self):
|
|
139
|
+
return {"engine": self._engine_instance(), "session_factory": self.session_factory()}
|
|
140
|
+
|
|
141
|
+
def _engine_instance(self):
|
|
142
|
+
if self._engine is _ENGINE_UNSET:
|
|
143
|
+
with self._lock:
|
|
144
|
+
if self._engine is _ENGINE_UNSET:
|
|
145
|
+
self._engine = self._create_engine()
|
|
146
|
+
return self._engine
|
|
147
|
+
|
|
148
|
+
def _create_engine(self):
|
|
149
|
+
self._validate_options()
|
|
150
|
+
sqlalchemy = self._sqlalchemy_module()
|
|
151
|
+
options = self.config.resolved_options()
|
|
152
|
+
kwargs = _engine_options(options)
|
|
153
|
+
try:
|
|
154
|
+
return sqlalchemy.create_engine(str(options["url"]), **kwargs)
|
|
155
|
+
except Exception as exc:
|
|
156
|
+
raise SqlAlchemyConnectionError(self._safe_error(exc)) from exc
|
|
157
|
+
|
|
158
|
+
def _sqlalchemy_module(self):
|
|
159
|
+
if self._sqlalchemy is None:
|
|
160
|
+
module = self._sqlalchemy_provider() if self._sqlalchemy_provider else _default_sqlalchemy_module()
|
|
161
|
+
if module is None:
|
|
162
|
+
raise SqlAlchemyClientMissingError("Install SQLAlchemy or muscles-data-sqlalchemy to use type=sqlalchemy")
|
|
163
|
+
self._sqlalchemy = module
|
|
164
|
+
return self._sqlalchemy
|
|
165
|
+
|
|
166
|
+
def _validate_options(self) -> None:
|
|
167
|
+
unknown = sorted(set(self.config.options) - _ALLOWED_OPTIONS)
|
|
168
|
+
if unknown:
|
|
169
|
+
names = ", ".join(unknown)
|
|
170
|
+
raise SqlAlchemyConfigError(f"Unsupported SQLAlchemy resource options: {names}")
|
|
171
|
+
if not self.config.options.get("url") and not self.config.options.get("url_env"):
|
|
172
|
+
raise SqlAlchemyConfigError("SQLAlchemy resource requires url or url_env")
|
|
173
|
+
connect_args = self.config.options.get("connect_args")
|
|
174
|
+
if connect_args is not None and not isinstance(connect_args, Mapping):
|
|
175
|
+
raise SqlAlchemyConfigError("SQLAlchemy connect_args must be a mapping")
|
|
176
|
+
|
|
177
|
+
def _safe_error(self, exc: Exception) -> str:
|
|
178
|
+
message = str(exc)
|
|
179
|
+
try:
|
|
180
|
+
url = str(self.config.resolved_options().get("url", ""))
|
|
181
|
+
except Exception:
|
|
182
|
+
url = str(self.config.options.get("url", ""))
|
|
183
|
+
if url:
|
|
184
|
+
message = message.replace(url, "***")
|
|
185
|
+
safe_url = self.config.safe_options().get("url")
|
|
186
|
+
if isinstance(safe_url, str) and safe_url != url:
|
|
187
|
+
message = message.replace(safe_url, "***")
|
|
188
|
+
return message
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class SqlAlchemySqlResourceFactory:
|
|
192
|
+
resource_type = "sqlalchemy"
|
|
193
|
+
|
|
194
|
+
def __init__(self, *, sqlalchemy_provider: Callable[[], Any] | None = None) -> None:
|
|
195
|
+
self._sqlalchemy_provider = sqlalchemy_provider
|
|
196
|
+
|
|
197
|
+
def capabilities(self, config: DataResourceConfig) -> set[DataCapability]:
|
|
198
|
+
native = {DataCapability.NATIVE_CLIENT} if bool(config.options.get("native_client")) else set()
|
|
199
|
+
return {DataCapability.SQL_SESSION, DataCapability.HEALTHCHECK} | native
|
|
200
|
+
|
|
201
|
+
def create(self, config: DataResourceConfig) -> SqlAlchemySqlResourceAdapter:
|
|
202
|
+
return SqlAlchemySqlResourceAdapter(config, sqlalchemy_provider=self._sqlalchemy_provider)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _engine_options(options: Mapping[str, Any]) -> dict[str, Any]:
|
|
206
|
+
output = {key: options[key] for key in _ENGINE_OPTIONS if key in options}
|
|
207
|
+
output.setdefault("future", True)
|
|
208
|
+
return output
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _default_sqlalchemy_module():
|
|
212
|
+
try:
|
|
213
|
+
return importlib.import_module("sqlalchemy")
|
|
214
|
+
except Exception as exc:
|
|
215
|
+
raise SqlAlchemyClientMissingError("Install SQLAlchemy or muscles-data-sqlalchemy to use type=sqlalchemy") from exc
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: muscles-data-sqlalchemy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: SQLAlchemy SqlResourcePort adapter for muscles-data
|
|
5
|
+
Author-email: "Denis B." <denis@butko.info>
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: muscles-data<1.0.0,>=0.1.0
|
|
9
|
+
Requires-Dist: SQLAlchemy<3.0,>=2.0
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest<9.0,>=8.0; extra == "dev"
|
|
12
|
+
|
|
13
|
+
# muscles-data-sqlalchemy
|
|
14
|
+
|
|
15
|
+
SQLAlchemy adapter package for `muscles-data`.
|
|
16
|
+
|
|
17
|
+
This package is intentionally separate from `muscles-data`: the core package
|
|
18
|
+
owns typed ports, resource runtime and diagnostics, while this package owns the
|
|
19
|
+
SQLAlchemy-backed `SqlResourcePort` adapter.
|
|
20
|
+
|
|
21
|
+
Use this package when a project wants direct SQLAlchemy sessions through the
|
|
22
|
+
same data resource runtime without wiring `muscles-sql` first.
|
|
23
|
+
|
|
24
|
+
## Related packages
|
|
25
|
+
|
|
26
|
+
- Core runtime and port contracts:
|
|
27
|
+
[`muscles-data`](https://github.com/butkoden/muscles-data)
|
|
28
|
+
- Elasticsearch search adapter:
|
|
29
|
+
[`muscles-data-elasticsearch`](https://github.com/butkoden/muscles-data-elasticsearch)
|
|
30
|
+
- OpenSearch search adapter:
|
|
31
|
+
[`muscles-data-opensearch`](https://github.com/butkoden/muscles-data-opensearch)
|
|
32
|
+
- Redis key-value/lock/stream adapter:
|
|
33
|
+
[`muscles-data-redis`](https://github.com/butkoden/muscles-data-redis)
|
|
34
|
+
- Qdrant vector adapter:
|
|
35
|
+
[`muscles-data-qdrant`](https://github.com/butkoden/muscles-data-qdrant)
|
|
36
|
+
- MongoDB document-store adapter:
|
|
37
|
+
[`muscles-data-mongodb`](https://github.com/butkoden/muscles-data-mongodb)
|
|
38
|
+
- S3 object-store adapter:
|
|
39
|
+
[`muscles-data-s3`](https://github.com/butkoden/muscles-data-s3)
|
|
40
|
+
- Executable example:
|
|
41
|
+
[`example_data_sqlalchemy_1`](https://github.com/butkoden/muscular-example/tree/master/example_data_sqlalchemy_1)
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
Register the factory in the project composition root:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from muscles_data.catalog import DataAdapterCatalog
|
|
49
|
+
from muscles_data.ports import SqlResourcePort
|
|
50
|
+
from muscles_data.runtime import DataRuntime
|
|
51
|
+
from muscles_data_sqlalchemy import SqlAlchemySqlResourceFactory
|
|
52
|
+
|
|
53
|
+
catalog = DataAdapterCatalog.with_defaults()
|
|
54
|
+
catalog.register(SqlAlchemySqlResourceFactory())
|
|
55
|
+
|
|
56
|
+
runtime = DataRuntime(config=config, catalog=catalog)
|
|
57
|
+
sql = runtime.require_port("sql.local", SqlResourcePort)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Resource config stays in the project:
|
|
61
|
+
|
|
62
|
+
```yaml
|
|
63
|
+
data:
|
|
64
|
+
resources:
|
|
65
|
+
sql.local:
|
|
66
|
+
type: sqlalchemy
|
|
67
|
+
url: sqlite:///:memory:
|
|
68
|
+
name: local_sqlite
|
|
69
|
+
pool_pre_ping: true
|
|
70
|
+
native_client: false
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`data.resources.list` does not create the engine. The engine/session factory is
|
|
74
|
+
created lazily by `session()`, `session_factory()`, explicit native access or
|
|
75
|
+
`data.doctor`.
|
|
76
|
+
|
|
77
|
+
Application code should use `SqlResourcePort`; direct engine/session-factory
|
|
78
|
+
access is only an advanced escape hatch with `native_client: true`.
|
|
79
|
+
|
|
80
|
+
See `muscular-example/example_data_sqlalchemy_1` for an executable example.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/muscles_data_sqlalchemy/__init__.py
|
|
4
|
+
src/muscles_data_sqlalchemy/adapter.py
|
|
5
|
+
src/muscles_data_sqlalchemy.egg-info/PKG-INFO
|
|
6
|
+
src/muscles_data_sqlalchemy.egg-info/SOURCES.txt
|
|
7
|
+
src/muscles_data_sqlalchemy.egg-info/dependency_links.txt
|
|
8
|
+
src/muscles_data_sqlalchemy.egg-info/requires.txt
|
|
9
|
+
src/muscles_data_sqlalchemy.egg-info/top_level.txt
|
|
10
|
+
tests/test_integration.py
|
|
11
|
+
tests/test_sqlalchemy_adapter.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
muscles_data_sqlalchemy
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from tempfile import TemporaryDirectory
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from muscles_data.catalog import DataAdapterCatalog
|
|
9
|
+
from muscles_data.config import DataConfig
|
|
10
|
+
from muscles_data.ports import SqlResourcePort
|
|
11
|
+
from muscles_data.runtime import DataRuntime
|
|
12
|
+
|
|
13
|
+
from muscles_data_sqlalchemy import SqlAlchemySqlResourceFactory
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
pytestmark = [
|
|
17
|
+
pytest.mark.integration,
|
|
18
|
+
pytest.mark.skipif(not os.getenv("MUSCLES_DATA_INTEGRATION"), reason="backend integration is disabled"),
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _runtime(url: str) -> DataRuntime:
|
|
23
|
+
config = DataConfig.from_raw(
|
|
24
|
+
{
|
|
25
|
+
"data": {
|
|
26
|
+
"resources": {
|
|
27
|
+
"sql.integration": {
|
|
28
|
+
"type": "sqlalchemy",
|
|
29
|
+
"url": url,
|
|
30
|
+
"name": "integration_sql",
|
|
31
|
+
"pool_pre_ping": True,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
catalog = DataAdapterCatalog.with_defaults()
|
|
38
|
+
catalog.register(SqlAlchemySqlResourceFactory())
|
|
39
|
+
return DataRuntime(config=config, catalog=catalog)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_sqlalchemy_real_session_and_health_lifecycle():
|
|
43
|
+
from sqlalchemy import text
|
|
44
|
+
|
|
45
|
+
with TemporaryDirectory() as temporary:
|
|
46
|
+
runtime = _runtime(f"sqlite:///{Path(temporary) / 'integration.sqlite3'}")
|
|
47
|
+
try:
|
|
48
|
+
sql = runtime.require_port("sql.integration", SqlResourcePort)
|
|
49
|
+
with sql.session() as session:
|
|
50
|
+
assert session.execute(text("select 1")).scalar_one() == 1
|
|
51
|
+
assert sql.doctor()["status"] == "ok"
|
|
52
|
+
assert runtime.doctor()["status"] == "ok"
|
|
53
|
+
contracts = pytest.importorskip("muscles_data.contracts")
|
|
54
|
+
contract = getattr(contracts, "assert_sql_resource_contract", None)
|
|
55
|
+
if contract is not None:
|
|
56
|
+
contract(lambda: sql)
|
|
57
|
+
finally:
|
|
58
|
+
runtime.close()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_sqlalchemy_postgresql_configuration_when_driver_is_available():
|
|
62
|
+
pytest.importorskip("psycopg")
|
|
63
|
+
url = os.getenv("SQLALCHEMY_POSTGRES_URL")
|
|
64
|
+
if not url:
|
|
65
|
+
pytest.skip("PostgreSQL integration URL is not configured")
|
|
66
|
+
from sqlalchemy import text
|
|
67
|
+
|
|
68
|
+
runtime = _runtime(url)
|
|
69
|
+
try:
|
|
70
|
+
sql = runtime.require_port("sql.integration", SqlResourcePort)
|
|
71
|
+
with sql.session() as session:
|
|
72
|
+
assert session.execute(text("select 1")).scalar_one() == 1
|
|
73
|
+
assert sql.doctor()["status"] == "ok"
|
|
74
|
+
finally:
|
|
75
|
+
runtime.close()
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from muscles_data.catalog import DataAdapterCatalog
|
|
5
|
+
from muscles_data.config import DataConfig
|
|
6
|
+
from muscles_data.models import DataCapability
|
|
7
|
+
from muscles_data.ports import SqlResourcePort
|
|
8
|
+
from muscles_data.runtime import DataRuntime
|
|
9
|
+
|
|
10
|
+
from muscles_data_sqlalchemy import (
|
|
11
|
+
SqlAlchemyClientMissingError,
|
|
12
|
+
SqlAlchemyConnectionError,
|
|
13
|
+
SqlAlchemySqlResourceFactory,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _config(url: str = "sqlite:///:memory:") -> dict:
|
|
18
|
+
return {
|
|
19
|
+
"data": {
|
|
20
|
+
"resources": {
|
|
21
|
+
"sql.local": {
|
|
22
|
+
"type": "sqlalchemy",
|
|
23
|
+
"url": url,
|
|
24
|
+
"name": "local_sqlite",
|
|
25
|
+
"native_client": True,
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _runtime(factory: SqlAlchemySqlResourceFactory | None = None, url: str = "sqlite:///:memory:") -> DataRuntime:
|
|
33
|
+
catalog = DataAdapterCatalog.with_defaults()
|
|
34
|
+
catalog.register(factory or SqlAlchemySqlResourceFactory())
|
|
35
|
+
return DataRuntime(config=DataConfig.from_raw(_config(url)), catalog=catalog)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_sqlalchemy_resource_port_is_external_lazy_and_exposes_sessions():
|
|
39
|
+
sqlalchemy = pytest.importorskip("sqlalchemy")
|
|
40
|
+
runtime = _runtime()
|
|
41
|
+
|
|
42
|
+
listed = runtime.list_resources()[0]
|
|
43
|
+
inspected_before = runtime.inspect_resource("sql.local")
|
|
44
|
+
|
|
45
|
+
assert listed["type"] == "sqlalchemy"
|
|
46
|
+
assert "sql_session" in listed["capabilities"]
|
|
47
|
+
assert "native_client" not in listed["capabilities"]
|
|
48
|
+
assert listed["initialized"] is False
|
|
49
|
+
assert inspected_before["initialized"] is False
|
|
50
|
+
assert inspected_before["options"]["url"] == "***"
|
|
51
|
+
|
|
52
|
+
sql = runtime.require_port("sql.local", SqlResourcePort)
|
|
53
|
+
with sql.session() as session:
|
|
54
|
+
session.execute(sqlalchemy.text("create table notes (id integer primary key, title varchar)"))
|
|
55
|
+
session.execute(sqlalchemy.text("insert into notes (title) values ('typed port')"))
|
|
56
|
+
rows = session.execute(sqlalchemy.text("select title from notes")).fetchall()
|
|
57
|
+
session.commit()
|
|
58
|
+
|
|
59
|
+
native = runtime.require_resource("sql.local", DataCapability.NATIVE_CLIENT).native_client()
|
|
60
|
+
assert [row[0] for row in rows] == ["typed port"]
|
|
61
|
+
assert {"engine", "session_factory"} <= set(native)
|
|
62
|
+
assert sql.inspect()["details"]["backend"] == "sqlalchemy"
|
|
63
|
+
assert sql.doctor()["status"] == "ok"
|
|
64
|
+
assert runtime.close()["status"] == "ok"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_sqlalchemy_safe_failures_and_missing_client():
|
|
68
|
+
runtime = _runtime(url="missingdialect://user:secret@localhost/app")
|
|
69
|
+
|
|
70
|
+
assert runtime.inspect_resource("sql.local")["options"]["url"] == "***"
|
|
71
|
+
doctor = runtime.doctor()
|
|
72
|
+
assert doctor["status"] == "failed"
|
|
73
|
+
assert "secret" not in repr(doctor)
|
|
74
|
+
|
|
75
|
+
with pytest.raises(SqlAlchemyConnectionError):
|
|
76
|
+
runtime.require_port("sql.local", SqlResourcePort).session()
|
|
77
|
+
|
|
78
|
+
missing = _runtime(SqlAlchemySqlResourceFactory(sqlalchemy_provider=lambda: None))
|
|
79
|
+
with pytest.raises(SqlAlchemyClientMissingError):
|
|
80
|
+
missing.require_port("sql.local", SqlResourcePort).session()
|