pyapiary 2.2.0__tar.gz → 2.3.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.
Files changed (67) hide show
  1. {pyapiary-2.2.0 → pyapiary-2.3.0}/PKG-INFO +2 -1
  2. {pyapiary-2.2.0 → pyapiary-2.3.0}/pyproject.toml +2 -1
  3. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/dbms_connectors/postgres.py +10 -3
  4. pyapiary-2.3.0/src/pyapiary/dbms_connectors/trino.py +32 -0
  5. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/test_postgres/test_unit_postgres.py +73 -43
  6. pyapiary-2.3.0/src/pyapiary/tests/test_trino/test_unit_trino.py +200 -0
  7. pyapiary-2.2.0/src/pyapiary/dbms_connectors/trino.py +0 -0
  8. {pyapiary-2.2.0 → pyapiary-2.3.0}/README.md +0 -0
  9. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/__init__.py +0 -0
  10. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/api_connectors/__init__.py +0 -0
  11. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/api_connectors/broker.py +0 -0
  12. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/api_connectors/domaintools.py +0 -0
  13. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/api_connectors/flashpoint.py +0 -0
  14. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/api_connectors/generic.py +0 -0
  15. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/api_connectors/ipqs.py +0 -0
  16. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/api_connectors/spycloud.py +0 -0
  17. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/api_connectors/twilio.py +0 -0
  18. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/api_connectors/urlscan.py +0 -0
  19. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/dbms_connectors/__init__.py +0 -0
  20. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/dbms_connectors/elasticsearch.py +0 -0
  21. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/dbms_connectors/mongo.py +0 -0
  22. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/dbms_connectors/mongo_async.py +0 -0
  23. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/dbms_connectors/odbc.py +0 -0
  24. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/dbms_connectors/splunk.py +0 -0
  25. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/helpers.py +0 -0
  26. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/__init__.py +0 -0
  27. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/conftest.py +0 -0
  28. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/test_broker/test_integration_broker.py +0 -0
  29. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/test_broker/test_unit_asyncbroker.py +0 -0
  30. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/test_broker/test_unit_broker.py +0 -0
  31. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/test_domaintools/cassettes/.gitkeep +0 -0
  32. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/test_domaintools/cassettes/test_domaintools_iris_investigate_vcr.yaml +0 -0
  33. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/test_domaintools/cassettes/test_domaintools_parsed_whois_vcr.yaml +0 -0
  34. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/test_domaintools/test_integration_domaintools.py +0 -0
  35. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/test_domaintools/test_unit_async_domaintools.py +0 -0
  36. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/test_domaintools/test_unit_domaintools.py +0 -0
  37. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/test_elasticsearch/test_unit_elasticsearch.py +0 -0
  38. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/test_flashpoint/cassettes/test_flashpoint_search_fraud_vcr.yaml +0 -0
  39. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/test_flashpoint/test_integration_flashpoint.py +0 -0
  40. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/test_flashpoint/test_unit_async_flashpoint.py +0 -0
  41. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/test_flashpoint/test_unit_flashpoint.py +0 -0
  42. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/test_generic/cassettes/test_generic_get_github_api.yaml +0 -0
  43. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/test_generic/test_integration_generic_connector.py +0 -0
  44. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/test_generic/test_unit_async_generic_connector.py +0 -0
  45. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/test_generic/test_unit_generic_connector.py +0 -0
  46. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/test_ipqs/__init__.py +0 -0
  47. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/test_ipqs/cassettes/test_ipqs_malicious_url_vcr.yaml +0 -0
  48. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/test_ipqs/cassettes/test_ipqs_phone_validation_vcr.yaml +0 -0
  49. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/test_ipqs/test_integration_ipqs.py +0 -0
  50. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/test_ipqs/test_unit_async_ipqs.py +0 -0
  51. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/test_ipqs/test_unit_ipqs.py +0 -0
  52. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/test_mongodb/test_unit_async_mongo.py +0 -0
  53. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/test_mongodb/test_unit_mongo.py +0 -0
  54. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/test_odbc/test_unit_odbc.py +0 -0
  55. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/test_splunk/test_unit_splunk.py +0 -0
  56. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/test_spycloud/cassettes/test_spycloud_ato_search_vcr.yaml +0 -0
  57. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/test_spycloud/test_integration_spycloud.py +0 -0
  58. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/test_spycloud/test_unit_async_spycloud.py +0 -0
  59. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/test_spycloud/test_unit_spycloud.py +0 -0
  60. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/test_twilio/cassettes/test_lookup_phone_vcr.yaml +0 -0
  61. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/test_twilio/test_integration_twilio.py +0 -0
  62. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/test_twilio/test_unit_async_twilio.py +0 -0
  63. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/test_twilio/test_unit_twilio.py +0 -0
  64. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/test_urlscan/cassettes/test_urlscan_results_vcr.yaml +0 -0
  65. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/test_urlscan/test_integration_urlscan.py +0 -0
  66. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/test_urlscan/test_unit_async_urlscan.py +0 -0
  67. {pyapiary-2.2.0 → pyapiary-2.3.0}/src/pyapiary/tests/test_urlscan/test_unit_urlscan.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyapiary
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: A simple, lightweight set of connectors and functions to various APIs and DBMSs, controlled by a central broker.
5
5
  Author: Rob D'Aveta
