pyapiary 2.1.4__tar.gz → 2.2.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 (66) hide show
  1. {pyapiary-2.1.4 → pyapiary-2.2.0}/PKG-INFO +14 -1
  2. {pyapiary-2.1.4 → pyapiary-2.2.0}/README.md +10 -0
  3. {pyapiary-2.1.4 → pyapiary-2.2.0}/pyproject.toml +4 -1
  4. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/api_connectors/ipqs.py +26 -0
  5. pyapiary-2.2.0/src/pyapiary/dbms_connectors/postgres.py +104 -0
  6. pyapiary-2.2.0/src/pyapiary/tests/test_ipqs/__init__.py +0 -0
  7. pyapiary-2.2.0/src/pyapiary/tests/test_ipqs/cassettes/test_ipqs_phone_validation_vcr.yaml +70 -0
  8. pyapiary-2.2.0/src/pyapiary/tests/test_ipqs/test_integration_ipqs.py +24 -0
  9. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/tests/test_ipqs/test_unit_async_ipqs.py +27 -0
  10. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/tests/test_ipqs/test_unit_ipqs.py +26 -0
  11. pyapiary-2.2.0/src/pyapiary/tests/test_postgres/test_unit_postgres.py +259 -0
  12. pyapiary-2.1.4/src/pyapiary/tests/test_ipqs/test_integration_ipqs.py +0 -13
  13. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/__init__.py +0 -0
  14. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/api_connectors/__init__.py +0 -0
  15. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/api_connectors/broker.py +0 -0
  16. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/api_connectors/domaintools.py +0 -0
  17. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/api_connectors/flashpoint.py +0 -0
  18. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/api_connectors/generic.py +0 -0
  19. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/api_connectors/spycloud.py +0 -0
  20. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/api_connectors/twilio.py +0 -0
  21. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/api_connectors/urlscan.py +0 -0
  22. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/dbms_connectors/__init__.py +0 -0
  23. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/dbms_connectors/elasticsearch.py +0 -0
  24. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/dbms_connectors/mongo.py +0 -0
  25. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/dbms_connectors/mongo_async.py +0 -0
  26. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/dbms_connectors/odbc.py +0 -0
  27. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/dbms_connectors/splunk.py +0 -0
  28. /pyapiary-2.1.4/src/pyapiary/tests/__init__.py → /pyapiary-2.2.0/src/pyapiary/dbms_connectors/trino.py +0 -0
  29. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/helpers.py +0 -0
  30. {pyapiary-2.1.4/src/pyapiary/tests/test_ipqs → pyapiary-2.2.0/src/pyapiary/tests}/__init__.py +0 -0
  31. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/tests/conftest.py +0 -0
  32. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/tests/test_broker/test_integration_broker.py +0 -0
  33. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/tests/test_broker/test_unit_asyncbroker.py +0 -0
  34. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/tests/test_broker/test_unit_broker.py +0 -0
  35. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/tests/test_domaintools/cassettes/.gitkeep +0 -0
  36. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/tests/test_domaintools/cassettes/test_domaintools_iris_investigate_vcr.yaml +0 -0
  37. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/tests/test_domaintools/cassettes/test_domaintools_parsed_whois_vcr.yaml +0 -0
  38. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/tests/test_domaintools/test_integration_domaintools.py +0 -0
  39. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/tests/test_domaintools/test_unit_async_domaintools.py +0 -0
  40. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/tests/test_domaintools/test_unit_domaintools.py +0 -0
  41. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/tests/test_elasticsearch/test_unit_elasticsearch.py +0 -0
  42. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/tests/test_flashpoint/cassettes/test_flashpoint_search_fraud_vcr.yaml +0 -0
  43. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/tests/test_flashpoint/test_integration_flashpoint.py +0 -0
  44. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/tests/test_flashpoint/test_unit_async_flashpoint.py +0 -0
  45. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/tests/test_flashpoint/test_unit_flashpoint.py +0 -0
  46. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/tests/test_generic/cassettes/test_generic_get_github_api.yaml +0 -0
  47. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/tests/test_generic/test_integration_generic_connector.py +0 -0
  48. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/tests/test_generic/test_unit_async_generic_connector.py +0 -0
  49. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/tests/test_generic/test_unit_generic_connector.py +0 -0
  50. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/tests/test_ipqs/cassettes/test_ipqs_malicious_url_vcr.yaml +0 -0
  51. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/tests/test_mongodb/test_unit_async_mongo.py +0 -0
  52. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/tests/test_mongodb/test_unit_mongo.py +0 -0
  53. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/tests/test_odbc/test_unit_odbc.py +0 -0
  54. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/tests/test_splunk/test_unit_splunk.py +0 -0
  55. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/tests/test_spycloud/cassettes/test_spycloud_ato_search_vcr.yaml +0 -0
  56. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/tests/test_spycloud/test_integration_spycloud.py +0 -0
  57. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/tests/test_spycloud/test_unit_async_spycloud.py +0 -0
  58. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/tests/test_spycloud/test_unit_spycloud.py +0 -0
  59. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/tests/test_twilio/cassettes/test_lookup_phone_vcr.yaml +0 -0
  60. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/tests/test_twilio/test_integration_twilio.py +0 -0
  61. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/tests/test_twilio/test_unit_async_twilio.py +0 -0
  62. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/tests/test_twilio/test_unit_twilio.py +0 -0
  63. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/tests/test_urlscan/cassettes/test_urlscan_results_vcr.yaml +0 -0
  64. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/tests/test_urlscan/test_integration_urlscan.py +0 -0
  65. {pyapiary-2.1.4 → pyapiary-2.2.0}/src/pyapiary/tests/test_urlscan/test_unit_async_urlscan.py +0 -0
  66. {pyapiary-2.1.4 → pyapiary-2.2.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.1.4
3
+ Version: 2.2.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
@@ -14,6 +14,9 @@ Classifier: Programming Language :: Python :: 3.14
14
14
  Provides-Extra: odbc
15
15
  Requires-Dist: elasticsearch (>=9.1.1,<10.0.0)
16
16
  Requires-Dist: httpx (>=0.28.1,<0.29.0)
17
+ Requires-Dist: psycopg-binary (>=3.3.4,<4.0.0)
18
+ Requires-Dist: psycopg-pool[binary,pool] (>=3.3.1,<4.0.0)
19
+ Requires-Dist: psycopg[pool] (>=3.3.4,<4.0.0)
17
20
  Requires-Dist: pymongo (>=4.15.0,<5.0.0)
18
21
  Requires-Dist: pyodbc (>=5.3.0,<6.0.0) ; extra == "odbc"
19
22
  Requires-Dist: python-dotenv (>=1.1.1,<2.0.0)
@@ -355,6 +358,16 @@ results = conn.query("search index=_internal | head 5")
355
358
  pytest -m integration
356
359
  ```
357
360
 
361
+
362
+ ### Manual Testing
363
+ - Located in dev_env
364
+ - Do not use pytest or mocking
365
+ - run the docker-compose.yaml to stand up services, change directories into the folder in dev_env you wish to test
366
+ - execute the following to test your module
367
+ ```
368
+ poetry run python <your test file here>.py
369
+ ```
370
+
358
371
  ### 🧼 Suppress warnings
359
372
 
360
373
  Add this to `pytest.ini`:
@@ -330,6 +330,16 @@ results = conn.query("search index=_internal | head 5")
330
330
  pytest -m integration
331
331
  ```
332
332
 
333
+
334
+ ### Manual Testing
335
+ - Located in dev_env
336
+ - Do not use pytest or mocking
337
+ - run the docker-compose.yaml to stand up services, change directories into the folder in dev_env you wish to test
338
+ - execute the following to test your module
339
+ ```
340
+ poetry run python <your test file here>.py
341
+ ```
342
+
333
343
  ### 🧼 Suppress warnings
334
344
 
335
345
  Add this to `pytest.ini`:
@@ -1,7 +1,7 @@
1
1
  [tool.poetry]
2
2
  name = "pyapiary"
3
3
  packages = [{ include = "pyapiary", from = "src" }]
4
- version = "2.1.4"
4
+ version = "2.2.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"
@@ -17,6 +17,9 @@ splunk-sdk = "^2.1.1"
17
17
  httpx = "^0.28.1"
18
18
  tenacity = "^9.1.2"
19
19
  pyodbc = "^5.3.0"
20
+ psycopg-pool = {extras = ["binary", "pool"], version = "^3.3.1"}
21
+ psycopg = {extras = ["pool"], version = "^3.3.4"}
22
+ psycopg-binary = "^3.3.4"
20
23
 
21
24
  [tool.poetry.extras]
22
25
  odbc = ["pyodbc"]
@@ -36,6 +36,19 @@ class IPQSConnector(Broker):
36
36
  """
37
37
  return self.post("/url/", data={"url": query, "key": self.api_key, **kwargs})
38
38
 
39
+ @log_method_call
40
+ def phone_validation(self, query: str, **kwargs) -> httpx.Response:
41
+ """The IPQS Phone Number Validation API offers rapid analysis to determine the risk core,
42
+ country of origin, carrier, validity, owner information, and connection status of phone numbers
43
+
44
+ Args:
45
+ query (str): The phone number to look up.
46
+
47
+ Returns:
48
+ httpx.Response: the httpx.Response object
49
+ """
50
+ return self.post("/phone/", data={"phone": query, "key": self.api_key, **kwargs})
51
+
39
52
 
40
53
  @bubble_broker_init_signature()
41
54
  class AsyncIPQSConnector(AsyncBroker):
@@ -63,3 +76,16 @@ class AsyncIPQSConnector(AsyncBroker):
63
76
  httpx.Response: the httpx.Response object
64
77
  """
65
78
  return await self.post("/url/", data={"url": query, "key": self.api_key, **kwargs})
79
+
80
+ @log_method_call
81
+ async def phone_validation(self, query: str, **kwargs) -> httpx.Response:
82
+ """The IPQS Phone Number Validation API offers rapid analysis to determine the risk core,
83
+ country of origin, carrier, validity, owner information, and connection status of phone numbers
84
+
85
+ Args:
86
+ query (str): The phone number to look up.
87
+
88
+ Returns:
89
+ httpx.Response: the httpx.Response object
90
+ """
91
+ return await self.post("/phone/", data={"phone": query, "key": self.api_key, **kwargs})
@@ -0,0 +1,104 @@
1
+ from typing import List, Dict, Any, Optional, Generator, Type, Union
2
+ from types import TracebackType
3
+ from pyapiary.helpers import setup_logger
4
+ from psycopg_pool import AsyncConnectionPool, ConnectionPool
5
+
6
+ class PostgresConnector:
7
+ def __init__(self,conn_str, logger=None, min_size=5, max_size=30):
8
+ self.dsn = conn_str
9
+ self.min_size = min_size
10
+ self.max_size = max_size
11
+ self.connection_pool = ConnectionPool(self.dsn, kwargs={"autocommit":True}, min_size=self.min_size, max_size=self.max_size)
12
+ self.logger = logger if logger else setup_logger(__name__)
13
+
14
+ def __enter__(self):
15
+ return self
16
+
17
+ def __exit__(self, exc_type, exc_value, traceback):
18
+ self.close()
19
+
20
+ def close(self):
21
+ """Close the PG connection."""
22
+ if self.connection_pool:
23
+ self.connection_pool.close()
24
+ self._log("PG connection closed")
25
+
26
+ def _log(self, msg: str, level: str = "info"):
27
+ if self.logger:
28
+ log_method = getattr(self.logger, level, self.logger.info)
29
+ log_method(msg)
30
+
31
+ def query(self, query: str, params=None):
32
+ """
33
+ query - string query representing the work the user wants done
34
+ params - must be legal for psycopog_pool AsyncConnectionPool object
35
+ https://www.psycopg.org/psycopg3/docs/api/pool.html#module-psycopg_pool
36
+ """
37
+ with self.connection_pool.connection() as conn:
38
+ with conn.transaction():
39
+ # claude recommended a transaction wrapper here
40
+ return conn.execute(query, params).fetchall()
41
+
42
+ def bulk_insert(self, table: str, data: List[Dict[str, Any]]):
43
+ if not data:
44
+ return
45
+
46
+ self._log(f"Inserting {len(data)} rows into table {table}")
47
+
48
+ columns = list(data[0].keys())
49
+ copy_query = f"COPY {table} ({', '.join(columns)}) FROM STDIN"
50
+
51
+ with self.connection_pool.connection() as conn:
52
+ with conn.cursor() as cur:
53
+ with cur.copy(copy_query) as copy:
54
+ for row in data:
55
+ copy.write_row(tuple(row[col] for col in columns))
56
+ # Note: pool is configured for autocommit, commit will happen before the with block ends
57
+
58
+ # Async Version
59
+ ## Need to write an async_bulk_insert
60
+ class AsyncPostgresConnector:
61
+ def __init__(self,conn_str, min_size=5, max_size=30, logger=None):
62
+ self.dsn = conn_str
63
+ self.min_size = min_size
64
+ self.max_size = max_size
65
+ self.connection_pool = AsyncConnectionPool(self.dsn, kwargs={"autocommit":True}, min_size=self.min_size, max_size=self.max_size, open=False)
66
+ self.logger = logger if logger else setup_logger(__name__)
67
+
68
+ async def __aenter__(self):
69
+ # for async with calls
70
+ await self.connection_pool.open()
71
+ return self
72
+
73
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
74
+ # for async with calls
75
+ await self.connection_pool.close()
76
+
77
+ async def async_query(self, query: str, params=None):
78
+ """
79
+ query - string query representing the work the user wants done
80
+ params - must be legal for psycopog_pool AsyncConnectionPool object
81
+ https://www.psycopg.org/psycopg3/docs/api/pool.html#module-psycopg_pool
82
+ """
83
+ async with self.connection_pool.connection() as conn:
84
+ cur = await conn.execute(query, params)
85
+ return await cur.fetchall()
86
+
87
+ async def async_bulk_insert(self, table_name: str, data: List[Dict[str, Any]]):
88
+ if not data:
89
+ return
90
+
91
+ columns = list(data[0].keys())
92
+
93
+ # Google recommended using an cursor.copy command here to process the dict, better performance over a high volume of rows, more efficient
94
+ # than the odbc write/execute_many paradigm.
95
+ async with self.connection_pool.connection() as aconn:
96
+ async with aconn.cursor() as acur:
97
+ # using COPY is the most performative for millions of rows
98
+ copy_query = f"COPY {table_name} ({', '.join(columns)}) FROM STDIN"
99
+
100
+ async with acur.copy(copy_query) as copy:
101
+ for record in data:
102
+ row = tuple(record[col] for col in columns)
103
+ await copy.write_row(row)
104
+ # Note: since asyncpool passes autocommit kwarg, commits will happen before the with block ends
@@ -0,0 +1,70 @@
1
+ interactions:
2
+ - request:
3
+ body: phone=12024567041&key=HOwg8USe8QTBYpdxDEnfAqWZeb0kpAuQ
4
+ headers:
5
+ accept:
6
+ - '*/*'
7
+ accept-encoding:
8
+ - gzip, deflate
9
+ connection:
10
+ - keep-alive
11
+ content-length:
12
+ - '54'
13
+ content-type:
14
+ - application/x-www-form-urlencoded
15
+ host:
16
+ - www.ipqualityscore.com
17
+ user-agent:
18
+ - REDACTED
19
+ method: POST
20
+ uri: https://www.ipqualityscore.com/api/json/phone/
21
+ response:
22
+ body:
23
+ string: !!binary |
24
+ H4sIAAAAAAAA/1VUXW/iOBT9K1aedjUM03a7s1KfcAE12aGBSaBVtbOKjGOIi2NnbYcqM+p/3+NA
25
+ aXmJcu+5Pvf7/opq4RzbiugmWlRGCyId2TMly2E0iFzLOeDoxttWDKKNsTXzXpQw/nR5dXF1/efX
26
+ vy6uL2GpDGeqOBgA/Q3g7wTw5yPeU554LGvLwnFj4fZiEFnBhfYFW7cOig1TDkYP82RxEhorGhYI
27
+ dKsUHki3604g417uxRs5Z9ZKYRHEg7Dyp9FkbOq61ZIzL412IVipReG7JiQ9Y7oMMtTctNpb8Ear
28
+ PIjSh/9HmsdJerecp9D9lE3BTRkeXl1c/PE1CsFvQQvFZAyplEhUb49Gl2/BFc4z36KQEe1lMoNL
29
+ 8pnEclshPr2RpdA8BOFqV5SmZjJQpj++UOiYc4ZLhsIXAogqWFla9EWA8Ff0Tn0yI72ZIxtk1Dfy
30
+ IEc3/4QAjVDSi9E26Ibc1MAb1irwWOnZqHWleR5uzR76Z+NEU72gpEKfARZl69bCbv9rpfPnkFnr
31
+ TgE/84C2qBEv2V6WzmipXQsKLo7os3QVa6Q1o45Vxhy1O8meTaVhf+bAV6iP0w1TwtbmzEvJNFvb
32
+ 1vHzNBSDtx1rENWZfiuM3QrHqxapnGfBKwyZ716k2IZUPkIbBN65GlPVjZhRpxR05yqpR5XZOG/Z
33
+ UJRt9O/rIMJQ26Ifg8NATbUXtgE7puD6E7ECJbSib1Ot+XvXa/5BUILtxGmBXMPqOsz4cQMCh3C+
34
+ CBuCLZXXfvfw8lThnWZ1GNa76Ty7m5LbVR4PyIKuZiRf0ixZ0gH5e55PFzF5pFk2TQcko+nkidxO
35
+ s7vvqyRfQjG/JenTDOoBGdNsRib0IZnk8zQ8TekypinJY7pIsnnQxCkg8i0B8zKe39Mc7mbT7B7g
36
+ hKaU3GarfAxwRlcZJd/oAswDcowvH8cruIbXcZzB+xN5TKZ3vetHfCnJ75MlMoC/x5Tcr7JF/ASn
37
+ SYggCfvpZS2w8iFligJh5398mQi9R60wHKbQxhc4VOpUOc8bVqwV4zuFdn84KRwT40VxvAnHfT6i
38
+ YUf7jXrvj25rjFCBS9bxcADCYr5f1rRHyQklG4GdtYKU0rG1QvPJAh3GRHCjPWaFuLZpjPXEG9KP
39
+ DmIZRm+XUnVHR+J0ERVD+8ML3IgSxm/6g+ogvb7+D6arKrXuBQAA
40
+ headers:
41
+ CF-RAY:
42
+ - 9e51976b889d98d5-ATL
43
+ Connection:
44
+ - keep-alive
45
+ Content-Encoding:
46
+ - gzip
47
+ Content-Type:
48
+ - application/json; charset=UTF-8
49
+ Date:
50
+ - Tue, 31 Mar 2026 18:59:29 GMT
51
+ Nel:
52
+ - '{"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}'
53
+ Report-To:
54
+ - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=MNIXz1eWc6WlUYSlvt%2FrHoZ5QQ62jQe%2BvrW%2B0dRfxNJU4OAh9YKgXohdqxSO3ED9eYP2ynVDPg4y7nwpTZdHvmVi8GcVjKsApuuMnMiWLjJXwliCb5Yq22rBsNTixyO5F%2BrJesPFCuc%3D"}]}'
55
+ Server:
56
+ - cloudflare
57
+ Transfer-Encoding:
58
+ - chunked
59
+ X-LB-ID:
60
+ - LB-3
61
+ X-Powered-By:
62
+ - IPQualityScore
63
+ X-Upstream-ID:
64
+ - N-152
65
+ cf-cache-status:
66
+ - DYNAMIC
67
+ status:
68
+ code: 200
69
+ message: OK
70
+ version: 1
@@ -0,0 +1,24 @@
1
+ import httpx
2
+ import pytest
3
+ from pyapiary.api_connectors.ipqs import IPQSConnector
4
+
5
+ @pytest.mark.integration
6
+ def test_ipqs_malicious_url_vcr(vcr_cassette):
7
+ with vcr_cassette.use_cassette("test_ipqs_malicious_url_vcr"):
8
+ connector = IPQSConnector(load_env_vars=True, enable_logging=True)
9
+ result = connector.malicious_url("github.com")
10
+
11
+ assert isinstance(result, httpx.Response)
12
+ assert "domain" in result.json()
13
+ assert result.json()["domain"] == "github.com"
14
+
15
+
16
+ @pytest.mark.integration
17
+ def test_ipqs_phone_validation_vcr(vcr_cassette):
18
+ with vcr_cassette.use_cassette("test_ipqs_phone_validation_vcr"):
19
+ connector = IPQSConnector(load_env_vars=True, enable_logging=True)
20
+ result = connector.phone_validation("+12024567041")
21
+
22
+ assert isinstance(result, httpx.Response)
23
+ assert "formatted" in result.json()
24
+ assert result.json()["formatted"] == "+12024567041"
@@ -51,3 +51,30 @@ async def test_async_malicious_url(mock_post):
51
51
  "/url/",
52
52
  data={"url": "example.com", "key": "test_key", "strictness": 1}
53
53
  )
