python-databases 1.2.1__tar.gz → 2.0.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.
- {python_databases-1.2.1/python_databases.egg-info → python_databases-2.0.0}/PKG-INFO +2 -6
- {python_databases-1.2.1 → python_databases-2.0.0}/pyproject.toml +10 -6
- {python_databases-1.2.1 → python_databases-2.0.0}/python_databases/elasticsearch_infrastructure/consts.py +1 -1
- {python_databases-1.2.1 → python_databases-2.0.0}/python_databases/elasticsearch_infrastructure/elasticsearch.py +26 -16
- python_databases-2.0.0/python_databases/postgresql_infrastructure/consts.py +3 -0
- {python_databases-1.2.1 → python_databases-2.0.0/python_databases.egg-info}/PKG-INFO +2 -6
- {python_databases-1.2.1 → python_databases-2.0.0}/python_databases.egg-info/SOURCES.txt +1 -0
- python_databases-2.0.0/python_databases.egg-info/requires.txt +6 -0
- python_databases-2.0.0/tests/test_elasticsearch.py +489 -0
- python_databases-1.2.1/python_databases/postgresql_infrastructure/consts.py +0 -3
- python_databases-1.2.1/python_databases.egg-info/requires.txt +0 -10
- {python_databases-1.2.1 → python_databases-2.0.0}/LICENSE +0 -0
- {python_databases-1.2.1 → python_databases-2.0.0}/MANIFEST.in +0 -0
- {python_databases-1.2.1 → python_databases-2.0.0}/README.md +0 -0
- {python_databases-1.2.1 → python_databases-2.0.0}/python_databases/__init__.py +0 -0
- {python_databases-1.2.1 → python_databases-2.0.0}/python_databases/consts.py +0 -0
- {python_databases-1.2.1 → python_databases-2.0.0}/python_databases/elasticsearch_infrastructure/__init__.py +0 -0
- {python_databases-1.2.1 → python_databases-2.0.0}/python_databases/postgresql_infrastructure/__init__.py +0 -0
- {python_databases-1.2.1 → python_databases-2.0.0}/python_databases/postgresql_infrastructure/postgresql.py +0 -0
- {python_databases-1.2.1 → python_databases-2.0.0}/python_databases.egg-info/dependency_links.txt +0 -0
- {python_databases-1.2.1 → python_databases-2.0.0}/python_databases.egg-info/top_level.txt +0 -0
- {python_databases-1.2.1 → python_databases-2.0.0}/setup.cfg +0 -0
- {python_databases-1.2.1 → python_databases-2.0.0}/tests/test_postgres.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-databases
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.0
|
|
4
4
|
Summary: A Python library for managing and interacting with multiple databases seamlessly.
|
|
5
5
|
Author: Avi Zaguri
|
|
6
6
|
License: MIT
|
|
@@ -16,16 +16,12 @@ Classifier: Topic :: System :: Logging
|
|
|
16
16
|
Requires-Python: >=3.12
|
|
17
17
|
Description-Content-Type: text/markdown
|
|
18
18
|
License-File: LICENSE
|
|
19
|
-
Requires-Dist: custom-python-logger>=4.0.
|
|
19
|
+
Requires-Dist: custom-python-logger>=4.0.2
|
|
20
20
|
Requires-Dist: elasticsearch<9
|
|
21
21
|
Requires-Dist: pandas>=2.3.3
|
|
22
|
-
Requires-Dist: pre-commit>=4.5.0
|
|
23
22
|
Requires-Dist: psycopg2-binary>=2.9.11
|
|
24
|
-
Requires-Dist: pytest>=9.0.1
|
|
25
23
|
Requires-Dist: python-dotenv>=1.2.1
|
|
26
24
|
Requires-Dist: retrying>=1.4.2
|
|
27
|
-
Requires-Dist: setuptools>=80.9.0
|
|
28
|
-
Requires-Dist: wheel>=0.45.1
|
|
29
25
|
Dynamic: license-file
|
|
30
26
|
|
|
31
27
|

