muscles-data-mongodb 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,133 @@
1
+ Metadata-Version: 2.4
2
+ Name: muscles-data-mongodb
3
+ Version: 0.1.0
4
+ Summary: MongoDB DocumentStorePort 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: pymongo<5.0,>=4.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest<9.0,>=8.0; extra == "dev"
12
+
13
+ # muscles-data-mongodb
14
+
15
+ MongoDB adapter package for `muscles-data`.
16
+
17
+ This package is intentionally separate from `muscles-data`: the core package
18
+ owns typed ports and runtime, while this package owns the PyMongo-backed
19
+ `DocumentStorePort` adapter.
20
+
21
+ ## Related packages
22
+
23
+ - Core runtime and port contracts:
24
+ [`muscles-data`](https://github.com/butkoden/muscles-data)
25
+ - Elasticsearch search adapter:
26
+ [`muscles-data-elasticsearch`](https://github.com/butkoden/muscles-data-elasticsearch)
27
+ - OpenSearch search adapter:
28
+ [`muscles-data-opensearch`](https://github.com/butkoden/muscles-data-opensearch)
29
+ - Redis key-value/lock/stream adapter:
30
+ [`muscles-data-redis`](https://github.com/butkoden/muscles-data-redis)
31
+ - Qdrant vector adapter:
32
+ [`muscles-data-qdrant`](https://github.com/butkoden/muscles-data-qdrant)
33
+ - S3 object-store adapter:
34
+ [`muscles-data-s3`](https://github.com/butkoden/muscles-data-s3)
35
+ - SQLAlchemy direct SQL resource adapter:
36
+ [`muscles-data-sqlalchemy`](https://github.com/butkoden/muscles-data-sqlalchemy)
37
+ - Executable example:
38
+ [`example_data_mongodb_1`](https://github.com/butkoden/muscular-example/tree/master/example_data_mongodb_1)
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ python -m pip install muscles-data-mongodb
44
+ ```
45
+
46
+ For local framework development:
47
+
48
+ ```bash
49
+ PYTHONPATH=../muscles-data/src:src python3 -m pytest -q
50
+ ```
51
+
52
+ ## Configuration
53
+
54
+ ```yaml
55
+ data:
56
+ resources:
57
+ mongo.content:
58
+ type: mongodb
59
+ url: ${MONGO_URL}
60
+ database: content
61
+ timeout: 3
62
+ max_limit: 100
63
+ ```
64
+
65
+ ## Usage
66
+
67
+ Register the external factory in the project composition root:
68
+
69
+ ```python
70
+ from muscles_data.catalog import DataAdapterCatalog
71
+ from muscles_data.config import DataConfig
72
+ from muscles_data.ports import DocumentStorePort
73
+ from muscles_data.runtime import DataRuntime
74
+ from muscles_data_mongodb import MongoDocumentStoreFactory
75
+
76
+ catalog = DataAdapterCatalog.with_defaults()
77
+ catalog.register(MongoDocumentStoreFactory())
78
+
79
+ runtime = DataRuntime(config=DataConfig.from_raw(config), catalog=catalog)
80
+ store = runtime.require_port("mongo.content", DocumentStorePort)
81
+ ```
82
+
83
+ Then use the narrow port:
84
+
85
+ ```python
86
+ store.upsert_document("profiles", "denis", {"name": "Denis"})
87
+ document = store.get_document("profiles", "denis")
88
+ documents = store.find_documents("profiles", filters={"role": "developer"}, limit=20)
89
+ store.delete_document("profiles", "denis")
90
+ ```
91
+
92
+ The adapter maps `document_id` to MongoDB `_id` internally and returns public
93
+ documents without `_id` to keep the port backend-neutral.
94
+
95
+ ## Capabilities
96
+
97
+ `MongoDocumentStoreFactory` provides:
98
+
99
+ - `document_store`;
100
+ - `healthcheck`;
101
+ - `native_client` only when the resource declares `native_client: true`.
102
+
103
+ Native access is an advanced project escape hatch:
104
+
105
+ ```python
106
+ from muscles_data import DataCapability
107
+
108
+ client = runtime.require_resource("mongo.content", DataCapability.NATIVE_CLIENT).native_client()
109
+ ```
110
+
111
+ Use the native client for backend-specific aggregations, indexes or operations
112
+ that do not belong in `DocumentStorePort`.
113
+
114
+ ## Boundaries
115
+
116
+ This package owns:
117
+
118
+ - lazy PyMongo client creation;
119
+ - database binding;
120
+ - simple document get/find/upsert/delete;
121
+ - safe `inspect()` and `doctor()`;
122
+ - bounded `find_documents()` limits.
123
+
124
+ It does not own:
125
+
126
+ - ORM/document model layers;
127
+ - schema validation;
128
+ - migrations;
129
+ - complex aggregation abstraction;
130
+ - business repository APIs.
131
+
132
+ `data.doctor` performs a MongoDB `ping`. `data.resources.list` and package
133
+ initialization do not open a MongoDB connection.
@@ -0,0 +1,121 @@
1
+ # muscles-data-mongodb
2
+
3
+ MongoDB adapter package for `muscles-data`.
4
+
5
+ This package is intentionally separate from `muscles-data`: the core package
6
+ owns typed ports and runtime, while this package owns the PyMongo-backed
7
+ `DocumentStorePort` adapter.
8
+
9
+ ## Related packages
10
+
11
+ - Core runtime and port contracts:
12
+ [`muscles-data`](https://github.com/butkoden/muscles-data)
13
+ - Elasticsearch search adapter:
14
+ [`muscles-data-elasticsearch`](https://github.com/butkoden/muscles-data-elasticsearch)
15
+ - OpenSearch search adapter:
16
+ [`muscles-data-opensearch`](https://github.com/butkoden/muscles-data-opensearch)
17
+ - Redis key-value/lock/stream adapter:
18
+ [`muscles-data-redis`](https://github.com/butkoden/muscles-data-redis)
19
+ - Qdrant vector adapter:
20
+ [`muscles-data-qdrant`](https://github.com/butkoden/muscles-data-qdrant)
21
+ - S3 object-store adapter:
22
+ [`muscles-data-s3`](https://github.com/butkoden/muscles-data-s3)
23
+ - SQLAlchemy direct SQL resource adapter:
24
+ [`muscles-data-sqlalchemy`](https://github.com/butkoden/muscles-data-sqlalchemy)
25
+ - Executable example:
26
+ [`example_data_mongodb_1`](https://github.com/butkoden/muscular-example/tree/master/example_data_mongodb_1)
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ python -m pip install muscles-data-mongodb
32
+ ```
33
+
34
+ For local framework development:
35
+
36
+ ```bash
37
+ PYTHONPATH=../muscles-data/src:src python3 -m pytest -q
38
+ ```
39
+
40
+ ## Configuration
41
+
42
+ ```yaml
43
+ data:
44
+ resources:
45
+ mongo.content:
46
+ type: mongodb
47
+ url: ${MONGO_URL}
48
+ database: content
49
+ timeout: 3
50
+ max_limit: 100
51
+ ```
52
+
53
+ ## Usage
54
+
55
+ Register the external factory in the project composition root:
56
+
57
+ ```python
58
+ from muscles_data.catalog import DataAdapterCatalog
59
+ from muscles_data.config import DataConfig
60
+ from muscles_data.ports import DocumentStorePort
61
+ from muscles_data.runtime import DataRuntime
62
+ from muscles_data_mongodb import MongoDocumentStoreFactory
63
+
64
+ catalog = DataAdapterCatalog.with_defaults()
65
+ catalog.register(MongoDocumentStoreFactory())
66
+
67
+ runtime = DataRuntime(config=DataConfig.from_raw(config), catalog=catalog)
68
+ store = runtime.require_port("mongo.content", DocumentStorePort)
69
+ ```
70
+
71
+ Then use the narrow port:
72
+
73
+ ```python
74
+ store.upsert_document("profiles", "denis", {"name": "Denis"})
75
+ document = store.get_document("profiles", "denis")
76
+ documents = store.find_documents("profiles", filters={"role": "developer"}, limit=20)
77
+ store.delete_document("profiles", "denis")
78
+ ```
79
+
80
+ The adapter maps `document_id` to MongoDB `_id` internally and returns public
81
+ documents without `_id` to keep the port backend-neutral.
82
+
83
+ ## Capabilities
84
+
85
+ `MongoDocumentStoreFactory` provides:
86
+
87
+ - `document_store`;
88
+ - `healthcheck`;
89
+ - `native_client` only when the resource declares `native_client: true`.
90
+
91
+ Native access is an advanced project escape hatch:
92
+
93
+ ```python
94
+ from muscles_data import DataCapability
95
+
96
+ client = runtime.require_resource("mongo.content", DataCapability.NATIVE_CLIENT).native_client()
97
+ ```
98
+
99
+ Use the native client for backend-specific aggregations, indexes or operations
100
+ that do not belong in `DocumentStorePort`.
101
+
102
+ ## Boundaries
103
+
104
+ This package owns:
105
+
106
+ - lazy PyMongo client creation;
107
+ - database binding;
108
+ - simple document get/find/upsert/delete;
109
+ - safe `inspect()` and `doctor()`;
110
+ - bounded `find_documents()` limits.
111
+
112
+ It does not own:
113
+
114
+ - ORM/document model layers;
115
+ - schema validation;
116
+ - migrations;
117
+ - complex aggregation abstraction;
118
+ - business repository APIs.
119
+
120
+ `data.doctor` performs a MongoDB `ping`. `data.resources.list` and package
121
+ initialization do not open a MongoDB connection.
@@ -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-mongodb"
7
+ version = "0.1.0"
8
+ description = "MongoDB DocumentStorePort 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
+ "pymongo>=4.0,<5.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
+ MongoDBAdapterError,
5
+ MongoDBClientMissingError,
6
+ MongoDBConfigError,
7
+ MongoDBConnectionError,
8
+ MongoDocumentStoreAdapter,
9
+ MongoDocumentStoreFactory,
10
+ )
11
+
12
+
13
+ __all__ = [
14
+ "MongoDBAdapterError",
15
+ "MongoDBClientMissingError",
16
+ "MongoDBConfigError",
17
+ "MongoDBConnectionError",
18
+ "MongoDocumentStoreAdapter",
19
+ "MongoDocumentStoreFactory",
20
+ ]
@@ -0,0 +1,287 @@
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
+ from urllib.parse import urlsplit
9
+
10
+ from muscles_data.config import DataResourceConfig
11
+ from muscles_data.errors import DataError
12
+ from muscles_data.models import DataCapability, HealthResult, InspectResult, WriteResult
13
+
14
+
15
+ _CLIENT_UNSET = object()
16
+ _ALLOWED_OPTIONS = {
17
+ "url",
18
+ "url_env",
19
+ "database",
20
+ "username",
21
+ "password",
22
+ "auth_source",
23
+ "timeout",
24
+ "timeout_ms",
25
+ "server_selection_timeout_ms",
26
+ "max_limit",
27
+ "native_client",
28
+ "tls",
29
+ }
30
+
31
+
32
+ class MongoDBAdapterError(DataError):
33
+ """Base error for MongoDB document-store adapter failures."""
34
+
35
+
36
+ class MongoDBConfigError(ValueError, MongoDBAdapterError):
37
+ """Raised when a MongoDB resource config cannot be mapped safely."""
38
+
39
+
40
+ class MongoDBClientMissingError(MongoDBAdapterError):
41
+ """Raised when MongoDB adapter is used without an available client."""
42
+
43
+
44
+ class MongoDBConnectionError(MongoDBAdapterError):
45
+ """Raised when a MongoDB operation cannot reach or use the backend."""
46
+
47
+
48
+ class MongoDocumentStoreAdapter:
49
+ resource_type = "mongodb"
50
+
51
+ def __init__(
52
+ self,
53
+ config: DataResourceConfig,
54
+ *,
55
+ client_factory: Callable[[DataResourceConfig], Any] | None = None,
56
+ ) -> None:
57
+ self.config = config
58
+ self._client_factory = client_factory
59
+ self._client: Any = _CLIENT_UNSET
60
+ self._lock = threading.RLock()
61
+ self.closed = False
62
+
63
+ def get_document(self, collection: str, document_id: str) -> Mapping[str, Any] | None:
64
+ try:
65
+ raw = self._collection(collection).find_one({"_id": str(document_id)})
66
+ except (MongoDBClientMissingError, MongoDBConfigError):
67
+ raise
68
+ except Exception as exc:
69
+ raise MongoDBConnectionError(self._safe_error(exc)) from exc
70
+ return _public_document(raw)
71
+
72
+ def upsert_document(self, collection: str, document_id: str, document: Mapping[str, Any]) -> WriteResult:
73
+ stored = _storage_document(document, document_id)
74
+ try:
75
+ result = self._collection(collection).replace_one({"_id": str(document_id)}, stored, upsert=True)
76
+ except (MongoDBClientMissingError, MongoDBConfigError):
77
+ raise
78
+ except Exception as exc:
79
+ raise MongoDBConnectionError(self._safe_error(exc)) from exc
80
+ matched = int(getattr(result, "matched_count", 0) or 0)
81
+ return WriteResult(written=1, matched=1 if matched else 0)
82
+
83
+ def find_documents(
84
+ self,
85
+ collection: str,
86
+ filters: Mapping[str, Any] | None = None,
87
+ limit: int = 100,
88
+ options: Mapping[str, Any] | None = None,
89
+ ) -> list[Mapping[str, Any]]:
90
+ options = dict(options or {})
91
+ query = dict(filters or {})
92
+ bounded_limit = self._bounded_limit(limit)
93
+ try:
94
+ cursor = self._collection(collection).find(query)
95
+ if "sort" in options:
96
+ cursor = cursor.sort(options["sort"])
97
+ if "skip" in options:
98
+ cursor = cursor.skip(max(0, int(options["skip"])))
99
+ cursor = cursor.limit(bounded_limit)
100
+ return [document for document in (_public_document(item) for item in cursor) if document is not None]
101
+ except (MongoDBClientMissingError, MongoDBConfigError):
102
+ raise
103
+ except Exception as exc:
104
+ raise MongoDBConnectionError(self._safe_error(exc)) from exc
105
+
106
+ def delete_document(self, collection: str, document_id: str) -> WriteResult:
107
+ try:
108
+ result = self._collection(collection).delete_one({"_id": str(document_id)})
109
+ except (MongoDBClientMissingError, MongoDBConfigError):
110
+ raise
111
+ except Exception as exc:
112
+ raise MongoDBConnectionError(self._safe_error(exc)) from exc
113
+ deleted = int(getattr(result, "deleted_count", 0) or 0)
114
+ return WriteResult(deleted=deleted, matched=deleted)
115
+
116
+ def inspect(self) -> dict[str, Any]:
117
+ return asdict(
118
+ InspectResult(
119
+ name=self.config.name,
120
+ type=self.config.type,
121
+ capabilities=[],
122
+ initialized=self._client is not _CLIENT_UNSET,
123
+ status="ok",
124
+ options=self.config.safe_options(),
125
+ details={"backend": "mongodb", "database": self.database_name(), "max_limit": self.max_limit()},
126
+ )
127
+ )
128
+
129
+ def health(self) -> HealthResult:
130
+ try:
131
+ self._client_instance().admin.command("ping")
132
+ except Exception as exc:
133
+ return HealthResult(status="failed", message=self._safe_error(exc), details={"database": self.database_name()})
134
+ return HealthResult(status="ok", message="MongoDB connection is available", details={"database": self.database_name()})
135
+
136
+ def close(self) -> None:
137
+ if self._client is _CLIENT_UNSET:
138
+ self.closed = True
139
+ return
140
+ close = getattr(self._client, "close", None)
141
+ if callable(close):
142
+ close()
143
+ self.closed = True
144
+
145
+ def native_client(self):
146
+ return self._client_instance()
147
+
148
+ def database_name(self) -> str:
149
+ database = str(self.config.options.get("database", "")).strip()
150
+ if not database:
151
+ raise MongoDBConfigError("MongoDB resource requires database")
152
+ return database
153
+
154
+ def max_limit(self) -> int:
155
+ value = int(self.config.options.get("max_limit", 100))
156
+ if value <= 0:
157
+ raise MongoDBConfigError("MongoDB max_limit must be positive")
158
+ return value
159
+
160
+ def _bounded_limit(self, limit: int) -> int:
161
+ return min(max(0, int(limit)), self.max_limit())
162
+
163
+ def _collection(self, collection: str):
164
+ name = _collection_name(collection)
165
+ return self._client_instance()[self.database_name()][name]
166
+
167
+ def _client_instance(self):
168
+ if self._client is _CLIENT_UNSET:
169
+ with self._lock:
170
+ if self._client is _CLIENT_UNSET:
171
+ self._validate_options()
172
+ client = (
173
+ self._client_factory(self.config)
174
+ if self._client_factory
175
+ else _default_mongo_client(self.config)
176
+ )
177
+ if client is None:
178
+ raise MongoDBClientMissingError("MongoDB client is not available")
179
+ self._client = client
180
+ return self._client
181
+
182
+ def _validate_options(self) -> None:
183
+ unknown = sorted(set(self.config.options) - _ALLOWED_OPTIONS)
184
+ if unknown:
185
+ names = ", ".join(unknown)
186
+ raise MongoDBConfigError(f"Unsupported MongoDB resource options: {names}")
187
+ if not self.config.options.get("url") and not self.config.options.get("url_env"):
188
+ raise MongoDBConfigError("MongoDB resource requires url or url_env")
189
+ self.database_name()
190
+ self.max_limit()
191
+
192
+ def _safe_error(self, exc: Exception) -> str:
193
+ message = str(exc)
194
+ try:
195
+ options = self.config.resolved_options()
196
+ except Exception:
197
+ options = self.config.options
198
+ sensitive_values = {
199
+ str(options.get("url", "")),
200
+ str(options.get("username", "")),
201
+ str(options.get("password", "")),
202
+ }
203
+ try:
204
+ parsed = urlsplit(str(options.get("url", "")))
205
+ if parsed.username:
206
+ sensitive_values.add(parsed.username)
207
+ if parsed.password:
208
+ sensitive_values.add(parsed.password)
209
+ except Exception: # pragma: no cover
210
+ pass
211
+ for value in sorted((item for item in sensitive_values if item), key=len, reverse=True):
212
+ message = message.replace(value, "***")
213
+ return message
214
+
215
+
216
+ class MongoDocumentStoreFactory:
217
+ resource_type = "mongodb"
218
+
219
+ def __init__(self, *, client_factory: Callable[[DataResourceConfig], Any] | None = None) -> None:
220
+ self._client_factory = client_factory
221
+
222
+ def capabilities(self, config: DataResourceConfig) -> set[DataCapability]:
223
+ native = {DataCapability.NATIVE_CLIENT} if bool(config.options.get("native_client")) else set()
224
+ return {DataCapability.DOCUMENT_STORE, DataCapability.HEALTHCHECK} | native
225
+
226
+ def create(self, config: DataResourceConfig) -> MongoDocumentStoreAdapter:
227
+ return MongoDocumentStoreAdapter(config, client_factory=self._client_factory)
228
+
229
+
230
+ def _default_mongo_client(config: DataResourceConfig):
231
+ try:
232
+ pymongo = importlib.import_module("pymongo")
233
+ except ImportError as exc:
234
+ raise MongoDBClientMissingError("pymongo package is not installed") from exc
235
+ client_cls = getattr(pymongo, "MongoClient", None)
236
+ if client_cls is None:
237
+ raise MongoDBClientMissingError("pymongo does not expose MongoClient")
238
+
239
+ try:
240
+ options = config.resolved_options()
241
+ except Exception as exc:
242
+ raise MongoDBConfigError(str(exc)) from exc
243
+
244
+ kwargs: dict[str, Any] = {}
245
+ if "timeout" in options:
246
+ timeout_ms = int(float(options["timeout"]) * 1000)
247
+ kwargs["timeoutMS"] = timeout_ms
248
+ kwargs["serverSelectionTimeoutMS"] = timeout_ms
249
+ if "timeout_ms" in options:
250
+ kwargs["timeoutMS"] = int(options["timeout_ms"])
251
+ if "server_selection_timeout_ms" in options:
252
+ kwargs["serverSelectionTimeoutMS"] = int(options["server_selection_timeout_ms"])
253
+ if "username" in options:
254
+ kwargs["username"] = options["username"]
255
+ if "password" in options:
256
+ kwargs["password"] = options["password"]
257
+ if "auth_source" in options:
258
+ kwargs["authSource"] = options["auth_source"]
259
+ if "tls" in options:
260
+ kwargs["tls"] = bool(options["tls"])
261
+ return client_cls(str(options["url"]), **kwargs)
262
+
263
+
264
+ def _collection_name(value: str) -> str:
265
+ name = str(value).strip()
266
+ if not name:
267
+ raise MongoDBConfigError("MongoDB collection name must not be empty")
268
+ if "\x00" in name:
269
+ raise MongoDBConfigError("MongoDB collection name must not contain null bytes")
270
+ return name
271
+
272
+
273
+ def _storage_document(document: Mapping[str, Any], document_id: str) -> dict[str, Any]:
274
+ stored = dict(document)
275
+ existing_id = stored.get("_id")
276
+ if existing_id is not None and str(existing_id) != str(document_id):
277
+ raise MongoDBConfigError("MongoDB document _id must match document_id")
278
+ stored["_id"] = str(document_id)
279
+ return stored
280
+
281
+
282
+ def _public_document(raw: Mapping[str, Any] | None) -> dict[str, Any] | None:
283
+ if raw is None:
284
+ return None
285
+ document = dict(raw)
286
+ document.pop("_id", None)
287
+ return document
@@ -0,0 +1,133 @@
1
+ Metadata-Version: 2.4
2
+ Name: muscles-data-mongodb
3
+ Version: 0.1.0
4
+ Summary: MongoDB DocumentStorePort 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: pymongo<5.0,>=4.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest<9.0,>=8.0; extra == "dev"
12
+
13
+ # muscles-data-mongodb
14
+
15
+ MongoDB adapter package for `muscles-data`.
16
+
17
+ This package is intentionally separate from `muscles-data`: the core package
18
+ owns typed ports and runtime, while this package owns the PyMongo-backed
19
+ `DocumentStorePort` adapter.
20
+
21
+ ## Related packages
22
+
23
+ - Core runtime and port contracts:
24
+ [`muscles-data`](https://github.com/butkoden/muscles-data)
25
+ - Elasticsearch search adapter:
26
+ [`muscles-data-elasticsearch`](https://github.com/butkoden/muscles-data-elasticsearch)
27
+ - OpenSearch search adapter:
28
+ [`muscles-data-opensearch`](https://github.com/butkoden/muscles-data-opensearch)
29
+ - Redis key-value/lock/stream adapter:
30
+ [`muscles-data-redis`](https://github.com/butkoden/muscles-data-redis)
31
+ - Qdrant vector adapter:
32
+ [`muscles-data-qdrant`](https://github.com/butkoden/muscles-data-qdrant)
33
+ - S3 object-store adapter:
34
+ [`muscles-data-s3`](https://github.com/butkoden/muscles-data-s3)
35
+ - SQLAlchemy direct SQL resource adapter:
36
+ [`muscles-data-sqlalchemy`](https://github.com/butkoden/muscles-data-sqlalchemy)
37
+ - Executable example:
38
+ [`example_data_mongodb_1`](https://github.com/butkoden/muscular-example/tree/master/example_data_mongodb_1)
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ python -m pip install muscles-data-mongodb
44
+ ```
45
+
46
+ For local framework development:
47
+
48
+ ```bash
49
+ PYTHONPATH=../muscles-data/src:src python3 -m pytest -q
50
+ ```
51
+
52
+ ## Configuration
53
+
54
+ ```yaml
55
+ data:
56
+ resources:
57
+ mongo.content:
58
+ type: mongodb
59
+ url: ${MONGO_URL}
60
+ database: content
61
+ timeout: 3
62
+ max_limit: 100
63
+ ```
64
+
65
+ ## Usage
66
+
67
+ Register the external factory in the project composition root:
68
+
69
+ ```python
70
+ from muscles_data.catalog import DataAdapterCatalog
71
+ from muscles_data.config import DataConfig
72
+ from muscles_data.ports import DocumentStorePort
73
+ from muscles_data.runtime import DataRuntime
74
+ from muscles_data_mongodb import MongoDocumentStoreFactory
75
+
76
+ catalog = DataAdapterCatalog.with_defaults()
77
+ catalog.register(MongoDocumentStoreFactory())
78
+
79
+ runtime = DataRuntime(config=DataConfig.from_raw(config), catalog=catalog)
80
+ store = runtime.require_port("mongo.content", DocumentStorePort)
81
+ ```
82
+
83
+ Then use the narrow port:
84
+
85
+ ```python
86
+ store.upsert_document("profiles", "denis", {"name": "Denis"})
87
+ document = store.get_document("profiles", "denis")
88
+ documents = store.find_documents("profiles", filters={"role": "developer"}, limit=20)
89
+ store.delete_document("profiles", "denis")
90
+ ```
91
+
92
+ The adapter maps `document_id` to MongoDB `_id` internally and returns public
93
+ documents without `_id` to keep the port backend-neutral.
94
+
95
+ ## Capabilities
96
+
97
+ `MongoDocumentStoreFactory` provides:
98
+
99
+ - `document_store`;
100
+ - `healthcheck`;
101
+ - `native_client` only when the resource declares `native_client: true`.
102
+
103
+ Native access is an advanced project escape hatch:
104
+
105
+ ```python
106
+ from muscles_data import DataCapability
107
+
108
+ client = runtime.require_resource("mongo.content", DataCapability.NATIVE_CLIENT).native_client()
109
+ ```
110
+
111
+ Use the native client for backend-specific aggregations, indexes or operations
112
+ that do not belong in `DocumentStorePort`.
113
+
114
+ ## Boundaries
115
+
116
+ This package owns:
117
+
118
+ - lazy PyMongo client creation;
119
+ - database binding;
120
+ - simple document get/find/upsert/delete;
121
+ - safe `inspect()` and `doctor()`;
122
+ - bounded `find_documents()` limits.
123
+
124
+ It does not own:
125
+
126
+ - ORM/document model layers;
127
+ - schema validation;
128
+ - migrations;
129
+ - complex aggregation abstraction;
130
+ - business repository APIs.
131
+
132
+ `data.doctor` performs a MongoDB `ping`. `data.resources.list` and package
133
+ initialization do not open a MongoDB connection.
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/muscles_data_mongodb/__init__.py
4
+ src/muscles_data_mongodb/adapter.py
5
+ src/muscles_data_mongodb.egg-info/PKG-INFO
6
+ src/muscles_data_mongodb.egg-info/SOURCES.txt
7
+ src/muscles_data_mongodb.egg-info/dependency_links.txt
8
+ src/muscles_data_mongodb.egg-info/requires.txt
9
+ src/muscles_data_mongodb.egg-info/top_level.txt
10
+ tests/test_integration.py
11
+ tests/test_mongodb_adapter.py
@@ -0,0 +1,5 @@
1
+ muscles-data<1.0.0,>=0.1.0
2
+ pymongo<5.0,>=4.0
3
+
4
+ [dev]
5
+ pytest<9.0,>=8.0
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from uuid import uuid4
5
+
6
+ import pytest
7
+ from muscles_data.catalog import DataAdapterCatalog
8
+ from muscles_data.config import DataConfig
9
+ from muscles_data.models import DataCapability
10
+ from muscles_data.ports import DocumentStorePort
11
+ from muscles_data.runtime import DataRuntime
12
+
13
+ from muscles_data_mongodb import MongoDocumentStoreFactory
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 test_mongodb_real_document_lifecycle():
23
+ database = f"muscles_data_it_{uuid4().hex[:12]}"
24
+ config = DataConfig.from_raw(
25
+ {
26
+ "data": {
27
+ "resources": {
28
+ "mongo.content": {
29
+ "type": "mongodb",
30
+ "url_env": "MONGODB_URL",
31
+ "database": database,
32
+ "timeout": 3,
33
+ "native_client": True,
34
+ }
35
+ }
36
+ }
37
+ }
38
+ )
39
+ catalog = DataAdapterCatalog.with_defaults()
40
+ catalog.register(MongoDocumentStoreFactory())
41
+ runtime = DataRuntime(config=config, catalog=catalog)
42
+
43
+ client = None
44
+ try:
45
+ store = runtime.require_port("mongo.content", DocumentStorePort)
46
+ contracts = pytest.importorskip("muscles_data.contracts")
47
+ contract = getattr(contracts, "assert_document_store_contract", None)
48
+ if contract is not None:
49
+ contract(lambda: store)
50
+ assert runtime.doctor()["status"] == "ok"
51
+ finally:
52
+ try:
53
+ if client is None:
54
+ try:
55
+ client = runtime.require_resource("mongo.content", DataCapability.NATIVE_CLIENT).native_client()
56
+ except Exception:
57
+ client = None
58
+ if client is not None:
59
+ client.drop_database(database)
60
+ finally:
61
+ runtime.close()
@@ -0,0 +1,255 @@
1
+ from __future__ import annotations
2
+
3
+ from types import SimpleNamespace
4
+ from typing import Any
5
+
6
+ import pytest
7
+ from muscles_data.catalog import DataAdapterCatalog
8
+ from muscles_data.config import DataConfig
9
+ from muscles_data.models import DataCapability
10
+ from muscles_data.ports import DocumentStorePort
11
+ from muscles_data.runtime import DataRuntime
12
+
13
+ from muscles_data_mongodb import (
14
+ MongoDBClientMissingError,
15
+ MongoDBConfigError,
16
+ MongoDBConnectionError,
17
+ MongoDocumentStoreFactory,
18
+ )
19
+
20
+
21
+ class FakeMongoAdmin:
22
+ def __init__(self, client: "FakeMongoClient") -> None:
23
+ self.client = client
24
+
25
+ def command(self, name: str):
26
+ self.client.admin_commands.append(name)
27
+ if self.client.fail_ping:
28
+ raise TimeoutError("mongodb password=mongo-secret timed out")
29
+ return {"ok": 1}
30
+
31
+
32
+ class FakeMongoCursor:
33
+ def __init__(self, docs: list[dict[str, Any]]) -> None:
34
+ self.docs = docs
35
+ self.limits: list[int] = []
36
+
37
+ def limit(self, value: int):
38
+ self.limits.append(value)
39
+ return self
40
+
41
+ def __iter__(self):
42
+ limit = self.limits[-1] if self.limits else len(self.docs)
43
+ return iter(self.docs[:limit])
44
+
45
+
46
+ class FakeMongoCollection:
47
+ def __init__(self, name: str) -> None:
48
+ self.name = name
49
+ self.docs: dict[str, dict[str, Any]] = {}
50
+ self.find_one_calls: list[dict[str, Any]] = []
51
+ self.replace_one_calls: list[dict[str, Any]] = []
52
+ self.find_calls: list[dict[str, Any]] = []
53
+ self.delete_one_calls: list[dict[str, Any]] = []
54
+ self.last_cursor: FakeMongoCursor | None = None
55
+
56
+ def find_one(self, filter):
57
+ self.find_one_calls.append(dict(filter))
58
+ document_id = str(filter.get("_id"))
59
+ doc = self.docs.get(document_id)
60
+ return dict(doc) if doc is not None else None
61
+
62
+ def replace_one(self, filter, replacement, upsert: bool = False):
63
+ self.replace_one_calls.append({"filter": dict(filter), "replacement": dict(replacement), "upsert": upsert})
64
+ document_id = str(filter["_id"])
65
+ matched = 1 if document_id in self.docs else 0
66
+ self.docs[document_id] = dict(replacement)
67
+ return SimpleNamespace(matched_count=matched, modified_count=1 if matched else 0, upserted_id=None if matched else document_id)
68
+
69
+ def find(self, filter):
70
+ self.find_calls.append(dict(filter))
71
+ docs = [
72
+ dict(doc)
73
+ for doc in self.docs.values()
74
+ if all(doc.get(key) == value for key, value in dict(filter).items())
75
+ ]
76
+ self.last_cursor = FakeMongoCursor(docs)
77
+ return self.last_cursor
78
+
79
+ def delete_one(self, filter):
80
+ self.delete_one_calls.append(dict(filter))
81
+ document_id = str(filter.get("_id"))
82
+ deleted = 1 if self.docs.pop(document_id, None) is not None else 0
83
+ return SimpleNamespace(deleted_count=deleted)
84
+
85
+
86
+ class FakeMongoDatabase:
87
+ def __init__(self, name: str) -> None:
88
+ self.name = name
89
+ self.collections: dict[str, FakeMongoCollection] = {}
90
+
91
+ def __getitem__(self, name: str) -> FakeMongoCollection:
92
+ return self.collections.setdefault(name, FakeMongoCollection(name))
93
+
94
+
95
+ class FakeMongoClient:
96
+ def __init__(self, *, fail_ping: bool = False) -> None:
97
+ self.fail_ping = fail_ping
98
+ self.admin = FakeMongoAdmin(self)
99
+ self.databases: dict[str, FakeMongoDatabase] = {}
100
+ self.admin_commands: list[str] = []
101
+ self.closed = False
102
+
103
+ def __getitem__(self, name: str) -> FakeMongoDatabase:
104
+ return self.databases.setdefault(name, FakeMongoDatabase(name))
105
+
106
+ def close(self) -> None:
107
+ self.closed = True
108
+
109
+
110
+ def _mongodb_config(url: str = "mongodb://user:mongo-secret@localhost:27017") -> dict[str, Any]:
111
+ return {
112
+ "data": {
113
+ "resources": {
114
+ "mongo.content": {
115
+ "type": "mongodb",
116
+ "url": url,
117
+ "database": "content",
118
+ "timeout": 1.5,
119
+ "max_limit": 2,
120
+ "native_client": True,
121
+ }
122
+ }
123
+ }
124
+ }
125
+
126
+
127
+ def _runtime(client: FakeMongoClient | None):
128
+ catalog = DataAdapterCatalog.with_defaults()
129
+ catalog.register(MongoDocumentStoreFactory(client_factory=lambda _config: client))
130
+ return DataRuntime(
131
+ config=DataConfig.from_raw(_mongodb_config()),
132
+ catalog=catalog,
133
+ )
134
+
135
+
136
+ def test_mongodb_document_store_is_registered_lazy_and_maps_operations():
137
+ client = FakeMongoClient()
138
+ runtime = _runtime(client)
139
+
140
+ listed = runtime.list_resources()
141
+ mongo = next(item for item in listed if item["name"] == "mongo.content")
142
+ inspected_before = runtime.inspect_resource("mongo.content")
143
+
144
+ assert mongo["type"] == "mongodb"
145
+ assert "document_store" in mongo["capabilities"]
146
+ assert "native_client" not in mongo["capabilities"]
147
+ assert mongo["initialized"] is False
148
+ assert inspected_before["initialized"] is False
149
+ assert inspected_before["options"]["url"] == "***"
150
+
151
+ store = runtime.require_port("mongo.content", DocumentStorePort)
152
+ write = store.upsert_document("profiles", "denis", {"name": "Denis", "role": "developer"})
153
+ found = store.get_document("profiles", "denis")
154
+ store.upsert_document("profiles", "reader", {"name": "Reader", "role": "developer"})
155
+ store.upsert_document("profiles", "other", {"name": "Other", "role": "developer"})
156
+ docs = store.find_documents("profiles", filters={"role": "developer"}, limit=10)
157
+ deleted = store.delete_document("profiles", "reader")
158
+
159
+ collection = client["content"]["profiles"]
160
+ assert write.written == 1
161
+ assert collection.replace_one_calls[0] == {
162
+ "filter": {"_id": "denis"},
163
+ "replacement": {"name": "Denis", "role": "developer", "_id": "denis"},
164
+ "upsert": True,
165
+ }
166
+ assert found == {"name": "Denis", "role": "developer"}
167
+ assert collection.find_one_calls[0] == {"_id": "denis"}
168
+ assert [doc["name"] for doc in docs] == ["Denis", "Reader"]
169
+ assert collection.last_cursor is not None
170
+ assert collection.last_cursor.limits == [2]
171
+ assert deleted.deleted == 1
172
+ assert collection.delete_one_calls[-1] == {"_id": "reader"}
173
+
174
+ native = runtime.require_resource("mongo.content", DataCapability.NATIVE_CLIENT).native_client()
175
+ assert native is client
176
+ assert client.admin_commands == []
177
+
178
+
179
+ def test_mongodb_inspect_doctor_close_and_safe_failures():
180
+ client = FakeMongoClient()
181
+ runtime = _runtime(client)
182
+
183
+ doctor = runtime.doctor()
184
+ mongo_checks = [check for check in doctor["checks"] if check["resource"] == "mongo.content"]
185
+
186
+ assert mongo_checks[0]["status"] == "ok"
187
+ assert client.admin_commands == ["ping"]
188
+ assert "mongo-secret" not in repr(doctor)
189
+ assert runtime.close()["status"] == "ok"
190
+ assert client.closed is True
191
+
192
+ missing_client_runtime = _runtime(None)
193
+ with pytest.raises(MongoDBClientMissingError):
194
+ missing_client_runtime.require_port("mongo.content", DocumentStorePort).get_document("profiles", "denis")
195
+
196
+ failing_runtime = DataRuntime(
197
+ config=DataConfig.from_raw(_mongodb_config("mongodb://user:mongo-secret@localhost:27017")),
198
+ catalog=_catalog(MongoDocumentStoreFactory(client_factory=lambda _config: FakeMongoClient(fail_ping=True))),
199
+ )
200
+ failing_doctor = failing_runtime.doctor()
201
+ assert failing_doctor["status"] == "failed"
202
+ assert [check for check in failing_doctor["checks"] if check["resource"] == "mongo.content"][0]["status"] == "failed"
203
+ assert "mongo-secret" not in repr(failing_doctor)
204
+
205
+ bad_client = FakeMongoClient()
206
+ bad_client["content"]["profiles"].find_one = lambda _filter: (_ for _ in ()).throw(RuntimeError("mongodb://user:mongo-secret@localhost unavailable"))
207
+ bad_runtime = DataRuntime(
208
+ config=DataConfig.from_raw(_mongodb_config()),
209
+ catalog=_catalog(MongoDocumentStoreFactory(client_factory=lambda _config: bad_client)),
210
+ )
211
+ with pytest.raises(MongoDBConnectionError):
212
+ bad_runtime.require_port("mongo.content", DocumentStorePort).get_document("profiles", "denis")
213
+
214
+ unsupported_runtime = DataRuntime(
215
+ config=DataConfig.from_raw(
216
+ {
217
+ "data": {
218
+ "resources": {
219
+ "mongo.content": {
220
+ "type": "mongodb",
221
+ "url": "mongodb://localhost",
222
+ "database": "content",
223
+ "unsafe": True,
224
+ }
225
+ }
226
+ }
227
+ }
228
+ ),
229
+ catalog=_catalog(MongoDocumentStoreFactory(client_factory=lambda _config: FakeMongoClient())),
230
+ )
231
+ with pytest.raises(MongoDBConfigError, match="Unsupported MongoDB resource options"):
232
+ unsupported_runtime.require_port("mongo.content", DocumentStorePort).get_document("profiles", "denis")
233
+
234
+
235
+ def test_mongodb_resource_requires_url_and_database():
236
+ assert DataConfig.from_raw(_mongodb_config()).resources["mongo.content"].type == "mongodb"
237
+
238
+ with pytest.raises(MongoDBConfigError, match="requires database"):
239
+ MongoDocumentStoreFactory(client_factory=lambda _config: FakeMongoClient()).create(
240
+ DataConfig.from_raw(
241
+ {
242
+ "data": {
243
+ "resources": {
244
+ "mongo.content": {"type": "mongodb", "url": "mongodb://localhost"}
245
+ }
246
+ }
247
+ }
248
+ ).resources["mongo.content"]
249
+ ).get_document("profiles", "denis")
250
+
251
+
252
+ def _catalog(factory: MongoDocumentStoreFactory) -> DataAdapterCatalog:
253
+ catalog = DataAdapterCatalog.with_defaults()
254
+ catalog.register(factory)
255
+ return catalog