muscles-data-sqlalchemy 0.1.0__py3-none-any.whl

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,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,6 @@
1
+ muscles_data_sqlalchemy/__init__.py,sha256=5_u2yroYfLJ-AnduDarOAvn0-RoCVLU88H4pZ56nUoI,465
2
+ muscles_data_sqlalchemy/adapter.py,sha256=yY_K8uRlXF-XPeIs7MlzHc-h46UJB6l2MakBQPJv2aQ,8127
3
+ muscles_data_sqlalchemy-0.1.0.dist-info/METADATA,sha256=LZijzuvRXFUzVkgwyMgIl9waH_d-IgFxNzzakTsG_Jo,2817
4
+ muscles_data_sqlalchemy-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
5
+ muscles_data_sqlalchemy-0.1.0.dist-info/top_level.txt,sha256=bXh2_zYVyyKeu-0HjWavLwYf-n2G5Ti7fELlfJl0sIU,24
6
+ muscles_data_sqlalchemy-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ muscles_data_sqlalchemy