6
6
  Author-email: rob.daveta@gmail.com
@@ -22,6 +22,7 @@ Requires-Dist: pyodbc (>=5.3.0,<6.0.0) ; extra == "odbc"
22
22
  Requires-Dist: python-dotenv (>=1.1.1,<2.0.0)
23
23
  Requires-Dist: splunk-sdk (>=2.1.1,<3.0.0)
24
24
  Requires-Dist: tenacity (>=9.1.2,<10.0.0)
25
+ Requires-Dist: trino (>=0.337.0,<0.338.0)
25
26
  Project-URL: Homepage, https://github.com/robd518/pyapiary
26
27
  Project-URL: Repository, https://github.com/robd518/pyapiary
27
28
  Description-Content-Type: text/markdown
@@ -1,7 +1,7 @@
1
1
  [tool.poetry]
2
2
  name = "pyapiary"
3
3
  packages = [{ include = "pyapiary", from = "src" }]
4
- version = "2.2.0"
4
+ version = "2.3.0"
5
5
  description = "A simple, lightweight set of connectors and functions to various APIs and DBMSs, controlled by a central broker."
6
6
  authors = ["Rob D'Aveta <rob.daveta@gmail.com>"]
7
7
  readme = "README.md"
@@ -20,6 +20,7 @@ pyodbc = "^5.3.0"
20
20
  psycopg-pool = {extras = ["binary", "pool"], version = "^3.3.1"}
21
21
  psycopg = {extras = ["pool"], version = "^3.3.4"}
22
22
  psycopg-binary = "^3.3.4"
23
+ trino = "^0.337.0"
23
24
 
24
25
  [tool.poetry.extras]
25
26
  odbc = ["pyodbc"]
@@ -35,9 +35,13 @@ class PostgresConnector:
35
35
  https://www.psycopg.org/psycopg3/docs/api/pool.html#module-psycopg_pool
36
36
  """
37
37
  with self.connection_pool.connection() as conn:
38
- with conn.transaction():
38
+ with conn.cursor() as cur:
39
39
  # claude recommended a transaction wrapper here
40
- return conn.execute(query, params).fetchall()
40
+ cur.execute(query, params)
41
+ if cur.description:
42
+ return cur.fetchall()
43
+ else:
44
+ return None
41
45
 
42
46
  def bulk_insert(self, table: str, data: List[Dict[str, Any]]):
43
47
  if not data:
@@ -82,7 +86,10 @@ class AsyncPostgresConnector:
82
86
  """
83
87
  async with self.connection_pool.connection() as conn:
84
88
  cur = await conn.execute(query, params)
85
- return await cur.fetchall()
89
+ if cur.description:
90
+ return await cur.fetchall()
91
+ else:
92
+ return None
86
93
 
87
94
  async def async_bulk_insert(self, table_name: str, data: List[Dict[str, Any]]):
88
95
  if not data:
@@ -0,0 +1,32 @@
1
+ from trino.dbapi import connect
2
+ from typing import List, Dict, Any
3
+
4
+ class TrinoConnector:
5
+ def __init__(self, host, port, user, catalog=None, schema=None):
6
+ self.conn = connect(
7
+ host=host,
8
+ port=port,
9
+ user=user,
10
+ catalog=catalog,
11
+ schema=schema
12
+ )
13
+
14
+ def query(self, query_str):
15
+ with self.conn.cursor() as cur:
16
+ cur.execute(query_str)
17
+ if cur.description:
18
+ return cur.fetchall()
19
+ else:
20
+ return None
21
+
22
+ def bulk_insert(self, table, data : List[Dict[str,Any]]):
23
+ if not len(data)>0:
24
+ print('Invalid List of Dict type passed. Must have more than one element.')
25
+ return None
26
+ columns = data[0].keys()
27
+ placeholders = ", ".join(["?"] * len(columns))
28
+ query = f"INSERT INTO {table} ({', '.join(columns)}) VALUES ({placeholders})"
29
+ values = [tuple(row[col] for col in columns) for row in data]
30
+ with self.conn.cursor() as cur:
31
+ cur.executemany(query, values)
32
+ return True
@@ -3,13 +3,26 @@ from unittest.mock import MagicMock, AsyncMock, patch
3
3
 
