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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,5 @@
1
+ muscles-data<1.0.0,>=0.1.0
2
+ SQLAlchemy<3.0,>=2.0
3
+
4
+ [dev]
5
+ pytest<9.0,>=8.0
@@ -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()