muscles-data-opensearch 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,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: muscles-data-opensearch
3
+ Version: 0.1.0
4
+ Summary: OpenSearch SearchIndexPort 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: opensearch-py<3.0,>=2.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest<9.0,>=8.0; extra == "dev"
12
+
13
+ # muscles-data-opensearch
14
+
15
+ OpenSearch 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
+ OpenSearch-backed `SearchIndexPort` implementation.
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
+ - Redis key-value/lock/stream adapter:
28
+ [`muscles-data-redis`](https://github.com/butkoden/muscles-data-redis)
29
+ - Qdrant vector adapter:
30
+ [`muscles-data-qdrant`](https://github.com/butkoden/muscles-data-qdrant)
31
+ - MongoDB document-store adapter:
32
+ [`muscles-data-mongodb`](https://github.com/butkoden/muscles-data-mongodb)
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_opensearch_1`](https://github.com/butkoden/muscular-example/tree/master/example_data_opensearch_1)
39
+
40
+ ## Usage
41
+
42
+ Register the factory in the project composition root:
43
+
44
+ ```python
45
+ from muscles_data.catalog import DataAdapterCatalog
46
+ from muscles_data.ports import SearchIndexPort
47
+ from muscles_data.runtime import DataRuntime
48
+ from muscles_data_opensearch import OpenSearchSearchFactory
49
+
50
+ catalog = DataAdapterCatalog.with_defaults()
51
+ catalog.register(OpenSearchSearchFactory())
52
+
53
+ runtime = DataRuntime(config=config, catalog=catalog)
54
+ search = runtime.require_port("search.public", SearchIndexPort)
55
+ ```
56
+
57
+ Resource config stays in the project:
58
+
59
+ ```yaml
60
+ data:
61
+ resources:
62
+ search.public:
63
+ type: opensearch
64
+ url: ${OPENSEARCH_URL}
65
+ username: ${OPENSEARCH_USER}
66
+ password: ${OPENSEARCH_PASSWORD}
67
+ index: docs
68
+ timeout: 3
69
+ verify_certs: true
70
+ ```
71
+
72
+ The adapter creates the OpenSearch client and a missing index lazily on search,
73
+ index/delete, explicit native access or `data.doctor`. New indexes receive a
74
+ default mapping for `text`, `title` and `metadata`; `mapping` and `settings`
75
+ resource options customize it. Application code should use
76
+ `SearchIndexPort`; direct client access is only an advanced escape hatch with
77
+ `native_client: true`.
78
+
79
+ See `muscular-example/example_data_opensearch_1` for an executable example.
@@ -0,0 +1,67 @@
1
+ # muscles-data-opensearch
2
+
3
+ OpenSearch 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
+ OpenSearch-backed `SearchIndexPort` implementation.
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
+ - Redis key-value/lock/stream adapter:
16
+ [`muscles-data-redis`](https://github.com/butkoden/muscles-data-redis)
17
+ - Qdrant vector adapter:
18
+ [`muscles-data-qdrant`](https://github.com/butkoden/muscles-data-qdrant)
19
+ - MongoDB document-store adapter:
20
+ [`muscles-data-mongodb`](https://github.com/butkoden/muscles-data-mongodb)
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_opensearch_1`](https://github.com/butkoden/muscular-example/tree/master/example_data_opensearch_1)
27
+
28
+ ## Usage
29
+
30
+ Register the factory in the project composition root:
31
+
32
+ ```python
33
+ from muscles_data.catalog import DataAdapterCatalog
34
+ from muscles_data.ports import SearchIndexPort
35
+ from muscles_data.runtime import DataRuntime
36
+ from muscles_data_opensearch import OpenSearchSearchFactory
37
+
38
+ catalog = DataAdapterCatalog.with_defaults()
39
+ catalog.register(OpenSearchSearchFactory())
40
+
41
+ runtime = DataRuntime(config=config, catalog=catalog)
42
+ search = runtime.require_port("search.public", SearchIndexPort)
43
+ ```
44
+
45
+ Resource config stays in the project:
46
+
47
+ ```yaml
48
+ data:
49
+ resources:
50
+ search.public:
51
+ type: opensearch
52
+ url: ${OPENSEARCH_URL}
53
+ username: ${OPENSEARCH_USER}
54
+ password: ${OPENSEARCH_PASSWORD}
55
+ index: docs
56
+ timeout: 3
57
+ verify_certs: true
58
+ ```
59
+
60
+ The adapter creates the OpenSearch client and a missing index lazily on search,
61
+ index/delete, explicit native access or `data.doctor`. New indexes receive a
62
+ default mapping for `text`, `title` and `metadata`; `mapping` and `settings`
63
+ resource options customize it. Application code should use
64
+ `SearchIndexPort`; direct client access is only an advanced escape hatch with
65
+ `native_client: true`.
66
+
67
+ See `muscular-example/example_data_opensearch_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-opensearch"
7
+ version = "0.1.0"
8
+ description = "OpenSearch SearchIndexPort 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
+ "opensearch-py>=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,24 @@
1
+ from __future__ import annotations
2
+
3
+ from .adapter import (
4
+ OpenSearchAdapterError,
5
+ OpenSearchClientMissingError,
6
+ OpenSearchConfigError,
7
+ OpenSearchConnectionError,
8
+ OpenSearchFilterError,
9
+ OpenSearchSearchAdapter,
10
+ OpenSearchSearchFactory,
11
+ opensearch_filter_from_mapping,
12
+ )
13
+
14
+
15
+ __all__ = [
16
+ "OpenSearchAdapterError",
17
+ "OpenSearchClientMissingError",
18
+ "OpenSearchConfigError",
19
+ "OpenSearchConnectionError",
20
+ "OpenSearchFilterError",
21
+ "OpenSearchSearchAdapter",
22
+ "OpenSearchSearchFactory",
23
+ "opensearch_filter_from_mapping",
24
+ ]
@@ -0,0 +1,488 @@
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, SearchHit, WriteResult
13
+
14
+
15
+ _CLIENT_UNSET = object()
16
+ _RANGE_OPERATORS = {"gt", "gte", "lt", "lte"}
17
+ DEFAULT_INDEX_MAPPING = {
18
+ "properties": {
19
+ "text": {"type": "text"},
20
+ "title": {"type": "text", "fields": {"keyword": {"type": "keyword"}}},
21
+ "metadata": {"type": "object", "dynamic": True},
22
+ }
23
+ }
24
+ _ALLOWED_OPTIONS = {
25
+ "url",
26
+ "url_env",
27
+ "username",
28
+ "password",
29
+ "http_auth",
30
+ "index",
31
+ "timeout",
32
+ "verify_certs",
33
+ "use_ssl",
34
+ "ssl_assert_hostname",
35
+ "ssl_show_warn",
36
+ "ca_certs",
37
+ "native_client",
38
+ "text_field",
39
+ "metadata_field",
40
+ "mapping",
41
+ "settings",
42
+ }
43
+
44
+
45
+ class OpenSearchAdapterError(DataError):
46
+ """Base error for OpenSearch search adapter failures."""
47
+
48
+
49
+ class OpenSearchConfigError(ValueError, OpenSearchAdapterError):
50
+ """Raised when an OpenSearch resource config cannot be mapped safely."""
51
+
52
+
53
+ class OpenSearchClientMissingError(OpenSearchAdapterError):
54
+ """Raised when OpenSearch adapter is used without an available client."""
55
+
56
+
57
+ class OpenSearchConnectionError(OpenSearchAdapterError):
58
+ """Raised when an OpenSearch operation cannot reach or use the backend."""
59
+
60
+
61
+ class OpenSearchFilterError(OpenSearchAdapterError):
62
+ """Raised when a data filter cannot be translated to OpenSearch."""
63
+
64
+
65
+ class OpenSearchSearchAdapter:
66
+ resource_type = "opensearch"
67
+
68
+ def __init__(
69
+ self,
70
+ config: DataResourceConfig,
71
+ *,
72
+ client_factory: Callable[[DataResourceConfig], Any] | None = None,
73
+ ) -> None:
74
+ self.config = config
75
+ self._client_factory = client_factory
76
+ self._client: Any = _CLIENT_UNSET
77
+ self._lock = threading.RLock()
78
+ self._index_ready = False
79
+ self.closed = False
80
+
81
+ def search_text(
82
+ self,
83
+ query: str,
84
+ filters: Mapping[str, Any] | None = None,
85
+ limit: int = 10,
86
+ options: Mapping[str, Any] | None = None,
87
+ ) -> list[SearchHit]:
88
+ options = dict(options or {})
89
+ body: dict[str, Any] = {
90
+ "query": self._query(query, filters),
91
+ "size": max(0, int(limit)),
92
+ }
93
+ if options.pop("highlight", False):
94
+ fields = options.pop("highlight_fields", [self.text_field()])
95
+ body["highlight"] = {"fields": {str(field): {} for field in fields}}
96
+ kwargs: dict[str, Any] = {"index": self.index_name(), "body": body}
97
+ for key in ("from_", "sort", "track_total_hits", "routing", "preference", "search_after"):
98
+ if key in options:
99
+ kwargs[key] = options[key]
100
+ try:
101
+ response = self._client_instance().search(**kwargs)
102
+ except (OpenSearchClientMissingError, OpenSearchConfigError, OpenSearchFilterError):
103
+ raise
104
+ except Exception as exc:
105
+ raise OpenSearchConnectionError(self._safe_error(exc)) from exc
106
+ return [
107
+ _hit_from_response(hit, text_field=self.text_field(), metadata_field=self.metadata_field())
108
+ for hit in _hits(response)
109
+ ]
110
+
111
+ def upsert_documents(self, items: list[Mapping[str, Any]], options: Mapping[str, Any] | None = None) -> WriteResult:
112
+ options = dict(options or {})
113
+ client = self._client_instance()
114
+ try:
115
+ for item in items:
116
+ kwargs: dict[str, Any] = {
117
+ "index": self.index_name(),
118
+ "id": str(item["id"]),
119
+ "body": self._document_from_item(item),
120
+ }
121
+ for key in ("refresh", "routing", "pipeline"):
122
+ if key in options:
123
+ kwargs[key] = options[key]
124
+ client.index(**kwargs)
125
+ except KeyError as exc:
126
+ raise OpenSearchConfigError(f"OpenSearch document item requires {exc.args[0]}") from exc
127
+ except (OpenSearchClientMissingError, OpenSearchConfigError, OpenSearchFilterError):
128
+ raise
129
+ except Exception as exc:
130
+ raise OpenSearchConnectionError(self._safe_error(exc)) from exc
131
+ return WriteResult(written=len(items), matched=len(items))
132
+
133
+ def delete_documents(
134
+ self,
135
+ ids: list[str] | None = None,
136
+ filters: Mapping[str, Any] | None = None,
137
+ options: Mapping[str, Any] | None = None,
138
+ ) -> WriteResult:
139
+ options = dict(options or {})
140
+ if ids is None and not filters:
141
+ return WriteResult()
142
+ client = self._client_instance()
143
+ try:
144
+ if ids is not None:
145
+ deleted = 0
146
+ for document_id in ids:
147
+ kwargs: dict[str, Any] = {"index": self.index_name(), "id": str(document_id)}
148
+ for key in ("refresh", "routing"):
149
+ if key in options:
150
+ kwargs[key] = options[key]
151
+ client.delete(**kwargs)
152
+ deleted += 1
153
+ return WriteResult(deleted=deleted, matched=deleted)
154
+ kwargs = {
155
+ "index": self.index_name(),
156
+ "body": {"query": self._filter_query(filters)},
157
+ }
158
+ for key in ("refresh", "routing", "conflicts"):
159
+ if key in options:
160
+ kwargs[key] = options[key]
161
+ response = client.delete_by_query(**kwargs)
162
+ except (OpenSearchClientMissingError, OpenSearchConfigError, OpenSearchFilterError):
163
+ raise
164
+ except Exception as exc:
165
+ raise OpenSearchConnectionError(self._safe_error(exc)) from exc
166
+ deleted = int(_mapping_get(response, "deleted", 0) or 0)
167
+ return WriteResult(deleted=deleted, matched=deleted)
168
+
169
+ def inspect(self) -> dict[str, Any]:
170
+ return asdict(
171
+ InspectResult(
172
+ name=self.config.name,
173
+ type=self.config.type,
174
+ capabilities=[],
175
+ initialized=self._client is not _CLIENT_UNSET,
176
+ status="ok",
177
+ options=self.config.safe_options(),
178
+ details={"backend": "opensearch", "index": self.index_name()},
179
+ )
180
+ )
181
+
182
+ def health(self) -> HealthResult:
183
+ try:
184
+ client = self._client_instance()
185
+ ping = getattr(client, "ping", None)
186
+ if callable(ping) and not bool(ping()):
187
+ return HealthResult(
188
+ status="failed",
189
+ message="OpenSearch ping failed",
190
+ details={"index": self.index_name()},
191
+ )
192
+ indices = getattr(client, "indices", None)
193
+ exists = self._index_ready
194
+ if not exists and indices is not None and hasattr(indices, "exists"):
195
+ exists = bool(indices.exists(index=self.index_name()))
196
+ except Exception as exc:
197
+ return HealthResult(status="failed", message=self._safe_error(exc), details={"index": self.index_name()})
198
+ if not exists:
199
+ return HealthResult(
200
+ status="failed",
201
+ message=f"OpenSearch index '{self.index_name()}' is not available",
202
+ details={"index": self.index_name()},
203
+ )
204
+ return HealthResult(status="ok", message="OpenSearch index is available", details={"index": self.index_name()})
205
+
206
+ def close(self) -> None:
207
+ if self._client is _CLIENT_UNSET:
208
+ self.closed = True
209
+ return
210
+ close = getattr(self._client, "close", None)
211
+ if callable(close):
212
+ close()
213
+ self.closed = True
214
+
215
+ def native_client(self):
216
+ return self._client_instance()
217
+
218
+ def index_name(self) -> str:
219
+ return str(self.config.options["index"])
220
+
221
+ def text_field(self) -> str:
222
+ return str(self.config.options.get("text_field", "text"))
223
+
224
+ def metadata_field(self) -> str:
225
+ return str(self.config.options.get("metadata_field", "metadata"))
226
+
227
+ def _query(self, query: str, filters: Mapping[str, Any] | None) -> dict[str, Any]:
228
+ clauses: dict[str, Any] = {}
229
+ if str(query).strip():
230
+ clauses["must"] = [{"match": {self.text_field(): {"query": str(query)}}}]
231
+ else:
232
+ clauses["must"] = [{"match_all": {}}]
233
+ filter_clauses = opensearch_filter_from_mapping(filters or {}, field_prefix=self.metadata_field())
234
+ if filter_clauses:
235
+ clauses["filter"] = filter_clauses
236
+ return {"bool": clauses}
237
+
238
+ def _filter_query(self, filters: Mapping[str, Any] | None) -> dict[str, Any]:
239
+ filter_clauses = opensearch_filter_from_mapping(filters or {}, field_prefix=self.metadata_field())
240
+ return {"bool": {"filter": filter_clauses}} if filter_clauses else {"match_all": {}}
241
+
242
+ def _document_from_item(self, item: Mapping[str, Any]) -> dict[str, Any]:
243
+ metadata = dict(item.get("payload", {}) or {})
244
+ metadata.update(dict(item.get("metadata", {}) or {}))
245
+ document = {
246
+ self.text_field(): str(item.get("text", "")),
247
+ self.metadata_field(): metadata,
248
+ }
249
+ if item.get("title") is not None:
250
+ document["title"] = str(item["title"])
251
+ fields = item.get("fields")
252
+ if isinstance(fields, Mapping):
253
+ document.update(dict(fields))
254
+ return document
255
+
256
+ def _client_instance(self):
257
+ if self._client is _CLIENT_UNSET:
258
+ with self._lock:
259
+ if self._client is _CLIENT_UNSET:
260
+ self._validate_options()
261
+ client = (
262
+ self._client_factory(self.config)
263
+ if self._client_factory
264
+ else _default_opensearch_client(self.config)
265
+ )
266
+ if client is None:
267
+ raise OpenSearchClientMissingError("OpenSearch client is not available")
268
+ self._client = client
269
+ try:
270
+ self._ensure_index(client)
271
+ except Exception:
272
+ self._client = _CLIENT_UNSET
273
+ close = getattr(client, "close", None)
274
+ if callable(close):
275
+ close()
276
+ raise
277
+ return self._client
278
+
279
+ def _validate_options(self) -> None:
280
+ unknown = sorted(set(self.config.options) - _ALLOWED_OPTIONS)
281
+ if unknown:
282
+ names = ", ".join(unknown)
283
+ raise OpenSearchConfigError(f"Unsupported OpenSearch resource options: {names}")
284
+ http_auth = self.config.options.get("http_auth")
285
+ if http_auth is not None and (not isinstance(http_auth, (list, tuple)) or len(http_auth) != 2):
286
+ raise OpenSearchConfigError("OpenSearch http_auth must contain username and password")
287
+ if not self.config.options.get("url") and not self.config.options.get("url_env"):
288
+ raise OpenSearchConfigError("OpenSearch resource requires url or url_env")
289
+ self.index_name()
290
+
291
+ def mapping(self) -> dict[str, Any]:
292
+ mapping = self.config.options.get("mapping")
293
+ if mapping is None:
294
+ return dict(DEFAULT_INDEX_MAPPING)
295
+ if not isinstance(mapping, Mapping):
296
+ raise OpenSearchConfigError("OpenSearch mapping must be a mapping")
297
+ return dict(mapping)
298
+
299
+ def _ensure_index(self, client: Any) -> None:
300
+ indices = getattr(client, "indices", None)
301
+ exists = getattr(indices, "exists", None)
302
+ create = getattr(indices, "create", None)
303
+ if not callable(exists) or not callable(create):
304
+ return
305
+ try:
306
+ if bool(exists(index=self.index_name())):
307
+ self._index_ready = True
308
+ return
309
+ body: dict[str, Any] = {"mappings": self.mapping()}
310
+ settings = self.config.options.get("settings")
311
+ if settings is not None:
312
+ if not isinstance(settings, Mapping):
313
+ raise OpenSearchConfigError("OpenSearch settings must be a mapping")
314
+ body["settings"] = dict(settings)
315
+ create(index=self.index_name(), body=body)
316
+ self._index_ready = True
317
+ except OpenSearchConfigError:
318
+ raise
319
+ except Exception as exc:
320
+ message = self._safe_error(exc)
321
+ if "already exists" in message.lower() or "resource_already_exists" in message.lower():
322
+ self._index_ready = True
323
+ return
324
+ raise OpenSearchConnectionError(message) from exc
325
+
326
+ def _safe_error(self, exc: Exception) -> str:
327
+ message = str(exc)
328
+ try:
329
+ options = self.config.resolved_options()
330
+ except Exception:
331
+ options = self.config.options
332
+ for key, value in options.items():
333
+ if key in {"url", "username", "password", "http_auth"} and value:
334
+ if isinstance(value, (list, tuple)):
335
+ for item in value:
336
+ message = message.replace(str(item), "***")
337
+ else:
338
+ message = message.replace(str(value), "***")
339
+ if key == "url":
340
+ parsed = urlsplit(str(value))
341
+ for item in (parsed.username, parsed.password):
342
+ if item:
343
+ message = message.replace(str(item), "***")
344
+ return message
345
+
346
+
347
+ class OpenSearchSearchFactory:
348
+ resource_type = "opensearch"
349
+
350
+ def __init__(self, *, client_factory: Callable[[DataResourceConfig], Any] | None = None) -> None:
351
+ self._client_factory = client_factory
352
+
353
+ def capabilities(self, config: DataResourceConfig) -> set[DataCapability]:
354
+ native = {DataCapability.NATIVE_CLIENT} if bool(config.options.get("native_client")) else set()
355
+ return {DataCapability.KEYWORD_SEARCH, DataCapability.DOCUMENT_INDEX, DataCapability.HEALTHCHECK} | native
356
+
357
+ def create(self, config: DataResourceConfig) -> OpenSearchSearchAdapter:
358
+ return OpenSearchSearchAdapter(config, client_factory=self._client_factory)
359
+
360
+
361
+ def opensearch_filter_from_mapping(
362
+ filters: Mapping[str, Any],
363
+ *,
364
+ field_prefix: str | None = "metadata",
365
+ ) -> list[dict[str, Any]]:
366
+ clauses: list[dict[str, Any]] = []
367
+ for key in sorted(filters, key=_filter_sort_key):
368
+ value = filters[key]
369
+ if key == "$and":
370
+ clauses.append({"bool": {"filter": _logical_conditions(value, field_prefix=field_prefix)}})
371
+ elif key == "$or":
372
+ clauses.append(
373
+ {
374
+ "bool": {
375
+ "should": _logical_conditions(value, field_prefix=field_prefix),
376
+ "minimum_should_match": 1,
377
+ }
378
+ }
379
+ )
380
+ elif key == "$not":
381
+ clauses.append({"bool": {"must_not": _logical_conditions(value, field_prefix=field_prefix)}})
382
+ else:
383
+ clauses.append(_field_condition(str(key), value, field_prefix=field_prefix))
384
+ return clauses
385
+
386
+
387
+ def _logical_conditions(value: Any, *, field_prefix: str | None) -> list[dict[str, Any]]:
388
+ if isinstance(value, Mapping):
389
+ return opensearch_filter_from_mapping(value, field_prefix=field_prefix)
390
+ if isinstance(value, list):
391
+ conditions: list[dict[str, Any]] = []
392
+ for item in value:
393
+ if not isinstance(item, Mapping):
394
+ raise OpenSearchFilterError("OpenSearch logical filter items must be mappings")
395
+ conditions.extend(opensearch_filter_from_mapping(item, field_prefix=field_prefix))
396
+ return conditions
397
+ raise OpenSearchFilterError("OpenSearch logical filters must be mappings or lists of mappings")
398
+
399
+
400
+ def _field_condition(key: str, value: Any, *, field_prefix: str | None) -> dict[str, Any]:
401
+ field = _field_name(key, field_prefix)
402
+ if isinstance(value, Mapping):
403
+ operators = set(value)
404
+ if operators <= _RANGE_OPERATORS:
405
+ return {"range": {field: dict(value)}}
406
+ if operators == {"eq"}:
407
+ return {"term": {field: value["eq"]}}
408
+ if operators == {"any"}:
409
+ return {"terms": {field: list(value["any"])}}
410
+ unsupported = ", ".join(sorted(str(operator) for operator in operators))
411
+ raise OpenSearchFilterError(f"Unsupported OpenSearch filter operator for '{key}': {unsupported}")
412
+ if isinstance(value, (list, tuple, set)):
413
+ return {"terms": {field: list(value)}}
414
+ return {"term": {field: value}}
415
+
416
+
417
+ def _field_name(key: str, field_prefix: str | None) -> str:
418
+ if not field_prefix or "." in key:
419
+ return key
420
+ return f"{field_prefix}.{key}"
421
+
422
+
423
+ def _filter_sort_key(key: Any) -> tuple[int, str]:
424
+ order = {"$and": 0, "$or": 1, "$not": 2}
425
+ key_text = str(key)
426
+ return (order.get(key_text, 10), key_text)
427
+
428
+
429
+ def _hit_from_response(hit: Mapping[str, Any], *, text_field: str, metadata_field: str) -> SearchHit:
430
+ source = dict(hit.get("_source", {}) or {})
431
+ metadata = dict(source.get(metadata_field, {}) or {})
432
+ highlights = {
433
+ str(key): [str(item) for item in value]
434
+ for key, value in dict(hit.get("highlight", {}) or {}).items()
435
+ }
436
+ return SearchHit(
437
+ id=str(hit.get("_id", "")),
438
+ score=float(hit.get("_score", 0.0) or 0.0),
439
+ text=source.get(text_field),
440
+ metadata=metadata,
441
+ highlights=highlights,
442
+ title=source.get("title"),
443
+ )
444
+
445
+
446
+ def _hits(response: Any) -> list[Mapping[str, Any]]:
447
+ return list(_mapping_get(_mapping_get(response, "hits", {}), "hits", []) or [])
448
+
449
+
450
+ def _mapping_get(value: Any, key: str, default: Any = None) -> Any:
451
+ if isinstance(value, Mapping):
452
+ return value.get(key, default)
453
+ try:
454
+ return value[key]
455
+ except Exception:
456
+ return default
457
+
458
+
459
+ def _default_opensearch_client(config: DataResourceConfig):
460
+ try:
461
+ client_module = importlib.import_module("opensearchpy")
462
+ except Exception as exc:
463
+ raise OpenSearchClientMissingError("Install opensearch-py or muscles-data-opensearch to use type=opensearch") from exc
464
+
465
+ try:
466
+ options = config.resolved_options()
467
+ except Exception as exc:
468
+ raise OpenSearchConfigError(str(exc)) from exc
469
+ url = str(options["url"])
470
+ parsed = urlsplit(url)
471
+ kwargs: dict[str, Any] = {"hosts": [url]}
472
+ if options.get("http_auth"):
473
+ kwargs["http_auth"] = tuple(options["http_auth"])
474
+ elif options.get("username") or options.get("password"):
475
+ kwargs["http_auth"] = (options.get("username"), options.get("password"))
476
+ if options.get("timeout") is not None:
477
+ kwargs["timeout"] = options["timeout"]
478
+ if options.get("verify_certs") is not None:
479
+ kwargs["verify_certs"] = bool(options["verify_certs"])
480
+ kwargs["use_ssl"] = bool(options.get("use_ssl", parsed.scheme == "https"))
481
+ for key in ("ssl_assert_hostname", "ssl_show_warn", "ca_certs"):
482
+ if key in options:
483
+ kwargs[key] = options[key]
484
+
485
+ try:
486
+ return client_module.OpenSearch(**kwargs)
487
+ except Exception as exc:
488
+ raise OpenSearchConnectionError(str(exc)) from exc
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: muscles-data-opensearch
3
+ Version: 0.1.0
4
+ Summary: OpenSearch SearchIndexPort 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: opensearch-py<3.0,>=2.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest<9.0,>=8.0; extra == "dev"
12
+
13
+ # muscles-data-opensearch
14
+
15
+ OpenSearch 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
+ OpenSearch-backed `SearchIndexPort` implementation.
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
+ - Redis key-value/lock/stream adapter:
28
+ [`muscles-data-redis`](https://github.com/butkoden/muscles-data-redis)
29
+ - Qdrant vector adapter:
30
+ [`muscles-data-qdrant`](https://github.com/butkoden/muscles-data-qdrant)
31
+ - MongoDB document-store adapter:
32
+ [`muscles-data-mongodb`](https://github.com/butkoden/muscles-data-mongodb)
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_opensearch_1`](https://github.com/butkoden/muscular-example/tree/master/example_data_opensearch_1)
39
+
40
+ ## Usage
41
+
42
+ Register the factory in the project composition root:
43
+
44
+ ```python
45
+ from muscles_data.catalog import DataAdapterCatalog
46
+ from muscles_data.ports import SearchIndexPort
47
+ from muscles_data.runtime import DataRuntime
48
+ from muscles_data_opensearch import OpenSearchSearchFactory
49
+
50
+ catalog = DataAdapterCatalog.with_defaults()
51
+ catalog.register(OpenSearchSearchFactory())
52
+
53
+ runtime = DataRuntime(config=config, catalog=catalog)
54
+ search = runtime.require_port("search.public", SearchIndexPort)
55
+ ```
56
+
57
+ Resource config stays in the project:
58
+
59
+ ```yaml
60
+ data:
61
+ resources:
62
+ search.public:
63
+ type: opensearch
64
+ url: ${OPENSEARCH_URL}
65
+ username: ${OPENSEARCH_USER}
66
+ password: ${OPENSEARCH_PASSWORD}
67
+ index: docs
68
+ timeout: 3
69
+ verify_certs: true
70
+ ```
71
+
72
+ The adapter creates the OpenSearch client and a missing index lazily on search,
73
+ index/delete, explicit native access or `data.doctor`. New indexes receive a
74
+ default mapping for `text`, `title` and `metadata`; `mapping` and `settings`
75
+ resource options customize it. Application code should use
76
+ `SearchIndexPort`; direct client access is only an advanced escape hatch with
77
+ `native_client: true`.
78
+
79
+ See `muscular-example/example_data_opensearch_1` for an executable example.
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/muscles_data_opensearch/__init__.py
4
+ src/muscles_data_opensearch/adapter.py
5
+ src/muscles_data_opensearch.egg-info/PKG-INFO
6
+ src/muscles_data_opensearch.egg-info/SOURCES.txt
7
+ src/muscles_data_opensearch.egg-info/dependency_links.txt
8
+ src/muscles_data_opensearch.egg-info/requires.txt
9
+ src/muscles_data_opensearch.egg-info/top_level.txt
10
+ tests/test_integration.py
11
+ tests/test_opensearch_adapter.py
@@ -0,0 +1,5 @@
1
+ muscles-data<1.0.0,>=0.1.0
2
+ opensearch-py<3.0,>=2.0
3
+
4
+ [dev]
5
+ pytest<9.0,>=8.0
@@ -0,0 +1,73 @@
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 SearchIndexPort
11
+ from muscles_data.runtime import DataRuntime
12
+
13
+ from muscles_data_opensearch import OpenSearchSearchFactory
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_opensearch_real_search_index_lifecycle():
23
+ index = f"muscles-data-it-{uuid4().hex[:12]}"
24
+ config = DataConfig.from_raw(
25
+ {
26
+ "data": {
27
+ "resources": {
28
+ "search.open": {
29
+ "type": "opensearch",
30
+ "url_env": "OPENSEARCH_URL",
31
+ "index": index,
32
+ "verify_certs": False,
33
+ "native_client": True,
34
+ }
35
+ }
36
+ }
37
+ }
38
+ )
39
+ catalog = DataAdapterCatalog.with_defaults()
40
+ catalog.register(OpenSearchSearchFactory())
41
+ runtime = DataRuntime(config=config, catalog=catalog)
42
+
43
+ client = None
44
+ try:
45
+ search = runtime.require_port("search.open", SearchIndexPort)
46
+ assert search.upsert_documents(
47
+ [
48
+ {"id": "alpha", "title": "Alpha", "text": "alpha document", "metadata": {"status": "ready"}},
49
+ {"id": "beta", "title": "Beta", "text": "beta document", "metadata": {"status": "draft"}},
50
+ ],
51
+ options={"refresh": "wait_for"},
52
+ ).written == 2
53
+ hits = search.search_text("alpha", filters={"status": "ready"}, options={"highlight": True})
54
+ assert [hit.id for hit in hits] == ["alpha"]
55
+ assert hits[0].highlights
56
+ assert runtime.doctor()["status"] == "ok"
57
+ assert search.delete_documents(ids=["alpha"], options={"refresh": "wait_for"}).deleted == 1
58
+ assert search.search_text("alpha") == []
59
+ contracts = pytest.importorskip("muscles_data.contracts")
60
+ contract = getattr(contracts, "assert_search_index_contract", None)
61
+ if contract is not None:
62
+ contract(lambda: search)
63
+ finally:
64
+ try:
65
+ if client is None:
66
+ try:
67
+ client = runtime.require_resource("search.open", DataCapability.NATIVE_CLIENT).native_client()
68
+ except Exception:
69
+ client = None
70
+ if client is not None:
71
+ client.indices.delete(index=index, ignore=[404])
72
+ finally:
73
+ runtime.close()
@@ -0,0 +1,160 @@
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 SearchIndexPort
8
+ from muscles_data.runtime import DataRuntime
9
+
10
+ from muscles_data_opensearch import (
11
+ OpenSearchClientMissingError,
12
+ OpenSearchConnectionError,
13
+ OpenSearchFilterError,
14
+ OpenSearchSearchFactory,
15
+ opensearch_filter_from_mapping,
16
+ )
17
+
18
+
19
+ class FakeIndices:
20
+ def __init__(self, client: "FakeOpenSearchClient") -> None:
21
+ self.client = client
22
+ self.created: list[dict] = []
23
+
24
+ def exists(self, *, index: str) -> bool:
25
+ self.client.index_checks.append(index)
26
+ if self.client.fail_health:
27
+ raise TimeoutError("opensearch password=secret timed out")
28
+ return index == "docs" or any(item["index"] == index for item in self.created)
29
+
30
+ def create(self, **kwargs):
31
+ self.created.append(dict(kwargs))
32
+ return {"acknowledged": True}
33
+
34
+
35
+ class FakeOpenSearchClient:
36
+ def __init__(self, *, fail_health: bool = False) -> None:
37
+ self.fail_health = fail_health
38
+ self.searches: list[dict] = []
39
+ self.indexes: list[dict] = []
40
+ self.delete_queries: list[dict] = []
41
+ self.index_checks: list[str] = []
42
+ self.closed = False
43
+ self.indices = FakeIndices(self)
44
+
45
+ def search(self, **kwargs):
46
+ self.searches.append(kwargs)
47
+ return {"hits": {"hits": [{"_id": "doc-1", "_score": 4.2, "_source": {"title": "Ports", "text": "Muscles data ports", "metadata": {"section": "docs"}}}]}}
48
+
49
+ def index(self, **kwargs):
50
+ self.indexes.append(kwargs)
51
+ return {"result": "created"}
52
+
53
+ def delete_by_query(self, **kwargs):
54
+ self.delete_queries.append(kwargs)
55
+ return {"deleted": 3}
56
+
57
+ def ping(self) -> bool:
58
+ if self.fail_health:
59
+ raise TimeoutError("opensearch password=secret timed out")
60
+ return True
61
+
62
+ def close(self) -> None:
63
+ self.closed = True
64
+
65
+
66
+ def _config(url: str = "https://opensearch.example") -> dict:
67
+ return {
68
+ "data": {
69
+ "resources": {
70
+ "search.open": {
71
+ "type": "opensearch",
72
+ "url": url,
73
+ "username": "admin",
74
+ "password": "open-secret",
75
+ "index": "docs",
76
+ "native_client": True,
77
+ }
78
+ }
79
+ }
80
+ }
81
+
82
+
83
+ def _runtime(client: FakeOpenSearchClient | None, url: str = "https://opensearch.example") -> DataRuntime:
84
+ catalog = DataAdapterCatalog.with_defaults()
85
+ catalog.register(OpenSearchSearchFactory(client_factory=lambda _config: client))
86
+ return DataRuntime(config=DataConfig.from_raw(_config(url)), catalog=catalog)
87
+
88
+
89
+ def test_opensearch_external_adapter_maps_search_index_delete_and_native_access():
90
+ client = FakeOpenSearchClient()
91
+ runtime = _runtime(client)
92
+
93
+ listed = runtime.list_resources()[0]
94
+ assert listed["type"] == "opensearch"
95
+ assert {"keyword_search", "document_index"} <= set(listed["capabilities"])
96
+ assert listed["initialized"] is False
97
+
98
+ search = runtime.require_port("search.open", SearchIndexPort)
99
+ hits = search.search_text("muscles", filters={"section": "docs"}, limit=2)
100
+ write = search.upsert_documents([{"id": "doc-1", "title": "Ports", "text": "Muscles data ports", "metadata": {"section": "docs"}}])
101
+ deleted = search.delete_documents(filters={"section": ["docs", "notes"]})
102
+
103
+ assert [hit.id for hit in hits] == ["doc-1"]
104
+ assert hits[0].title == "Ports"
105
+ assert client.searches[0]["body"]["query"]["bool"]["filter"] == [{"term": {"metadata.section": "docs"}}]
106
+ assert write.written == 1
107
+ assert client.indexes[0]["body"]["title"] == "Ports"
108
+ assert client.indexes[0]["body"]["metadata"] == {"section": "docs"}
109
+ assert deleted.deleted == 3
110
+ assert client.delete_queries[0]["body"]["query"]["bool"]["filter"][0] == {"terms": {"metadata.section": ["docs", "notes"]}}
111
+ assert runtime.require_resource("search.open", DataCapability.NATIVE_CLIENT).native_client() is client
112
+ assert runtime.doctor()["status"] == "ok"
113
+ assert client.index_checks == ["docs"]
114
+ assert runtime.close()["status"] == "ok"
115
+ assert client.closed is True
116
+
117
+
118
+ def test_opensearch_creates_missing_index_with_default_mapping():
119
+ client = FakeOpenSearchClient()
120
+ config = DataConfig.from_raw(
121
+ {
122
+ "data": {
123
+ "resources": {
124
+ "search.new": {
125
+ "type": "opensearch",
126
+ "url": "https://opensearch.example",
127
+ "index": "new-docs",
128
+ }
129
+ }
130
+ }
131
+ }
132
+ )
133
+ catalog = DataAdapterCatalog.with_defaults()
134
+ catalog.register(OpenSearchSearchFactory(client_factory=lambda _config: client))
135
+ runtime = DataRuntime(config=config, catalog=catalog)
136
+
137
+ runtime.require_port("search.new", SearchIndexPort).search_text("first")
138
+
139
+ assert client.indices.created[0]["index"] == "new-docs"
140
+ assert client.indices.created[0]["body"]["mappings"]["properties"]["text"] == {"type": "text"}
141
+
142
+
143
+ def test_opensearch_external_adapter_filters_and_safe_failures():
144
+ translated = opensearch_filter_from_mapping({"score": {"gte": 0.5}, "$not": {"archived": True}})
145
+ assert translated[0] == {"bool": {"must_not": [{"term": {"metadata.archived": True}}]}}
146
+ assert translated[1] == {"range": {"metadata.score": {"gte": 0.5}}}
147
+ with pytest.raises(OpenSearchFilterError):
148
+ opensearch_filter_from_mapping({"score": {"near": 1.0}})
149
+
150
+ with pytest.raises(OpenSearchClientMissingError):
151
+ _runtime(None).require_port("search.open", SearchIndexPort).search_text("x")
152
+
153
+ failing = _runtime(FakeOpenSearchClient(fail_health=True), "https://user:secret@opensearch.example").doctor()
154
+ assert failing["status"] == "failed"
155
+ assert "secret" not in repr(failing)
156
+
157
+ bad_client = FakeOpenSearchClient()
158
+ bad_client.search = lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("network unavailable"))
159
+ with pytest.raises(OpenSearchConnectionError):
160
+ _runtime(bad_client).require_port("search.open", SearchIndexPort).search_text("x")