4
4
  import pytest
5
5
 
6
- # Mock psycopg_pool before importing the module so tests run even when the
7
- # driver is not installed in the environment.
8
6
  sys.modules.setdefault("psycopg_pool", MagicMock())
9
7
 
10
8
  from pyapiary.dbms_connectors.postgres import PostgresConnector, AsyncPostgresConnector
11
9
 
12
10
 
11
+ # ──────────────────────────────────────────────
12
+ # Helpers
13
+ # ──────────────────────────────────────────────
14
+
15
+ def make_sync_conn(cursor):
16
+ mock_conn = MagicMock()
17
+ mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=cursor)
18
+ mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False)
19
+ return mock_conn
20
+
21
+ def wire_pool(pg, conn):
22
+ pg.connection_pool.connection.return_value.__enter__ = MagicMock(return_value=conn)
23
+ pg.connection_pool.connection.return_value.__exit__ = MagicMock(return_value=False)
24
+
25
+
13
26
  # ──────────────────────────────────────────────
14
27
  # Sync PostgresConnector
15
28
  # ──────────────────────────────────────────────
@@ -76,28 +89,42 @@ class TestPostgresConnectorClose:
76
89
 
77
90
 
78
91
  class TestPostgresConnectorQuery:
79
- def test_query_returns_results(self, pg):
80
- mock_conn = MagicMock()
81
- mock_conn.execute.return_value.fetchall.return_value = [("row1",), ("row2",)]
82
- pg.connection_pool.connection.return_value.__enter__ = MagicMock(return_value=mock_conn)
83
- pg.connection_pool.connection.return_value.__exit__ = MagicMock(return_value=False)
84
- mock_conn.transaction.return_value.__enter__ = MagicMock()
85
- mock_conn.transaction.return_value.__exit__ = MagicMock(return_value=False)
92
+ def test_select_returns_rows(self, pg):
93
+ cur = MagicMock()
94
+ cur.description = [("col1",)]
95
+ cur.fetchall.return_value = [("row1",), ("row2",)]
96
+ wire_pool(pg, make_sync_conn(cur))
86
97
 
87
98
  result = pg.query("SELECT 1")
88
99
  assert result == [("row1",), ("row2",)]
89
- mock_conn.execute.assert_called_once_with("SELECT 1", None)
100
+ cur.execute.assert_called_once_with("SELECT 1", None)
90
101
 
91
- def test_query_passes_params(self, pg):
92
- mock_conn = MagicMock()
93
- mock_conn.execute.return_value.fetchall.return_value = []
94
- pg.connection_pool.connection.return_value.__enter__ = MagicMock(return_value=mock_conn)
95
- pg.connection_pool.connection.return_value.__exit__ = MagicMock(return_value=False)
96
- mock_conn.transaction.return_value.__enter__ = MagicMock()
97
- mock_conn.transaction.return_value.__exit__ = MagicMock(return_value=False)
102
+ def test_select_passes_params(self, pg):
103
+ cur = MagicMock()
104
+ cur.description = [("col1",)]
105
+ cur.fetchall.return_value = []
106
+ wire_pool(pg, make_sync_conn(cur))
98
107
 
99
108
  pg.query("SELECT * FROM t WHERE id = %s", (42,))
100
- mock_conn.execute.assert_called_once_with("SELECT * FROM t WHERE id = %s", (42,))
109
+ cur.execute.assert_called_once_with("SELECT * FROM t WHERE id = %s", (42,))
110
+
111
+ def test_non_select_returns_none(self, pg):
112
+ cur = MagicMock()
113
+ cur.description = None # INSERT/DDL has no description
114
+ wire_pool(pg, make_sync_conn(cur))
115
+
116
+ result = pg.query("INSERT INTO t VALUES (1)")
117
+ assert result is None
118
+ cur.fetchall.assert_not_called()
119
+
120
+ def test_select_empty_result_returns_empty_list(self, pg):
121
+ cur = MagicMock()
122
+ cur.description = [("col1",)]
123
+ cur.fetchall.return_value = []
124
+ wire_pool(pg, make_sync_conn(cur))
125
+
126
+ result = pg.query("SELECT 1 WHERE false")
127
+ assert result == []
101
128
 
