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.
- muscles_data_elasticsearch-0.1.0/PKG-INFO +80 -0
- muscles_data_elasticsearch-0.1.0/README.md +68 -0
- muscles_data_elasticsearch-0.1.0/pyproject.toml +32 -0
- muscles_data_elasticsearch-0.1.0/setup.cfg +4 -0
- muscles_data_elasticsearch-0.1.0/src/muscles_data_elasticsearch/__init__.py +28 -0
- muscles_data_elasticsearch-0.1.0/src/muscles_data_elasticsearch/adapter.py +541 -0
- muscles_data_elasticsearch-0.1.0/src/muscles_data_elasticsearch.egg-info/PKG-INFO +80 -0
- muscles_data_elasticsearch-0.1.0/src/muscles_data_elasticsearch.egg-info/SOURCES.txt +11 -0
- muscles_data_elasticsearch-0.1.0/src/muscles_data_elasticsearch.egg-info/dependency_links.txt +1 -0
- muscles_data_elasticsearch-0.1.0/src/muscles_data_elasticsearch.egg-info/requires.txt +5 -0
- muscles_data_elasticsearch-0.1.0/src/muscles_data_elasticsearch.egg-info/top_level.txt +1 -0
- muscles_data_elasticsearch-0.1.0/tests/test_elasticsearch_adapter.py +209 -0
- muscles_data_elasticsearch-0.1.0/tests/test_integration.py +72 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
muscles_data_elasticsearch
|
|
@@ -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()
|