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