102
129
 
103
130
  class TestPostgresConnectorBulkInsert:
@@ -111,12 +138,7 @@ class TestPostgresConnectorBulkInsert:
111
138
  mock_cursor.copy.return_value.__enter__ = MagicMock(return_value=mock_copy)
112
139
  mock_cursor.copy.return_value.__exit__ = MagicMock(return_value=False)
113
140
 
114
- mock_conn = MagicMock()
115
- mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor)
116
- mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False)
117
-
118
- pg.connection_pool.connection.return_value.__enter__ = MagicMock(return_value=mock_conn)
119
- pg.connection_pool.connection.return_value.__exit__ = MagicMock(return_value=False)
141
+ wire_pool(pg, make_sync_conn(mock_cursor))
120
142
 
121
143
  data = [{"name": "alice", "age": 30}, {"name": "bob", "age": 25}]
122
144
  pg.bulk_insert("users", data)
@@ -196,36 +218,44 @@ class TestAsyncPostgresConnectorContextManager:
196
218
 
197
219
 
198
220
  class TestAsyncPostgresConnectorQuery:
199
- @pytest.mark.asyncio
200
- async def test_async_query_returns_results(self, async_pg):
201
- mock_cursor = AsyncMock()
202
- mock_cursor.fetchall.return_value = [("row1",)]
203
-
221
+ def _wire(self, async_pg, cur):
204
222
  mock_conn = AsyncMock()
205
- mock_conn.execute.return_value = mock_cursor
206
-
223
+ mock_conn.execute = AsyncMock(return_value=cur)
207
224
  async_cm = AsyncMock()
208
225
  async_cm.__aenter__.return_value = mock_conn
209
226
  async_pg.connection_pool.connection.return_value = async_cm
227
+ return mock_conn
228
+
229
+ @pytest.mark.asyncio
230
+ async def test_select_returns_rows(self, async_pg):
231
+ cur = AsyncMock()
232
+ cur.description = [("col1",)]
233
+ cur.fetchall = AsyncMock(return_value=[("row1",)])
234
+ conn = self._wire(async_pg, cur)
210
235
 
211
236
  result = await async_pg.async_query("SELECT 1")
212
237
  assert result == [("row1",)]
213
- mock_conn.execute.assert_awaited_once_with("SELECT 1", None)
238
+ conn.execute.assert_awaited_once_with("SELECT 1", None)
214
239
 
215
240
  @pytest.mark.asyncio
216
- async def test_async_query_passes_params(self, async_pg):
217
- mock_cursor = AsyncMock()
218
- mock_cursor.fetchall.return_value = []
241
+ async def test_select_passes_params(self, async_pg):
242
+ cur = AsyncMock()
243
+ cur.description = [("col1",)]
244
+ cur.fetchall = AsyncMock(return_value=[])
245
+ conn = self._wire(async_pg, cur)
219
246
 
220
- mock_conn = AsyncMock()
221
- mock_conn.execute.return_value = mock_cursor
247
+ await async_pg.async_query("SELECT * FROM t WHERE id = %s", (1,))
248
+ conn.execute.assert_awaited_once_with("SELECT * FROM t WHERE id = %s", (1,))
222
249
 
223
- async_cm = AsyncMock()
224
- async_cm.__aenter__.return_value = mock_conn
225
- async_pg.connection_pool.connection.return_value = async_cm
250
+ @pytest.mark.asyncio
251
+ async def test_non_select_returns_none(self, async_pg):
252
+ cur = AsyncMock()
253
+ cur.description = None
254
+ conn = self._wire(async_pg, cur)
226
255
 
227
- await async_pg.async_query("SELECT * FROM t WHERE id = %s", (1,))
228
- mock_conn.execute.assert_awaited_once_with("SELECT * FROM t WHERE id = %s", (1,))
256
+ result = await async_pg.async_query("INSERT INTO t VALUES (1)")
257
+ assert result is None
258
+ cur.fetchall.assert_not_called()
229
259
 
230
260
 
231
261
  class TestAsyncPostgresConnectorBulkInsert:
@@ -256,4 +286,4 @@ class TestAsyncPostgresConnectorBulkInsert:
256
286
  mock_cursor.copy.assert_called_once_with("COPY users (name, age) FROM STDIN")