|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-databases"
|
|
7
|
-
version = "
|
|
7
|
+
version = "2.0.0"
|
|
8
8
|
description = "A Python library for managing and interacting with multiple databases seamlessly."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -21,16 +21,20 @@ classifiers = [
|
|
|
21
21
|
]
|
|
22
22
|
|
|
23
23
|
dependencies = [
|
|
24
|
-
"custom-python-logger>=4.0.
|
|
24
|
+
"custom-python-logger>=4.0.2",
|
|
25
25
|
"elasticsearch<9",
|
|
26
26
|
"pandas>=2.3.3",
|
|
27
|
-
"pre-commit>=4.5.0",
|
|
28
27
|
"psycopg2-binary>=2.9.11",
|
|
29
|
-
"pytest>=9.0.1",
|
|
30
28
|
"python-dotenv>=1.2.1",
|
|
31
29
|
"retrying>=1.4.2",
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[dependency-groups]
|
|
33
|
+
dev = [
|
|
34
|
+
"pre-commit>=4.6.2",
|
|
35
|
+
"pytest>=9.0.2",
|
|
36
|
+
"setuptools>=84.0.0",
|
|
37
|
+
"wheel>=0.48.0",
|
|
34
38
|
]
|
|
35
39
|
|
|
36
40
|
[project.urls]
|
|
@@ -63,7 +63,7 @@ class ElasticSearch(ABC):
|
|
|
63
63
|
) -> None:
|
|
64
64
|
failed_response_list = []
|
|
65
65
|
if failed_response := helpers.streaming_bulk(
|
|
66
|
-
|
|
66
|
+
self.elk_client, list_of_docs, raise_on_error=False, request_timeout=request_timeout, chunk_size=1000
|
|
67
67
|
):
|
|
68
68
|
for item in failed_response:
|
|
69
69
|
if item[1]["index"]["status"] != 201 or item[1]["index"]["_shards"]["failed"] > 0:
|
|
@@ -83,19 +83,13 @@ class ElasticSearch(ABC):
|
|
|
83
83
|
)
|
|
84
84
|
|
|
85
85
|
def post_list_of_docs(
|
|
86
|
-
self,
|
|
87
|
-
list_of_docs: list[dict],
|
|
88
|
-
request_timeout: int = 600,
|
|
89
|
-
quick: bool = False,
|
|
90
|
-
raise_on_error: bool = False
|
|
86
|
+
self, list_of_docs: list[dict], request_timeout: int = 600, quick: bool = False, raise_on_error: bool = False
|
|
91
87
|
) -> None:
|
|
92
88
|
self.logger.info("Start to report the documents to ELK")
|
|
93
89
|
|
|
94
90
|
if quick:
|
|
95
91
|
self._set_list_of_docs_quick(
|
|
96
|
-
list_of_docs=list_of_docs,
|
|
97
|
-
request_timeout=request_timeout,
|
|
98
|
-
raise_on_error=raise_on_error
|
|
92
|
+
list_of_docs=list_of_docs, request_timeout=request_timeout, raise_on_error=raise_on_error
|
|
99
93
|
)
|
|
100
94
|
else:
|
|
101
95
|
self._set_list_of_docs_safe(list_of_docs=list_of_docs)
|
|
@@ -113,7 +107,7 @@ class ElasticSearch(ABC):
|
|
|
113
107
|
doc.update(
|
|
114
108
|
{
|
|
115
109
|
"doc_id": timestamp.strftime("%Y%m%dT%H%M%S%f"),
|
|
116
|
-
"
|
|
110
|
+
"upload_timestamp": timestamp,
|
|
117
111
|
"date_and_time_str": date_and_time_str,
|
|
118
112
|
"date_str": date,
|
|
119
113
|
"time_str": time_part,
|
|
@@ -145,10 +139,16 @@ class ElasticSearch(ABC):
|
|
|
145
139
|
def _fill_list_of_docs(index_name: str, doc: dict[str, Any]) -> dict:
|
|
146
140
|
return {"_index": index_name, "_source": deepcopy(doc)}
|
|
147
141
|
|
|
148
|
-
def _prepare_documents_for_bulk(
|
|
142
|
+
def _prepare_documents_for_bulk(
|
|
143
|
+
self,
|
|
144
|
+
data: list[dict],
|
|
145
|
+
doc_index_name: str,
|
|
146
|
+
log_progress: bool = False,
|
|
147
|
+
) -> list[dict]:
|
|
149
148
|
list_of_docs = []
|
|
150
149
|
for index, row in enumerate(data):
|
|
151
|
-
|
|
150
|
+
if log_progress:
|
|
151
|
+
self.logger.debug(f"index: {index + 1}/{len(data)}")
|
|
152
152
|
doc = self._build_document(row=row)
|
|
153
153
|
list_of_docs.append(self._fill_list_of_docs(index_name=doc_index_name, doc=doc))
|
|
154
154
|
return list_of_docs
|
|
@@ -160,10 +160,12 @@ class ElasticSearch(ABC):
|
|
|
160
160
|
time_sleep: int = 60,
|
|
161
161
|
quick: bool = False,
|
|
162
162
|
raise_on_error: bool = False,
|
|
163
|
+
log_progress: bool = False,
|
|
163
164
|
) -> None:
|
|
164
165
|
for i in range(0, len(list_of_docs), chunk_size):
|
|
165
|
-
|
|
166
|
-
|
|
166
|
+
if log_progress:
|
|
167
|
+
self.logger.info(f"index: {i} - {i + chunk_size} / {len(list_of_docs)}")
|
|
168
|
+
chunk = list_of_docs[i : i + chunk_size]
|
|
167
169
|
self.post_list_of_docs(list_of_docs=chunk, quick=quick, raise_on_error=raise_on_error)
|
|
168
170
|
time.sleep(time_sleep)
|
|
169
171
|
|
|
@@ -175,10 +177,12 @@ class ElasticSearch(ABC):
|
|
|
175
177
|
time_sleep: int = 1,
|
|
176
178
|
quick: bool = True,
|
|
177
179
|
raise_on_error: bool = False,
|
|
180
|
+
log_progress: bool = False,
|
|
178
181
|
) -> None: # use as the main function to send data to the elastic search
|
|
179
182
|
list_of_docs = self._prepare_documents_for_bulk(
|
|
180
183
|
data=data,
|
|
181
184
|
doc_index_name=doc_index_name,
|
|
185
|
+
log_progress=log_progress,
|
|
182
186
|
)
|
|
183
187
|
|
|
184
188
|
self.post_list_of_docs_as_bulk_chunk(
|
|
@@ -187,6 +191,7 @@ class ElasticSearch(ABC):
|
|
|
187
191
|
time_sleep=time_sleep,
|
|
188
192
|
quick=quick,
|
|
189
193
|
raise_on_error=raise_on_error,
|
|
194
|
+
log_progress=log_progress,
|
|
190
195
|
)
|
|
191
196
|
|
|
192
197
|
def check_if_index_exists(self, index: str) -> bool:
|
|
@@ -213,11 +218,16 @@ class ElasticSearch(ABC):
|
|
|
213
218
|
except Exception as e:
|
|
214
219
|
raise Exception(f"Failed to get documents from index '{index}': {str(e)}") from e
|
|
215
220
|
|
|
216
|
-
def convert_dataframes_to_list_of_docs(
|
|
221
|
+
def convert_dataframes_to_list_of_docs(
|
|
222
|
+
self,
|
|
223
|
+
dataframe: pd.DataFrame,
|
|
224
|
+
log_progress: bool = False,
|
|
225
|
+
) -> list:
|
|
217
226
|
data = []
|
|
218
227
|
dataframe_values = dataframe.values.tolist()
|
|
219
228
|
for index, value in enumerate(dataframe_values):
|
|
220
|
-
|
|
229
|
+
if log_progress:
|
|
230
|
+
self.logger.debug(f"index: {index}/{len(dataframe_values)}")
|
|
221
231
|
data.append(value)
|
|
222
232
|
return data
|
|
223
233
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-databases
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.0
|
|
4
4
|
Summary: A Python library for managing and interacting with multiple databases seamlessly.
|
|
5
5
|
Author: Avi Zaguri
|
|
6
6
|
License: MIT
|
|
@@ -16,16 +16,12 @@ Classifier: Topic :: System :: Logging
|
|
|
16
16
|
Requires-Python: >=3.12
|
|
17
17
|
Description-Content-Type: text/markdown
|
|
18
18
|
License-File: LICENSE
|
|
19
|
-
Requires-Dist: custom-python-logger>=4.0.
|
|
19
|
+
Requires-Dist: custom-python-logger>=4.0.2
|
|
20
20
|
Requires-Dist: elasticsearch<9
|
|
21
21
|
Requires-Dist: pandas>=2.3.3
|
|
22
|
-
Requires-Dist: pre-commit>=4.5.0
|
|
23
22
|
Requires-Dist: psycopg2-binary>=2.9.11
|
|
24
|
-
Requires-Dist: pytest>=9.0.1
|
|
25
23
|
Requires-Dist: python-dotenv>=1.2.1
|
|
26
24
|
Requires-Dist: retrying>=1.4.2
|
|
27
|
-
Requires-Dist: setuptools>=80.9.0
|
|
28
|
-
Requires-Dist: wheel>=0.45.1
|
|
29
25
|
Dynamic: license-file
|
|
30
26
|
|
|
31
27
|

|
|
@@ -15,4 +15,5 @@ python_databases/elasticsearch_infrastructure/elasticsearch.py
|
|
|
15
15
|
python_databases/postgresql_infrastructure/__init__.py
|
|
16
16
|
python_databases/postgresql_infrastructure/consts.py
|
|
17
17
|
python_databases/postgresql_infrastructure/postgresql.py
|
|
18
|
+
tests/test_elasticsearch.py
|
|
18
19
|
tests/test_postgres.py
|
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
# test_elasticsearch.py
|
|
2
|
+
#
|
|
3
|
+
# Tests assert the *intended contract* of each unit, not merely what the current
|
|
4
|
+
# implementation happens to do. Where the code violates its contract the test is
|
|
5
|
+
# marked xfail(strict=True): it stays red-aware and will fail loudly if the bug
|
|
6
|
+
# is silently "fixed" or changes shape. See the module docstring of each xfail
|
|
7
|
+
# for the defect being tracked.
|
|
8
|
+
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from unittest.mock import MagicMock, call, patch
|
|
12
|
+
|
|
13
|
+
import pandas as pd
|
|
14
|
+
import pytest
|
|
15
|
+
from elasticsearch.helpers import BulkIndexError
|
|
16
|
+
|
|
17
|
+
from python_databases.elasticsearch_infrastructure import (
|
|
18
|
+
ElasticSearch,
|
|
19
|
+
ElasticSearchCloud,
|
|
20
|
+
ElasticSearchOnPrem,
|
|
21
|
+
UrlProtocol,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
MODULE = "python_databases.elasticsearch_infrastructure.elasticsearch"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _FakeElasticSearch(ElasticSearch):
|
|
28
|
+
"""Concrete subclass: no real connection, no 180s retry."""
|
|
29
|
+
|
|
30
|
+
def connect_to_elasticsearch(self) -> None:
|
|
31
|
+
self.elk_client = MagicMock()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _ok_item(status: int = 201, shard_failures: int = 0) -> tuple[bool, dict]:
|
|
35
|
+
"""A realistic (ok, info) pair as yielded by helpers.streaming_bulk for a success."""
|
|
36
|
+
return True, {
|
|
37
|
+
"index": {
|
|
38
|
+
"_index": "idx",
|
|
39
|
+
"status": status,
|
|
40
|
+
"_shards": {"total": 2, "successful": 2 - shard_failures, "failed": shard_failures},
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _failed_item(status: int = 400) -> tuple[bool, dict]:
|
|
46
|
+
"""A realistic failed (ok, info) pair -- note: no ``_shards`` key, like the real client."""
|
|
47
|
+
return False, {
|
|
48
|
+
"index": {
|
|
49
|
+
"_index": "idx",
|
|
50
|
+
"status": status,
|
|
51
|
+
"error": {"type": "mapper_parsing_exception", "reason": "boom"},
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@pytest.fixture(autouse=True)
|
|
57
|
+
def no_sleep() -> Iterator[MagicMock]:
|
|
58
|
+
with patch(f"{MODULE}.time.sleep") as mock_sleep:
|
|
59
|
+
yield mock_sleep
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@pytest.fixture
|
|
63
|
+
def es() -> _FakeElasticSearch:
|
|
64
|
+
obj = _FakeElasticSearch(
|
|
65
|
+
elk_hostname="localhost",
|
|
66
|
+
elasticsearch_port=9200,
|
|
67
|
+
kibana_port=5602,
|
|
68
|
+
protocol=UrlProtocol.HTTP,
|
|
69
|
+
username=None,
|
|
70
|
+
password=None,
|
|
71
|
+
)
|
|
72
|
+
obj.connect_to_elasticsearch()
|
|
73
|
+
obj.logger = MagicMock()
|
|
74
|
+
return obj
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _sent_docs(mock_helpers: MagicMock) -> list[dict]:
|
|
78
|
+
"""The list of bulk actions handed to streaming_bulk (2nd positional arg)."""
|
|
79
|
+
return mock_helpers.streaming_bulk.call_args[0][1]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# --------------------------------------------------------------------------- #
|
|
83
|
+
# URL construction
|
|
84
|
+
# --------------------------------------------------------------------------- #
|
|
85
|
+
def test_url_protocol_enum_maps_to_wire_schemes() -> None:
|
|
86
|
+
assert (UrlProtocol.HTTP.value, UrlProtocol.HTTPS.value) == ("http", "https"), "enum drifted"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_url_includes_port_when_given() -> None:
|
|
90
|
+
obj = _FakeElasticSearch("host", 9200, None, UrlProtocol.HTTPS, None, None)
|
|
91
|
+
assert obj.elasticsearch_url == "https://host:9200", f"Got {obj.elasticsearch_url}"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_url_omits_port_when_none() -> None:
|
|
95
|
+
obj = _FakeElasticSearch("host", None, None, UrlProtocol.HTTP, None, None)
|
|
96
|
+
assert obj.elasticsearch_url == "http://host", f"Got {obj.elasticsearch_url}"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# --------------------------------------------------------------------------- #
|
|
100
|
+
# Document enrichment contract (fill_elk_index_as_bulk -> prepared bulk actions)
|
|
101
|
+
# --------------------------------------------------------------------------- #
|
|
102
|
+
@patch(f"{MODULE}.helpers")
|
|
103
|
+
def test_each_row_becomes_one_bulk_action_targeting_the_index(
|
|
104
|
+
mock_helpers: MagicMock, es: _FakeElasticSearch # pylint: disable=redefined-outer-name
|
|
105
|
+
) -> None:
|
|
106
|
+
mock_helpers.streaming_bulk.side_effect = lambda *a, **kw: iter([_ok_item()])
|
|
107
|
+
es.fill_elk_index_as_bulk(data=[{"n": 1}, {"n": 2}, {"n": 3}], doc_index_name="my-index", chunk_size=100)
|
|
108
|
+
|
|
109
|
+
docs = _sent_docs(mock_helpers)
|
|
110
|
+
assert len(docs) == 3, f"expected 3 actions, got {len(docs)}"
|
|
111
|
+
assert [d["_index"] for d in docs] == ["my-index"] * 3, f"wrong index targets: {docs}"
|
|
112
|
+
assert [d["_source"]["n"] for d in docs] == [1, 2, 3], "row payload not preserved"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@patch(f"{MODULE}.helpers")
|
|
116
|
+
def test_source_preserves_original_fields_and_adds_string_variant_for_str_lists(
|
|
117
|
+
mock_helpers: MagicMock, es: _FakeElasticSearch # pylint: disable=redefined-outer-name
|
|
118
|
+
) -> None:
|
|
119
|
+
mock_helpers.streaming_bulk.side_effect = lambda *a, **kw: iter([_ok_item()])
|
|
120
|
+
es.fill_elk_index_as_bulk(
|
|
121
|
+
data=[{"name": "svc", "tags": ["a", "b"], "count": 7}], doc_index_name="idx", chunk_size=100
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
src = _sent_docs(mock_helpers)[0]["_source"]
|
|
125
|
+
assert src["name"] == "svc", f"Got {src.get('name')}"
|
|
126
|
+
assert src["count"] == 7, f"Got {src.get('count')}"
|
|
127
|
+
assert src["tags"] == ["a", "b"], f"Got {src.get('tags')}"
|
|
128
|
+
assert src["tags_str"] == "a, b", f"str list not joined: {src.get('tags_str')!r}"
|
|
129
|
+
assert "name_str" not in src, "scalar field must not gain a _str variant"
|
|
130
|
+
assert "count_str" not in src, "non-str list/scalar must not gain a _str variant"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@patch(f"{MODULE}.helpers")
|
|
134
|
+
def test_doc_id_is_derived_from_the_timestamp(
|
|
135
|
+
mock_helpers: MagicMock, es: _FakeElasticSearch # pylint: disable=redefined-outer-name
|
|
136
|
+
) -> None:
|
|
137
|
+
mock_helpers.streaming_bulk.side_effect = lambda *a, **kw: iter([_ok_item()])
|
|
138
|
+
es.fill_elk_index_as_bulk(data=[{"n": 1}], doc_index_name="idx", chunk_size=100)
|
|
139
|
+
|
|
140
|
+
src = _sent_docs(mock_helpers)[0]["_source"]
|
|
141
|
+
ts = src["upload_timestamp"]
|
|
142
|
+
assert isinstance(ts, datetime), f"timestamp must be a datetime, got {type(ts)}"
|
|
143
|
+
assert src["doc_id"] == ts.strftime("%Y%m%dT%H%M%S%f"), "doc_id/timestamp derivation broken"
|
|
144
|
+
assert src["date_and_time_str"].startswith(src["date_str"]), "date_str is not the date half"
|
|
145
|
+
assert src["time_str"] in src["date_and_time_str"], "time_str is not the time half"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@patch(f"{MODULE}.helpers")
|
|
149
|
+
def test_bulk_actions_do_not_share_mutable_source_state(
|
|
150
|
+
mock_helpers: MagicMock, es: _FakeElasticSearch # pylint: disable=redefined-outer-name
|
|
151
|
+
) -> None:
|
|
152
|
+
mock_helpers.streaming_bulk.side_effect = lambda *a, **kw: iter([_ok_item()])
|
|
153
|
+
es.fill_elk_index_as_bulk(data=[{"n": 1}, {"n": 2}], doc_index_name="idx", chunk_size=100)
|
|
154
|
+
|
|
155
|
+
docs = _sent_docs(mock_helpers)
|
|
156
|
+
docs[0]["_source"]["injected"] = True
|
|
157
|
+
assert "injected" not in docs[1]["_source"], "bulk actions alias the same _source dict"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# --------------------------------------------------------------------------- #
|
|
161
|
+
# log_progress (the HEAD commit under test)
|
|
162
|
+
# --------------------------------------------------------------------------- #
|
|
163
|
+
@patch(f"{MODULE}.helpers")
|
|
164
|
+
def test_log_progress_false_emits_no_progress_logging(
|
|
165
|
+
mock_helpers: MagicMock, es: _FakeElasticSearch # pylint: disable=redefined-outer-name
|
|
166
|
+
) -> None:
|
|
167
|
+
mock_helpers.streaming_bulk.side_effect = lambda *a, **kw: iter([_ok_item()])
|
|
168
|
+
es.fill_elk_index_as_bulk(data=[{"n": 1}, {"n": 2}], doc_index_name="idx", chunk_size=1)
|
|
169
|
+
es.logger.debug.assert_not_called()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@patch(f"{MODULE}.helpers")
|
|
173
|
+
def test_log_progress_true_logs_each_row_at_debug_and_each_chunk_at_info(
|
|
174
|
+
mock_helpers: MagicMock, es: _FakeElasticSearch # pylint: disable=redefined-outer-name
|
|
175
|
+
) -> None:
|
|
176
|
+
mock_helpers.streaming_bulk.side_effect = lambda *a, **kw: iter([_ok_item()])
|
|
177
|
+
es.fill_elk_index_as_bulk(data=[{"n": i} for i in range(4)], doc_index_name="idx", chunk_size=2, log_progress=True)
|
|
178
|
+
# 4 rows -> 4 per-row debug lines (from _prepare_documents_for_bulk).
|
|
179
|
+
assert es.logger.debug.call_count == 4, f"expected one debug per row, got {es.logger.debug.call_count}"
|
|
180
|
+
# 2 chunks -> at least 2 per-chunk info lines (from post_list_of_docs_as_bulk_chunk).
|
|
181
|
+
assert es.logger.info.call_count >= 2, f"chunk progress not logged at info: {es.logger.info.call_count}"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# --------------------------------------------------------------------------- #
|
|
185
|
+
# _add_list_values_as_str behaviour boundaries (pinned via public surface)
|
|
186
|
+
# --------------------------------------------------------------------------- #
|
|
187
|
+
@patch(f"{MODULE}.helpers")
|
|
188
|
+
@pytest.mark.parametrize(
|
|
189
|
+
"value,expect_str_key",
|
|
190
|
+
[
|
|
191
|
+
(["a", "b"], True),
|
|
192
|
+
([], True), # empty list: all() is vacuously true
|
|
193
|
+
([1, 2], False), # non-str list -> no string variant
|
|
194
|
+
(["a", 1], False), # mixed list -> no string variant
|
|
195
|
+
("plain", False), # scalar -> no string variant
|
|
196
|
+
],
|
|
197
|
+
)
|
|
198
|
+
def test_string_variant_only_for_all_string_lists(
|
|
199
|
+
mock_helpers: MagicMock,
|
|
200
|
+
es: _FakeElasticSearch, # pylint: disable=redefined-outer-name
|
|
201
|
+
value: object,
|
|
202
|
+
expect_str_key: bool,
|
|
203
|
+
) -> None:
|
|
204
|
+
mock_helpers.streaming_bulk.side_effect = lambda *a, **kw: iter([_ok_item()])
|
|
205
|
+
es.fill_elk_index_as_bulk(data=[{"f": value}], doc_index_name="idx", chunk_size=100)
|
|
206
|
+
|
|
207
|
+
src = _sent_docs(mock_helpers)[0]["_source"]
|
|
208
|
+
assert ("f_str" in src) is expect_str_key, f"f_str presence wrong for {value!r}: {src.get('f_str')!r}"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# --------------------------------------------------------------------------- #
|
|
212
|
+
# Quick path: streaming_bulk result inspection
|
|
213
|
+
# --------------------------------------------------------------------------- #
|
|
214
|
+
@patch(f"{MODULE}.helpers")
|
|
215
|
+
def test_quick_all_success_never_raises_even_with_raise_on_error(
|
|
216
|
+
mock_helpers: MagicMock, es: _FakeElasticSearch # pylint: disable=redefined-outer-name
|
|
217
|
+
) -> None:
|
|
218
|
+
mock_helpers.streaming_bulk.return_value = iter([_ok_item(), _ok_item()])
|
|
219
|
+
es.post_list_of_docs(list_of_docs=[{}, {}], quick=True, raise_on_error=True)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@patch(f"{MODULE}.helpers")
|
|
223
|
+
def test_quick_drives_streaming_bulk_in_result_inspection_mode(
|
|
224
|
+
mock_helpers: MagicMock, es: _FakeElasticSearch # pylint: disable=redefined-outer-name
|
|
225
|
+
) -> None:
|
|
226
|
+
# These kwargs are load-bearing: raise_on_error=False is what keeps the
|
|
227
|
+
# failure-inspection loop reachable instead of letting helpers raise.
|
|
228
|
+
mock_helpers.streaming_bulk.return_value = iter([_ok_item()])
|
|
229
|
+
es.post_list_of_docs(list_of_docs=[{}], quick=True, request_timeout=42)
|
|
230
|
+
|
|
231
|
+
kwargs = mock_helpers.streaming_bulk.call_args.kwargs
|
|
232
|
+
assert kwargs["raise_on_error"] is False, "must inspect results, not let helpers raise"
|
|
233
|
+
assert kwargs["request_timeout"] == 42, f"request_timeout not forwarded: {kwargs.get('request_timeout')}"
|
|
234
|
+
assert kwargs["chunk_size"] == 1000, f"Got {kwargs.get('chunk_size')}"
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@patch(f"{MODULE}.helpers")
|
|
238
|
+
def test_quick_raises_with_exact_failure_count_when_raise_on_error(
|
|
239
|
+
mock_helpers: MagicMock, es: _FakeElasticSearch # pylint: disable=redefined-outer-name
|
|
240
|
+
) -> None:
|
|
241
|
+
mock_helpers.streaming_bulk.return_value = iter([_ok_item(), _failed_item(), _failed_item(503)])
|
|
242
|
+
with pytest.raises(Exception, match=r"Failed to report 2 documents"):
|
|
243
|
+
es.post_list_of_docs(list_of_docs=[{}, {}, {}], quick=True, raise_on_error=True)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@patch(f"{MODULE}.helpers")
|
|
247
|
+
def test_quick_does_not_raise_on_failures_when_raise_on_error_false(
|
|
248
|
+
mock_helpers: MagicMock, es: _FakeElasticSearch # pylint: disable=redefined-outer-name
|
|
249
|
+
) -> None:
|
|
250
|
+
mock_helpers.streaming_bulk.return_value = iter([_failed_item(), _failed_item()])
|
|
251
|
+
es.post_list_of_docs(list_of_docs=[{}, {}], quick=True, raise_on_error=False)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@patch(f"{MODULE}.helpers")
|
|
255
|
+
def test_quick_treats_2xx_with_shard_failures_as_a_failed_document(
|
|
256
|
+
mock_helpers: MagicMock, es: _FakeElasticSearch # pylint: disable=redefined-outer-name
|
|
257
|
+
) -> None:
|
|
258
|
+
# ok=True, HTTP 201, but a replica shard rejected the write -> must count as failure.
|
|
259
|
+
mock_helpers.streaming_bulk.return_value = iter([_ok_item(status=201, shard_failures=1)])
|
|
260
|
+
with pytest.raises(Exception, match=r"Failed to report 1 documents"):
|
|
261
|
+
es.post_list_of_docs(list_of_docs=[{}], quick=True, raise_on_error=True)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@patch(f"{MODULE}.helpers")
|
|
265
|
+
def test_quick_handles_realistic_failure_item_without_shards_key(
|
|
266
|
+
mock_helpers: MagicMock, es: _FakeElasticSearch # pylint: disable=redefined-outer-name
|
|
267
|
+
) -> None:
|
|
268
|
+
# Regression guard: a real failed item has no "_shards"; inspection must not KeyError.
|
|
269
|
+
mock_helpers.streaming_bulk.return_value = iter([_failed_item()])
|
|
270
|
+
es.post_list_of_docs(list_of_docs=[{}], quick=True, raise_on_error=False)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# --------------------------------------------------------------------------- #
|
|
274
|
+
# Safe path: helpers.bulk
|
|
275
|
+
# --------------------------------------------------------------------------- #
|
|
276
|
+
@patch(f"{MODULE}.helpers")
|
|
277
|
+
def test_safe_logs_success_and_no_error_when_all_reported(
|
|
278
|
+
mock_helpers: MagicMock, es: _FakeElasticSearch # pylint: disable=redefined-outer-name
|
|
279
|
+
) -> None:
|
|
280
|
+
mock_helpers.bulk.return_value = (2, [])
|
|
281
|
+
es.post_list_of_docs(list_of_docs=[{}, {}], quick=False)
|
|
282
|
+
es.logger.info.assert_called()
|
|
283
|
+
es.logger.error.assert_not_called()
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@patch(f"{MODULE}.helpers")
|
|
287
|
+
def test_safe_propagates_bulk_index_error(
|
|
288
|
+
mock_helpers: MagicMock, es: _FakeElasticSearch # pylint: disable=redefined-outer-name
|
|
289
|
+
) -> None:
|
|
290
|
+
# helpers.bulk raises BulkIndexError on failure by default; the safe path has no
|
|
291
|
+
# handler, so the caller sees it. (raise_on_error is not consulted here at all.)
|
|
292
|
+
mock_helpers.bulk.side_effect = BulkIndexError("2 document(s) failed to index", [{"x": 1}])
|
|
293
|
+
with pytest.raises(BulkIndexError):
|
|
294
|
+
es.post_list_of_docs(list_of_docs=[{}, {}], quick=False, raise_on_error=False)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# --------------------------------------------------------------------------- #
|
|
298
|
+
# Dispatch
|
|
299
|
+
# --------------------------------------------------------------------------- #
|
|
300
|
+
@patch(f"{MODULE}.helpers")
|
|
301
|
+
def test_quick_flag_selects_streaming_bulk(
|
|
302
|
+
mock_helpers: MagicMock, es: _FakeElasticSearch # pylint: disable=redefined-outer-name
|
|
303
|
+
) -> None:
|
|
304
|
+
mock_helpers.streaming_bulk.return_value = iter([_ok_item()])
|
|
305
|
+
es.post_list_of_docs(list_of_docs=[{}], quick=True)
|
|
306
|
+
mock_helpers.streaming_bulk.assert_called_once()
|
|
307
|
+
mock_helpers.bulk.assert_not_called()
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@patch(f"{MODULE}.helpers")
|
|
311
|
+
def test_default_path_selects_bulk(
|
|
312
|
+
mock_helpers: MagicMock, es: _FakeElasticSearch # pylint: disable=redefined-outer-name
|
|
313
|
+
) -> None:
|
|
314
|
+
mock_helpers.bulk.return_value = (1, [])
|
|
315
|
+
es.post_list_of_docs(list_of_docs=[{}], quick=False)
|
|
316
|
+
mock_helpers.bulk.assert_called_once()
|
|
317
|
+
mock_helpers.streaming_bulk.assert_not_called()
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# --------------------------------------------------------------------------- #
|
|
321
|
+
# Chunking
|
|
322
|
+
# --------------------------------------------------------------------------- #
|
|
323
|
+
def test_chunking_posts_every_document_exactly_once_in_order(
|
|
324
|
+
es: _FakeElasticSearch, # pylint: disable=redefined-outer-name
|
|
325
|
+
) -> None:
|
|
326
|
+
docs = [{"n": i} for i in range(5)]
|
|
327
|
+
with patch.object(es, "post_list_of_docs") as mock_post:
|
|
328
|
+
es.post_list_of_docs_as_bulk_chunk(list_of_docs=docs, chunk_size=2)
|
|
329
|
+
|
|
330
|
+
posted = [c.kwargs["list_of_docs"] for c in mock_post.call_args_list]
|
|
331
|
+
assert posted == [docs[0:2], docs[2:4], docs[4:5]], f"chunk boundaries wrong: {posted}"
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def test_chunking_forwards_quick_and_raise_on_error(
|
|
335
|
+
es: _FakeElasticSearch, # pylint: disable=redefined-outer-name
|
|
336
|
+
) -> None:
|
|
337
|
+
with patch.object(es, "post_list_of_docs") as mock_post:
|
|
338
|
+
es.post_list_of_docs_as_bulk_chunk(list_of_docs=[{}, {}], chunk_size=1, quick=True, raise_on_error=True)
|
|
339
|
+
for c in mock_post.call_args_list:
|
|
340
|
+
assert c.kwargs["quick"] is True and c.kwargs["raise_on_error"] is True, f"flags not forwarded: {c}"
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@pytest.mark.xfail(
|
|
344
|
+
strict=True,
|
|
345
|
+
reason="BUG: time.sleep is dedented outside the chunk loop -> an empty input list still "
|
|
346
|
+
"sleeps for time_sleep (60s by default) despite posting nothing.",
|
|
347
|
+
)
|
|
348
|
+
def test_chunking_empty_input_posts_nothing_and_does_not_sleep(
|
|
349
|
+
es: _FakeElasticSearch, no_sleep: MagicMock # pylint: disable=redefined-outer-name
|
|
350
|
+
) -> None:
|
|
351
|
+
with patch.object(es, "post_list_of_docs") as mock_post:
|
|
352
|
+
es.post_list_of_docs_as_bulk_chunk(list_of_docs=[], chunk_size=2)
|
|
353
|
+
mock_post.assert_not_called()
|
|
354
|
+
no_sleep.assert_not_called()
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@pytest.mark.xfail(
|
|
358
|
+
strict=True,
|
|
359
|
+
reason="BUG: time.sleep is dedented outside the chunk loop -> it fires once after all "
|
|
360
|
+
"chunks instead of pausing between them, so time_sleep cannot rate-limit chunked writes.",
|
|
361
|
+
)
|
|
362
|
+
def test_chunking_sleeps_between_chunks(
|
|
363
|
+
es: _FakeElasticSearch, no_sleep: MagicMock # pylint: disable=redefined-outer-name
|
|
364
|
+
) -> None:
|
|
365
|
+
with patch.object(es, "post_list_of_docs"):
|
|
366
|
+
es.post_list_of_docs_as_bulk_chunk(list_of_docs=[{}] * 6, chunk_size=2, time_sleep=30)
|
|
367
|
+
assert no_sleep.call_args_list == [
|
|
368
|
+
call(30),
|
|
369
|
+
call(30),
|
|
370
|
+
], f"expected a sleep after each non-final chunk, got {no_sleep.call_args_list}"
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
# --------------------------------------------------------------------------- #
|
|
374
|
+
# Index management
|
|
375
|
+
# --------------------------------------------------------------------------- #
|
|
376
|
+
@pytest.mark.parametrize("present", [True, False])
|
|
377
|
+
def test_check_if_index_exists_mirrors_client(
|
|
378
|
+
es: _FakeElasticSearch, present: bool # pylint: disable=redefined-outer-name
|
|
379
|
+
) -> None:
|
|
380
|
+
es.elk_client.indices.exists.return_value = present
|
|
381
|
+
assert es.check_if_index_exists(index="idx") is present, f"expected {present}"
|
|
382
|
+
es.elk_client.indices.exists.assert_called_once_with(index="idx")
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def test_delete_index_delegates_to_client(es: _FakeElasticSearch) -> None: # pylint: disable=redefined-outer-name
|
|
386
|
+
es.delete_index(index="idx")
|
|
387
|
+
es.elk_client.indices.delete.assert_called_once_with(index="idx")
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def test_check_and_delete_removes_index_only_when_present(
|
|
391
|
+
es: _FakeElasticSearch, # pylint: disable=redefined-outer-name
|
|
392
|
+
) -> None:
|
|
393
|
+
es.elk_client.indices.exists.return_value = True
|
|
394
|
+
assert es.check_if_index_exists_and_delete_if_exists(index="idx") is True, "expected True"
|
|
395
|
+
es.elk_client.indices.delete.assert_called_once_with(index="idx")
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def test_check_and_delete_is_a_noop_when_absent(es: _FakeElasticSearch) -> None: # pylint: disable=redefined-outer-name
|
|
399
|
+
es.elk_client.indices.exists.return_value = False
|
|
400
|
+
assert es.check_if_index_exists_and_delete_if_exists(index="idx") is False, "expected False"
|
|
401
|
+
es.elk_client.indices.delete.assert_not_called()
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
# --------------------------------------------------------------------------- #
|
|
405
|
+
# get_documents
|
|
406
|
+
# --------------------------------------------------------------------------- #
|
|
407
|
+
def test_get_documents_returns_sources_in_order(es: _FakeElasticSearch) -> None: # pylint: disable=redefined-outer-name
|
|
408
|
+
es.elk_client.search.return_value = {"hits": {"hits": [{"_source": {"a": 1}}, {"_source": {"a": 2}}]}}
|
|
409
|
+
query = {"query": {"match_all": {}}}
|
|
410
|
+
assert es.get_documents(index="idx", query=query) == [{"a": 1}, {"a": 2}], "sources not unwrapped"
|
|
411
|
+
es.elk_client.search.assert_called_once_with(index="idx", body=query)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def test_get_documents_wraps_and_chains_client_error( # pylint: disable=redefined-outer-name
|
|
415
|
+
es: _FakeElasticSearch,
|
|
416
|
+
) -> None:
|
|
417
|
+
original = ValueError("kaboom")
|
|
418
|
+
es.elk_client.search.side_effect = original
|
|
419
|
+
with pytest.raises(Exception, match=r"Failed to get documents from index 'idx'") as exc_info:
|
|
420
|
+
es.get_documents(index="idx", query={})
|
|
421
|
+
assert exc_info.value.__cause__ is original, "original error not chained"
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# --------------------------------------------------------------------------- #
|
|
425
|
+
# convert_dataframes_to_list_of_docs
|
|
426
|
+
# --------------------------------------------------------------------------- #
|
|
427
|
+
def test_convert_dataframes_returns_one_entry_per_row( # pylint: disable=redefined-outer-name
|
|
428
|
+
es: _FakeElasticSearch,
|
|
429
|
+
) -> None:
|
|
430
|
+
df = pd.DataFrame({"a": [1, 2], "b": [3, 4]})
|
|
431
|
+
result = es.convert_dataframes_to_list_of_docs(dataframe=df)
|
|
432
|
+
assert len(result) == 2, f"expected one entry per row, got {result}"
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
@pytest.mark.xfail(
|
|
436
|
+
strict=True,
|
|
437
|
+
reason="BUG: convert_dataframes_to_list_of_docs returns bare value lists (df.values.tolist()), "
|
|
438
|
+
"dropping column names. Its output is not consumable by fill_elk_index_as_bulk / _build_document, "
|
|
439
|
+
"which iterate row.items().",
|
|
440
|
+
)
|
|
441
|
+
def test_convert_dataframes_output_is_keyed_by_column( # pylint: disable=redefined-outer-name
|
|
442
|
+
es: _FakeElasticSearch,
|
|
443
|
+
) -> None:
|
|
444
|
+
df = pd.DataFrame({"a": [1, 2], "b": [3, 4]})
|
|
445
|
+
result = es.convert_dataframes_to_list_of_docs(dataframe=df)
|
|
446
|
+
assert result == [{"a": 1, "b": 3}, {"a": 2, "b": 4}], f"column names lost: {result}"
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
# --------------------------------------------------------------------------- #
|
|
450
|
+
# Concrete connect implementations
|
|
451
|
+
# --------------------------------------------------------------------------- #
|
|
452
|
+
@patch(f"{MODULE}.Elasticsearch")
|
|
453
|
+
def test_onprem_without_auth_connects_to_ported_url(mock_es: MagicMock) -> None:
|
|
454
|
+
mock_es.return_value.ping.return_value = True
|
|
455
|
+
client = ElasticSearchOnPrem(elk_hostname="host")
|
|
456
|
+
mock_es.assert_called_once_with(hosts=["https://host:9200"])
|
|
457
|
+
mock_es.return_value.ping.assert_called_once()
|
|
458
|
+
assert client.elk_client is mock_es.return_value, "client not stored"
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
@patch(f"{MODULE}.Elasticsearch")
|
|
462
|
+
def test_onprem_with_auth_sends_credentials_and_disables_cert_check(mock_es: MagicMock) -> None:
|
|
463
|
+
mock_es.return_value.ping.return_value = True
|
|
464
|
+
ElasticSearchOnPrem(elk_hostname="host", username="u", password="p")
|
|
465
|
+
mock_es.assert_called_once_with(hosts=["https://host:9200"], http_auth=("u", "p"), verify_certs=False)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
@patch("retrying.time.sleep")
|
|
469
|
+
@patch(f"{MODULE}.Elasticsearch")
|
|
470
|
+
def test_onprem_raises_identifiable_error_when_ping_fails(mock_es: MagicMock, _sleep: MagicMock) -> None:
|
|
471
|
+
mock_es.return_value.ping.return_value = False
|
|
472
|
+
with pytest.raises(Exception, match=r"Failed to connect to Elasticsearch on-prem"):
|
|
473
|
+
ElasticSearchOnPrem(elk_hostname="host")
|
|
474
|
+
assert mock_es.return_value.ping.call_count == 3, "retry did not run the configured 3 attempts"
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
@patch(f"{MODULE}.Elasticsearch")
|
|
478
|
+
def test_cloud_connects_via_cloud_id(mock_es: MagicMock) -> None:
|
|
479
|
+
mock_es.return_value.ping.return_value = True
|
|
480
|
+
ElasticSearchCloud(elk_hostname="deployment:abc123", username="u", password="p")
|
|
481
|
+
mock_es.assert_called_once_with(cloud_id="deployment:abc123", http_auth=("u", "p"))
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
@patch("retrying.time.sleep")
|
|
485
|
+
@patch(f"{MODULE}.Elasticsearch")
|
|
486
|
+
def test_cloud_raises_identifiable_error_when_ping_fails(mock_es: MagicMock, _sleep: MagicMock) -> None:
|
|
487
|
+
mock_es.return_value.ping.return_value = False
|
|
488
|
+
with pytest.raises(Exception, match=r"Failed to connect to Elasticsearch cloud"):
|
|
489
|
+
ElasticSearchCloud(elk_hostname="deployment:abc123", username="u", password="p")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_databases-1.2.1 → python_databases-2.0.0}/python_databases.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|