54
+
55
+
56
+ @patch("pyapiary.api_connectors.ipqs.AsyncIPQSConnector.post", new_callable=AsyncMock)
57
+ @pytest.mark.asyncio
58
+ async def test_async_phone_validation(mock_post):
59
+ import json
60
+
61
+ request = httpx.Request("POST", "https://www.ipqualityscore.com/api/json/url/")
62
+ payload = {"success": True, "phone": "8888888888"}
63
+ mock_response = httpx.Response(
64
+ 200,
65
+ request=request,
66
+ content=json.dumps(payload).encode("utf-8"),
67
+ headers={"Content-Type": "application/json"},
68
+ )
69
+ mock_post.return_value = mock_response
70
+
71
+ connector = AsyncIPQSConnector(api_key="test_key")
72
+ response = await connector.phone_validation("8888888888", strictness=1)
73
+
74
+ assert isinstance(response, httpx.Response)
75
+ assert response.status_code == 200
76
+ assert response.json() == payload
77
+ mock_post.assert_awaited_once_with(
78
+ "/phone/",
79
+ data={"phone": "8888888888", "key": "test_key", "strictness": 1}
80
+ )
@@ -46,3 +46,29 @@ def test_malicious_url(mock_post):
46
46
  )
47
47
  assert isinstance(result, httpx.Response)