257
287
  assert mock_copy.write_row.await_count == 2
258
288
  mock_copy.write_row.assert_any_await(("alice", 30))
259
- mock_copy.write_row.assert_any_await(("bob", 25))
289
+ mock_copy.write_row.assert_any_await(("bob", 25))
@@ -0,0 +1,200 @@
1
+ import pytest
2
+ from unittest.mock import MagicMock, patch, call
3
+ from pyapiary.dbms_connectors.trino import TrinoConnector
4
+
5
+
6
+ @pytest.fixture
7
+ def mock_connect(mocker):
8
+ """Patch trino.dbapi.connect and return the mock connection."""
9
+ mock_conn = MagicMock()
10
+ mocker.patch("pyapiary.dbms_connectors.trino.connect", return_value=mock_conn)
11
+ return mock_conn
12
+
13
+
14
+ @pytest.fixture
15
+ def connector(mock_connect):
16
+ """Return a TrinoConnector backed by a mocked connection."""
17
+ return TrinoConnector(
18
+ host="localhost",
19
+ port=8080,
20
+ user="test_user",
21
+ catalog="hive",
22
+ schema="default",
23
+ )
24
+
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # __init__
28
+ # ---------------------------------------------------------------------------
29
+
30
+ class TestInit:
31
+ def test_connect_called_with_correct_args(self, mocker):
32
+ mock_connect = mocker.patch("pyapiary.dbms_connectors.trino.connect")
33
+ TrinoConnector(host="myhost", port=9090, user="alice", catalog="iceberg", schema="raw")
34
+ mock_connect.assert_called_once_with(
35
+ host="myhost",
36
+ port=9090,
37
+ user="alice",
38
+ catalog="iceberg",
39
+ schema="raw",
40
+ )
41
+
42
+ def test_connect_called_without_optional_args(self, mocker):
43
+ mock_connect = mocker.patch("pyapiary.dbms_connectors.trino.connect")
44
+ TrinoConnector(host="myhost", port=9090, user="alice")
45
+ mock_connect.assert_called_once_with(
46
+ host="myhost",
47
+ port=9090,
48
+ user="alice",
49
+ catalog=None,
50
+ schema=None,
51
+ )
52
+
53
+ def test_conn_attribute_set(self, mock_connect, connector):
54
+ assert connector.conn is mock_connect
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # query()
59
+ # ---------------------------------------------------------------------------
60
+
61
+ class TestQuery:
62
+ def test_returns_rows_when_description_present(self, mock_connect, connector):
63
+ mock_cursor = MagicMock()
64
+ mock_cursor.description = [("col1",), ("col2",)]
65
+ mock_cursor.fetchall.return_value = [(1, "a"), (2, "b")]
66
+ mock_connect.cursor.return_value.__enter__.return_value = mock_cursor
67
+
68
+ result = connector.query("SELECT * FROM foo")
69
+
70
+ mock_cursor.execute.assert_called_once_with("SELECT * FROM foo")
71
+ mock_cursor.fetchall.assert_called_once()
72
+ assert result == [(1, "a"), (2, "b")]
73
+
74
+ def test_returns_none_when_no_description(self, mock_connect, connector):
75
+ mock_cursor = MagicMock()
76
+ mock_cursor.description = None
77
+ mock_connect.cursor.return_value.__enter__.return_value = mock_cursor
78
+
79
+ result = connector.query("CREATE TABLE foo (id INT)")
80
+
81
+ mock_cursor.execute.assert_called_once_with("CREATE TABLE foo (id INT)")
82
+ mock_cursor.fetchall.assert_not_called()
83
+ assert result is None
84
+
85
+ def test_returns_empty_list_for_empty_result_set(self, mock_connect, connector):
86
+ mock_cursor = MagicMock()
87
+ mock_cursor.description = [("id",)]
88
+ mock_cursor.fetchall.return_value = []
89
+ mock_connect.cursor.return_value.__enter__.return_value = mock_cursor
90
+
91
+ result = connector.query("SELECT * FROM empty_table")
92
+
93
+ assert result == []
94
+
95
+ def test_cursor_used_as_context_manager(self, mock_connect, connector):
96
+ mock_cursor = MagicMock()
97
+ mock_cursor.description = None
98
+ mock_connect.cursor.return_value.__enter__.return_value = mock_cursor
99
+
100
+ connector.query("SELECT 1")
101
+
102
+ mock_connect.cursor.return_value.__enter__.assert_called_once()
103
+ mock_connect.cursor.return_value.__exit__.assert_called_once()
104
+
105
+ def test_propagates_execute_exception(self, mock_connect, connector):
106
+ mock_cursor = MagicMock()
107
+ mock_cursor.execute.side_effect = RuntimeError("syntax error")
108
+ mock_connect.cursor.return_value.__enter__.return_value = mock_cursor
109
+
110
+ with pytest.raises(RuntimeError, match="syntax error"):
111
+ connector.query("SELECT bad syntax %%")
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # bulk_insert()
116
+ # ---------------------------------------------------------------------------
117
+
118
+ class TestBulkInsert:
119
+ def test_inserts_single_row(self, mock_connect, connector):
120
+ mock_cursor = MagicMock()
121
+ mock_connect.cursor.return_value.__enter__.return_value = mock_cursor
122
+
123
+ result = connector.bulk_insert("my_table", [{"id": 1, "name": "alice"}])
124
+
125
+ expected_query = "INSERT INTO my_table (id, name) VALUES (?, ?)"
126
+ mock_cursor.executemany.assert_called_once_with(
127
+ expected_query, [(1, "alice")]
128
+ )
129
+ assert result is True
130
+
131
+ def test_inserts_multiple_rows(self, mock_connect, connector):
132
+ mock_cursor = MagicMock()
133
+ mock_connect.cursor.return_value.__enter__.return_value = mock_cursor
134
+
135
+ data = [
136
+ {"id": 1, "val": "a"},
137
+ {"id": 2, "val": "b"},
138
+ {"id": 3, "val": "c"},
139
+ ]
140
+ result = connector.bulk_insert("my_table", data)
141
+
142
+ expected_values = [(1, "a"), (2, "b"), (3, "c")]
143
+ _, call_values = mock_cursor.executemany.call_args
144
+ # positional args
145
+ actual_values = mock_cursor.executemany.call_args[0][1]
146
+ assert actual_values == expected_values
147
+ assert result is True
148
+
149
+ def test_returns_none_for_empty_list(self, mock_connect, connector, capsys):
150
+ result = connector.bulk_insert("my_table", [])
151
+
152
+ assert result is None
153
+ captured = capsys.readouterr()
154
+ assert "Invalid" in captured.out
155
+
156
+ def test_empty_list_does_not_touch_cursor(self, mock_connect, connector):
157
+ mock_cursor = MagicMock()
158
+ mock_connect.cursor.return_value.__enter__.return_value = mock_cursor
159
+
160
+ connector.bulk_insert("my_table", [])
161
+
162
+ mock_cursor.executemany.assert_not_called()
163
+
164
+ def test_query_string_uses_correct_table_name(self, mock_connect, connector):
165
+ mock_cursor = MagicMock()
166
+ mock_connect.cursor.return_value.__enter__.return_value = mock_cursor
167
+
168
+ connector.bulk_insert("schema.target_table", [{"x": 99}])
169
+
170
+ actual_query = mock_cursor.executemany.call_args[0][0]
171
+ assert "schema.target_table" in actual_query
172
+
173
+ def test_column_order_matches_first_row_keys(self, mock_connect, connector):
174
+ mock_cursor = MagicMock()
175
+ mock_connect.cursor.return_value.__enter__.return_value = mock_cursor
176
+
177
+ data = [{"z": 3, "a": 1, "m": 2}]
178
+ connector.bulk_insert("t", data)
179
+
180
+ actual_query = mock_cursor.executemany.call_args[0][0]
181
+ # columns in query should match key order of first dict
182
+ for col in ["z", "a", "m"]:
183
+ assert col in actual_query
184
+
185
+ def test_propagates_executemany_exception(self, mock_connect, connector):
186
+ mock_cursor = MagicMock()
187
+ mock_cursor.executemany.side_effect = RuntimeError("DB write error")
188
+ mock_connect.cursor.return_value.__enter__.return_value = mock_cursor
189
+
190
+ with pytest.raises(RuntimeError, match="DB write error"):
191
+ connector.bulk_insert("t", [{"id": 1}])
192
+
193
+ def test_cursor_used_as_context_manager(self, mock_connect, connector):
194
+ mock_cursor = MagicMock()
195
+ mock_connect.cursor.return_value.__enter__.return_value = mock_cursor
196
+
197
+ connector.bulk_insert("t", [{"id": 1}])
198
+
199
+ mock_connect.cursor.return_value.__enter__.assert_called_once()
200
+ mock_connect.cursor.return_value.__exit__.assert_called_once()
File without changes
File without changes