python-databases 1.2.1__tar.gz → 1.2.2__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.
Files changed (22) hide show
  1. {python_databases-1.2.1/python_databases.egg-info → python_databases-1.2.2}/PKG-INFO +1 -5
  2. {python_databases-1.2.1 → python_databases-1.2.2}/pyproject.toml +9 -5
  3. {python_databases-1.2.1 → python_databases-1.2.2}/python_databases/elasticsearch_infrastructure/consts.py +1 -1
  4. {python_databases-1.2.1 → python_databases-1.2.2}/python_databases/elasticsearch_infrastructure/elasticsearch.py +25 -15
  5. python_databases-1.2.2/python_databases/postgresql_infrastructure/consts.py +3 -0
  6. {python_databases-1.2.1 → python_databases-1.2.2/python_databases.egg-info}/PKG-INFO +1 -5
  7. {python_databases-1.2.1 → python_databases-1.2.2}/python_databases.egg-info/SOURCES.txt +1 -0
  8. {python_databases-1.2.1 → python_databases-1.2.2}/python_databases.egg-info/requires.txt +0 -4
  9. python_databases-1.2.2/tests/test_elasticsearch.py +489 -0
  10. python_databases-1.2.1/python_databases/postgresql_infrastructure/consts.py +0 -3
  11. {python_databases-1.2.1 → python_databases-1.2.2}/LICENSE +0 -0
  12. {python_databases-1.2.1 → python_databases-1.2.2}/MANIFEST.in +0 -0
  13. {python_databases-1.2.1 → python_databases-1.2.2}/README.md +0 -0
  14. {python_databases-1.2.1 → python_databases-1.2.2}/python_databases/__init__.py +0 -0
  15. {python_databases-1.2.1 → python_databases-1.2.2}/python_databases/consts.py +0 -0
  16. {python_databases-1.2.1 → python_databases-1.2.2}/python_databases/elasticsearch_infrastructure/__init__.py +0 -0
  17. {python_databases-1.2.1 → python_databases-1.2.2}/python_databases/postgresql_infrastructure/__init__.py +0 -0
  18. {python_databases-1.2.1 → python_databases-1.2.2}/python_databases/postgresql_infrastructure/postgresql.py +0 -0
  19. {python_databases-1.2.1 → python_databases-1.2.2}/python_databases.egg-info/dependency_links.txt +0 -0
  20. {python_databases-1.2.1 → python_databases-1.2.2}/python_databases.egg-info/top_level.txt +0 -0
  21. {python_databases-1.2.1 → python_databases-1.2.2}/setup.cfg +0 -0
  22. {python_databases-1.2.1 → python_databases-1.2.2}/tests/test_postgres.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-databases
3
- Version: 1.2.1
3
+ Version: 1.2.2
4
4
  Summary: A Python library for managing and interacting with multiple databases seamlessly.
5
5
  Author: Avi Zaguri
6
6
  License: MIT
@@ -19,13 +19,9 @@ License-File: LICENSE
19
19
  Requires-Dist: custom-python-logger>=4.0.0
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
  ![PyPI version](https://img.shields.io/pypi/v/python-databases)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-databases"
7
- version = "1.2.1"
7
+ version = "1.2.2"
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"
@@ -24,13 +24,17 @@ dependencies = [
24
24
  "custom-python-logger>=4.0.0",
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
- "setuptools>=80.9.0",
33
- "wheel>=0.45.1",
30
+ ]
31
+
32
+ [dependency-groups]
33
+ dev = [
34
+ "pre-commit>=4.6.2",
35
+ "pytest>=9.1.1",
36
+ "setuptools>=84.0.0",
37
+ "wheel>=0.48.0",
34
38
  ]
35
39
 
36
40
  [project.urls]
@@ -1,3 +1,3 @@
1
1
  from python_databases.consts import LOGGER_NAME
2
2
 
3
- ELK_LOGGER_NAME = f'{LOGGER_NAME}.elasticsearch'
3
+ ELK_LOGGER_NAME = f"{LOGGER_NAME}.elasticsearch"
@@ -63,7 +63,7 @@ class ElasticSearch(ABC):
63
63
  ) -> None:
64
64
  failed_response_list = []
65
65
  if failed_response := helpers.streaming_bulk(
66
- self.elk_client, list_of_docs, raise_on_error=False, request_timeout=request_timeout, chunk_size=1000
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)
@@ -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(self, data: list[dict], doc_index_name: str) -> list[dict]:
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
- self.logger.debug(f"index: {index + 1}/{len(data)}")
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
- self.logger.info(f"index: {i} - {i + chunk_size} / {len(list_of_docs)}")
166
- chunk = list_of_docs[i: i + chunk_size]
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(self, dataframe: pd.DataFrame) -> list:
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
- self.logger.debug(f"index: {index}/{len(dataframe_values)}")
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
 
@@ -0,0 +1,3 @@
1
+ from python_databases.consts import LOGGER_NAME
2
+
3
+ POSTGRESQL_LOGGER_NAME = f"{LOGGER_NAME}.postgresql"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-databases
3
- Version: 1.2.1
3
+ Version: 1.2.2
4
4
  Summary: A Python library for managing and interacting with multiple databases seamlessly.
5
5
  Author: Avi Zaguri
6
6
  License: MIT
@@ -19,13 +19,9 @@ License-File: LICENSE
19
19
  Requires-Dist: custom-python-logger>=4.0.0
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
  ![PyPI version](https://img.shields.io/pypi/v/python-databases)
@@ -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
@@ -1,10 +1,6 @@
1
1
  custom-python-logger>=4.0.0
2
2
  elasticsearch<9
3
3
  pandas>=2.3.3
4
- pre-commit>=4.5.0
5
4
  psycopg2-binary>=2.9.11
6
- pytest>=9.0.1
7
5
  python-dotenv>=1.2.1
8
6
  retrying>=1.4.2
9
- setuptools>=80.9.0
10
- wheel>=0.45.1
@@ -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["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")
@@ -1,3 +0,0 @@
1
- from python_databases.consts import LOGGER_NAME
2
-
3
- POSTGRESQL_LOGGER_NAME = f'{LOGGER_NAME}.postgresql'