48
48
  assert result.json() == payload
49
+
50
+
51
+ @patch("pyapiary.api_connectors.ipqs.IPQSConnector.post")
52
+ def test_phone_validation(mock_post):
53
+ # Build a real httpx.Response to match the new return type
54
+ import json
55
+
56
+ request = httpx.Request("POST", "https://www.ipqualityscore.com/api/json/phone/")
57
+ payload = {"success": True, "phone": "8888888888"}
58
+ mock_response = httpx.Response(
59
+ 200,
60
+ request=request,
61
+ content=json.dumps(payload).encode("utf-8"),
62
+ headers={"Content-Type": "application/json"},
63
+ )
64
+ mock_post.return_value = mock_response
65
+
66
+ connector = IPQSConnector(api_key="test_key")
67
+ result = connector.phone_validation("8888888888")
68
+
69
+ mock_post.assert_called_once_with(
70
+ "/phone/",
71
+ data={"phone": "8888888888", "key": "test_key"},
72
+ )
73
+ assert isinstance(result, httpx.Response)
74
+ assert result.json() == payload
@@ -0,0 +1,259 @@
1
+ import sys
2
+ from unittest.mock import MagicMock, AsyncMock, patch
3
+
4
+ import pytest
5
+
6
+ # Mock psycopg_pool before importing the module so tests run even when the
7
+ # driver is not installed in the environment.
8
+ sys.modules.setdefault("psycopg_pool", MagicMock())
9
+
10
+ from pyapiary.dbms_connectors.postgres import PostgresConnector, AsyncPostgresConnector
11
+
12
+
13
+ # ──────────────────────────────────────────────
14
+ # Sync PostgresConnector
15
+ # ──────────────────────────────────────────────
16
+
17
+ @pytest.fixture
18
+ def pg():
19
+ with patch("pyapiary.dbms_connectors.postgres.ConnectionPool"):
20
+ yield PostgresConnector("postgresql://user:pass@localhost/testdb")
21
+
22
+
23
+ class TestPostgresConnectorInit:
24
+ @patch("pyapiary.dbms_connectors.postgres.ConnectionPool")
25
+ def test_creates_pool_with_defaults(self, mock_pool):
26
+ conn = PostgresConnector("postgresql://localhost/db")
27
+ mock_pool.assert_called_once_with(
28
+ "postgresql://localhost/db",
29
+ kwargs={"autocommit": True},
30
+ min_size=5,
31
+ max_size=30,
32
+ )
33
+ assert conn.dsn == "postgresql://localhost/db"
34
+ assert conn.min_size == 5
35
+ assert conn.max_size == 30
36
+
37
+ @patch("pyapiary.dbms_connectors.postgres.ConnectionPool")
38
+ def test_creates_pool_with_custom_sizes(self, mock_pool):
39
+ conn = PostgresConnector("dsn", min_size=1, max_size=10)
40
+ mock_pool.assert_called_once_with(
41
+ "dsn",
42
+ kwargs={"autocommit": True},
43
+ min_size=1,
44
+ max_size=10,
45
+ )
46
+
47
+ @patch("pyapiary.dbms_connectors.postgres.ConnectionPool")
48
+ def test_accepts_custom_logger(self, mock_pool):
49
+ logger = MagicMock()
50
+ conn = PostgresConnector("dsn", logger=logger)
51
+ assert conn.logger is logger
52
+
53
+ @patch("pyapiary.dbms_connectors.postgres.ConnectionPool")
54
+ def test_uses_default_logger_when_none(self, mock_pool):
55
+ conn = PostgresConnector("dsn")
56
+ assert conn.logger is not None
57
+
58
+
59
+ class TestPostgresConnectorContextManager:
60
+ def test_enter_returns_self(self, pg):
61
+ assert pg.__enter__() is pg
62
+
63
+ def test_exit_closes_pool(self, pg):
64
+ pg.__exit__(None, None, None)
65
+ pg.connection_pool.close.assert_called_once()
66
+
67
+
68
+ class TestPostgresConnectorClose:
69
+ def test_close_closes_pool(self, pg):
70
+ pg.close()
71
+ pg.connection_pool.close.assert_called_once()
72
+
73
+ def test_close_when_pool_is_none(self, pg):
74
+ pg.connection_pool = None
75
+ pg.close() # should not raise
76
+
77
+
78
+ 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)
86
+
87
+ result = pg.query("SELECT 1")
88
+ assert result == [("row1",), ("row2",)]
89
+ mock_conn.execute.assert_called_once_with("SELECT 1", None)
90
+
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)
98
+
99
+ pg.query("SELECT * FROM t WHERE id = %s", (42,))
100
+ mock_conn.execute.assert_called_once_with("SELECT * FROM t WHERE id = %s", (42,))
101
+
102
+
103
+ class TestPostgresConnectorBulkInsert:
104
+ def test_empty_data_returns_immediately(self, pg):
105
+ pg.bulk_insert("my_table", [])
106
+ pg.connection_pool.connection.assert_not_called()
107
+
108
+ def test_bulk_insert_calls_copy(self, pg):
109
+ mock_copy = MagicMock()
110
+ mock_cursor = MagicMock()
111
+ mock_cursor.copy.return_value.__enter__ = MagicMock(return_value=mock_copy)
112
+ mock_cursor.copy.return_value.__exit__ = MagicMock(return_value=False)
113
+
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)
120
+
121
+ data = [{"name": "alice", "age": 30}, {"name": "bob", "age": 25}]
122
+ pg.bulk_insert("users", data)
123
+
124
+ mock_cursor.copy.assert_called_once_with("COPY users (name, age) FROM STDIN")
125
+ assert mock_copy.write_row.call_count == 2
126
+ mock_copy.write_row.assert_any_call(("alice", 30))
127
+ mock_copy.write_row.assert_any_call(("bob", 25))
128
+
129
+
130
+ class TestPostgresConnectorLog:
131
+ def test_log_default_level(self, pg):
132
+ pg.logger = MagicMock()
133
+ pg._log("test message")
134
+ pg.logger.info.assert_called_once_with("test message")
135
+
136
+ def test_log_custom_level(self, pg):
137
+ pg.logger = MagicMock()
138
+ pg._log("warning msg", level="warning")
139
+ pg.logger.warning.assert_called_once_with("warning msg")
140
+
141
+ def test_log_falls_back_to_info(self, pg):
142
+ pg.logger = MagicMock(spec=["info"])
143
+ pg.logger.info = MagicMock()
144
+ pg._log("msg", level="nonexistent")
145
+ pg.logger.info.assert_called_once_with("msg")
146
+
147
+
148
+ # ──────────────────────────────────────────────
149
+ # Async AsyncPostgresConnector
150
+ # ──────────────────────────────────────────────
151
+
152
+ @pytest.fixture
153
+ def async_pg():
154
+ with patch("pyapiary.dbms_connectors.postgres.AsyncConnectionPool"):
155
+ yield AsyncPostgresConnector("postgresql://user:pass@localhost/testdb")
156
+
157
+
158
+ class TestAsyncPostgresConnectorInit:
159
+ @patch("pyapiary.dbms_connectors.postgres.AsyncConnectionPool")
160
+ def test_creates_pool_with_defaults(self, mock_pool):
161
+ conn = AsyncPostgresConnector("postgresql://localhost/db")
162
+ mock_pool.assert_called_once_with(
163
+ "postgresql://localhost/db",
164
+ kwargs={"autocommit": True},
165
+ min_size=5,
166
+ max_size=30,
167
+ open=False,
168
+ )
169
+ assert conn.dsn == "postgresql://localhost/db"
170
+
171
+ @patch("pyapiary.dbms_connectors.postgres.AsyncConnectionPool")
172
+ def test_creates_pool_with_custom_sizes(self, mock_pool):
173
+ conn = AsyncPostgresConnector("dsn", min_size=2, max_size=20)
174
+ mock_pool.assert_called_once_with(
175
+ "dsn",
176
+ kwargs={"autocommit": True},
177
+ min_size=2,
178
+ max_size=20,
179
+ open=False,
180
+ )
181
+
182
+
183
+ class TestAsyncPostgresConnectorContextManager:
184
+ @pytest.mark.asyncio
185
+ async def test_aenter_opens_pool(self, async_pg):
186
+ async_pg.connection_pool.open = AsyncMock()
187
+ result = await async_pg.__aenter__()
188
+ assert result is async_pg
189
+ async_pg.connection_pool.open.assert_awaited_once()
190
+
191
+ @pytest.mark.asyncio
192
+ async def test_aexit_closes_pool(self, async_pg):
193
+ async_pg.connection_pool.close = AsyncMock()
194
+ await async_pg.__aexit__(None, None, None)
195
+ async_pg.connection_pool.close.assert_awaited_once()
196
+
197
+
198
+ 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
+
204
+ mock_conn = AsyncMock()
205
+ mock_conn.execute.return_value = mock_cursor
206
+
207
+ async_cm = AsyncMock()
208
+ async_cm.__aenter__.return_value = mock_conn
209
+ async_pg.connection_pool.connection.return_value = async_cm
210
+
211
+ result = await async_pg.async_query("SELECT 1")
212
+ assert result == [("row1",)]
213
+ mock_conn.execute.assert_awaited_once_with("SELECT 1", None)
214
+
215
+ @pytest.mark.asyncio
216
+ async def test_async_query_passes_params(self, async_pg):
217
+ mock_cursor = AsyncMock()
218
+ mock_cursor.fetchall.return_value = []
219
+
220
+ mock_conn = AsyncMock()
221
+ mock_conn.execute.return_value = mock_cursor
222
+
223
+ async_cm = AsyncMock()
224
+ async_cm.__aenter__.return_value = mock_conn
225
+ async_pg.connection_pool.connection.return_value = async_cm
226
+
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,))
229
+
230
+
231
+ class TestAsyncPostgresConnectorBulkInsert:
232
+ @pytest.mark.asyncio
233
+ async def test_empty_data_returns_immediately(self, async_pg):
234
+ await async_pg.async_bulk_insert("my_table", [])
235
+ async_pg.connection_pool.connection.assert_not_called()
236
+
237
+ @pytest.mark.asyncio
238
+ async def test_async_bulk_insert_calls_copy(self, async_pg):
239
+ mock_copy = AsyncMock()
240
+
241
+ mock_cursor = MagicMock()
242
+ mock_cursor.copy.return_value.__aenter__ = AsyncMock(return_value=mock_copy)
243
+ mock_cursor.copy.return_value.__aexit__ = AsyncMock(return_value=False)
244
+
245
+ mock_conn = MagicMock()
246
+ mock_conn.cursor.return_value.__aenter__ = AsyncMock(return_value=mock_cursor)
247
+ mock_conn.cursor.return_value.__aexit__ = AsyncMock(return_value=False)
248
+
249
+ async_cm = AsyncMock()
250
+ async_cm.__aenter__.return_value = mock_conn
251
+ async_pg.connection_pool.connection.return_value = async_cm
252
+
253
+ data = [{"name": "alice", "age": 30}, {"name": "bob", "age": 25}]
254
+ await async_pg.async_bulk_insert("users", data)
255
+
256
+ mock_cursor.copy.assert_called_once_with("COPY users (name, age) FROM STDIN")
257
+ assert mock_copy.write_row.await_count == 2
258
+ mock_copy.write_row.assert_any_await(("alice", 30))
259
+ mock_copy.write_row.assert_any_await(("bob", 25))
@@ -1,13 +0,0 @@
1
- import httpx
2
- import pytest
3
- from pyapiary.api_connectors.ipqs import IPQSConnector
4
-
5
- @pytest.mark.integration
6
- def test_ipqs_malicious_url_vcr(vcr_cassette):
7
- with vcr_cassette.use_cassette("test_ipqs_malicious_url_vcr"):
8
- connector = IPQSConnector(load_env_vars=True, enable_logging=True)
9
- result = connector.malicious_url("github.com")
10
-
11
- assert isinstance(result, httpx.Response)
12
- assert "domain" in result.json()
13
- assert result.json()["domain"] == "github.com"