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