Skip to content

Commit 08358d0

Browse files
authored
Merge pull request #72 from NYPL/azure-client-setup
Add AzureClient
2 parents 45f7245 + c37e86d commit 08358d0

5 files changed

Lines changed: 351 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
# Changelog
2+
## v1.12.0 6/15/26
3+
- Add Azure client
4+
25
## v1.11.1 5/20/26
36
- Reset Snowflake connection to None when it's closed
47

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ This package contains common Python utility classes and functions.
99
* Encoding and decoding records using a given Avro schema
1010
* Retrieving secrets from AWS Secrets Manager
1111
* Downloading files from a remote SSH SFTP server
12+
* Connecting to and querying an Azure SQL database
1213
* Connecting to and querying a MySQL database
1314
* Connecting to and querying a PostgreSQL database
1415
* Connecting to and querying Redshift

pyproject.toml

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "nypl_py_utils"
7-
version = "1.11.1"
7+
version = "1.12.0"
88
authors = [
99
{ name="Aaron Friedman", email="aaronfriedman@nypl.org" },
1010
]
@@ -28,6 +28,11 @@ avro-client = [
2828
"fastavro==1.12.2",
2929
"requests==2.34.0"
3030
]
31+
azure-client = [
32+
"nypl_py_utils[log-helper]",
33+
"mssql-python==1.9.0",
34+
"pandas==3.0.3"
35+
]
3136
cloudlibrary-client = [
3237
"nypl_py_utils[log-helper]",
3338
"requests==2.34.0"
@@ -91,13 +96,13 @@ obfuscation-helper = [
9196
]
9297
patron-data-helper = [
9398
"nypl_py_utils[postgresql-client,redshift-client,log-helper]",
94-
"pandas==3.0.2"
99+
"pandas==3.0.3"
95100
]
96101
research-catalog-identifier-helper = [
97102
"requests==2.34.0"
98103
]
99104
development = [
100-
"nypl_py_utils[avro-client,cloudlibrary-client,kinesis-client,kms-client,mysql-client,oauth2-api-client,postgresql-client,redshift-client,s3-client,secrets-manager-client,sftp-client,snowflake-client,config-helper,log-helper,obfuscation-helper,patron-data-helper,research-catalog-identifier-helper]",
105+
"nypl_py_utils[avro-client,azure-client,cloudlibrary-client,kinesis-client,kms-client,mysql-client,oauth2-api-client,postgresql-client,redshift-client,s3-client,secrets-manager-client,sftp-client,snowflake-client,config-helper,log-helper,obfuscation-helper,patron-data-helper,research-catalog-identifier-helper]",
101106
"flake8==7.3.0",
102107
"freezegun==1.5.5",
103108
"mock==5.2.0",
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import mssql_python
2+
import pandas as pd
3+
import time
4+
5+
from contextlib import closing
6+
from nypl_py_utils.functions.log_helper import create_log
7+
8+
9+
class AzureClient:
10+
"""Class for managing connections to a Microsoft Azure SQL database"""
11+
12+
def __init__(self, server, database, user, password):
13+
self.logger = create_log("azure_client")
14+
self.server = server
15+
self.database = database
16+
self.user = user
17+
self.password = password
18+
self.conn = None
19+
20+
def connect(self, retry_count=0, backoff_factor=5):
21+
"""
22+
Connects to an Azure database using the given credentials.
23+
24+
Parameters
25+
----------
26+
retry_count: int, optional
27+
The number of times to retry connecting before throwing an error.
28+
By default no retry occurs.
29+
backoff_factor: int, optional
30+
The backoff factor when retrying. The amount of time to wait before
31+
retrying is backoff_factor ** number_of_retries_made.
32+
"""
33+
self.logger.info(f"Connecting to {self.database} database...")
34+
35+
# Close any existing connection first so reconnecting doesn't leak it
36+
self.close_connection()
37+
38+
attempt_count = 0
39+
while attempt_count <= retry_count:
40+
try:
41+
try:
42+
connection_string = (
43+
f"Server={self.server};"
44+
f"Database={self.database};"
45+
f"UID={self.user};"
46+
f"PWD={self.password};"
47+
f"Encrypt=yes;"
48+
)
49+
self.conn = mssql_python.connect(
50+
connection_str=connection_string,
51+
timeout=30,
52+
)
53+
self.conn.setencoding(encoding="utf-8")
54+
self.conn.setdecoding(
55+
sqltype=mssql_python.SQL_WCHAR, encoding="utf-8"
56+
)
57+
return
58+
except (mssql_python.InterfaceError,
59+
mssql_python.OperationalError):
60+
if attempt_count < retry_count:
61+
self.logger.info("Failed to connect — retrying")
62+
time.sleep(backoff_factor**attempt_count)
63+
attempt_count += 1
64+
else:
65+
raise
66+
except Exception as e:
67+
msg = f"Error connecting to {self.database} database: {e}"
68+
self.logger.error(msg)
69+
raise AzureClientError(msg) from e
70+
71+
def execute_query(self, query: str, params=None, dataframe=False):
72+
"""
73+
Executes an arbitrary SQL read query against the database.
74+
75+
Parameters
76+
----------
77+
query: str
78+
The query to execute, assumed to be a read query
79+
params: tuple or list, optional
80+
The parameters to pass into the query, if any. Defaults to None.
81+
dataframe: bool, optional
82+
Whether the data will be returned as a pandas DataFrame. Defaults
83+
to False, which means the data is returned as a list of tuples.
84+
85+
Returns
86+
-------
87+
None or sequence
88+
A list of tuples or a pandas DataFrame (based on the `dataframe`
89+
input)
90+
"""
91+
if not self.conn:
92+
msg = "No active database connection"
93+
self.logger.error(msg)
94+
raise AzureClientError(msg)
95+
96+
try:
97+
# Automatically closes cursor when done, even if there's an error
98+
with closing(self.conn.cursor()) as cursor:
99+
if params is not None:
100+
cursor.execute(query, params)
101+
else:
102+
cursor.execute(query)
103+
if dataframe:
104+
columns = [col[0] for col in cursor.description]
105+
return pd.DataFrame.from_records(
106+
cursor.fetchall(), columns=columns)
107+
return cursor.fetchall()
108+
except Exception as e:
109+
self.close_connection()
110+
msg = f"Error executing {self.database} query '{query}': {e}"
111+
self.logger.error(msg)
112+
raise AzureClientError(msg) from e
113+
114+
def close_connection(self):
115+
"""Rolls back any open transaction and closes the connection"""
116+
if self.conn:
117+
# A rollback failure is logged but doesn't prevent the close
118+
try:
119+
self.conn.rollback()
120+
except Exception:
121+
self.logger.error("Error rolling back open transaction")
122+
self.conn.close()
123+
self.conn = None
124+
self.logger.info(f"Connection to {self.database} closed.")
125+
126+
127+
class AzureClientError(Exception):
128+
"""Custom exception for AzureClient errors"""
129+
130+
def __init__(self, message=None):
131+
super().__init__(message)
132+
self.message = message

tests/test_azure_client.py

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import mssql_python
2+
import pandas as pd
3+
import pytest
4+
5+
from nypl_py_utils.classes.azure_client import AzureClient, AzureClientError
6+
from pandas.testing import assert_frame_equal
7+
8+
9+
class TestAzureClient:
10+
@pytest.fixture
11+
def mock_azure_conn(self, mocker):
12+
return mocker.patch(
13+
"nypl_py_utils.classes.azure_client.mssql_python.connect")
14+
15+
@pytest.fixture
16+
def test_instance(self):
17+
return AzureClient(
18+
server="test_server",
19+
database="test_database",
20+
user="test_user",
21+
password="test_password",
22+
)
23+
24+
def test_connect_success(self, mock_azure_conn, test_instance):
25+
test_instance.connect()
26+
27+
assert test_instance.conn == mock_azure_conn.return_value
28+
mock_azure_conn.return_value.setencoding.assert_called_once_with(
29+
encoding="utf-8"
30+
)
31+
mock_azure_conn.return_value.setdecoding.assert_called_once_with(
32+
sqltype=mssql_python.SQL_WCHAR, encoding="utf-8"
33+
)
34+
# credentials are interpolated into connection string
35+
connection_str = mock_azure_conn.call_args.kwargs["connection_str"]
36+
assert connection_str == (
37+
"Server=test_server;Database=test_database;"
38+
"UID=test_user;PWD=test_password;Encrypt=yes;"
39+
)
40+
41+
def test_connect_retry_success(
42+
self, mock_azure_conn, test_instance, mocker, caplog
43+
):
44+
mock_sleep = mocker.patch(
45+
"nypl_py_utils.classes.azure_client.time.sleep")
46+
success_conn = mocker.MagicMock()
47+
mock_azure_conn.side_effect = [
48+
mssql_python.OperationalError("busy", "ddbc busy"),
49+
success_conn,
50+
]
51+
with caplog.at_level("ERROR"):
52+
test_instance.connect(retry_count=2, backoff_factor=2)
53+
54+
assert test_instance.conn == success_conn
55+
assert mock_azure_conn.call_count == 2
56+
mock_sleep.assert_called_once_with(2**0)
57+
assert caplog.text == ""
58+
59+
def test_connect_retry_fail(
60+
self, mock_azure_conn, test_instance, mocker, caplog
61+
):
62+
mocker.patch("nypl_py_utils.classes.azure_client.time.sleep")
63+
mock_azure_conn.side_effect = mssql_python.OperationalError(
64+
"still busy", "ddbc busy"
65+
)
66+
67+
with pytest.raises(AzureClientError):
68+
test_instance.connect(retry_count=2, backoff_factor=2)
69+
70+
# retry_count=2 -> three attempts total before giving up
71+
assert mock_azure_conn.call_count == 3
72+
assert "Error connecting to test_database database" in caplog.text
73+
74+
def test_connect_unexpected_error(
75+
self, mock_azure_conn, test_instance, caplog
76+
):
77+
mock_azure_conn.side_effect = ValueError("uh oh")
78+
79+
with pytest.raises(AzureClientError):
80+
test_instance.connect(retry_count=3)
81+
82+
assert mock_azure_conn.call_count == 1
83+
assert (
84+
"Error connecting to test_database database: uh oh" in caplog.text
85+
)
86+
87+
def test_execute_query_no_params_success(
88+
self, mock_azure_conn, test_instance, mocker
89+
):
90+
test_instance.connect()
91+
mock_cursor = mocker.MagicMock()
92+
mock_cursor.fetchall.return_value = [(1, 2), (3, 4)]
93+
test_instance.conn.cursor.return_value = mock_cursor
94+
95+
result = test_instance.execute_query("SELECT * FROM t")
96+
97+
assert result == [(1, 2), (3, 4)]
98+
mock_cursor.execute.assert_called_once_with("SELECT * FROM t")
99+
mock_cursor.close.assert_called_once()
100+
101+
def test_execute_query_with_params_success(
102+
self, mock_azure_conn, test_instance, mocker
103+
):
104+
test_instance.connect()
105+
mock_cursor = mocker.MagicMock()
106+
mock_cursor.fetchall.return_value = []
107+
test_instance.conn.cursor.return_value = mock_cursor
108+
109+
test_instance.execute_query("SELECT ?", params=("a",))
110+
111+
mock_cursor.execute.assert_called_once_with("SELECT ?", ("a",))
112+
113+
def test_execute_query_no_params_returns_dataframe_success(
114+
self, mock_azure_conn, test_instance, mocker
115+
):
116+
test_instance.connect()
117+
mock_cursor = mocker.MagicMock()
118+
mock_cursor.description = [("col1",), ("col2",)]
119+
mock_cursor.fetchall.return_value = [(1, 2), (3, 4)]
120+
test_instance.conn.cursor.return_value = mock_cursor
121+
122+
result = test_instance.execute_query("SELECT * FROM t", dataframe=True)
123+
124+
expected = pd.DataFrame({"col1": [1, 3], "col2": [2, 4]})
125+
assert_frame_equal(result, expected)
126+
127+
def test_execute_query_with_params_returns_dataframe_success(
128+
self, mock_azure_conn, test_instance, mocker
129+
):
130+
test_instance.connect()
131+
mock_cursor = mocker.MagicMock()
132+
mock_cursor.description = [("col1",), ("col2",)]
133+
mock_cursor.fetchall.return_value = [(1, 2), (3, 4)]
134+
test_instance.conn.cursor.return_value = mock_cursor
135+
expected = pd.DataFrame({"col1": [1, 3], "col2": [2, 4]})
136+
137+
result = test_instance.execute_query(
138+
"SELECT * FROM t WHERE col1 = ?", params=("a",), dataframe=True
139+
)
140+
141+
mock_cursor.execute.assert_called_once_with(
142+
"SELECT * FROM t WHERE col1 = ?", ("a",)
143+
)
144+
assert_frame_equal(result, expected)
145+
146+
def test_execute_query_fail(
147+
self, mock_azure_conn, test_instance, mocker, caplog
148+
):
149+
test_instance.connect()
150+
mock_conn = test_instance.conn
151+
mock_cursor = mocker.MagicMock()
152+
mock_cursor.execute.side_effect = Exception("bad query")
153+
mock_conn.cursor.return_value = mock_cursor
154+
155+
with pytest.raises(AzureClientError):
156+
test_instance.execute_query("SELECT bad")
157+
158+
mock_conn.rollback.assert_called_once()
159+
mock_conn.close.assert_called_once()
160+
assert test_instance.conn is None
161+
mock_cursor.close.assert_called_once()
162+
assert (
163+
"Error executing test_database query 'SELECT bad'" in caplog.text
164+
)
165+
166+
def test_execute_query_fail_with_rollback_error(
167+
self, mock_azure_conn, test_instance, mocker, caplog
168+
):
169+
test_instance.connect()
170+
mock_conn = test_instance.conn
171+
mock_cursor = mocker.MagicMock()
172+
mock_cursor.execute.side_effect = Exception("bad query")
173+
mock_conn.cursor.return_value = mock_cursor
174+
mock_conn.rollback.side_effect = Exception("rollback issue")
175+
176+
with pytest.raises(AzureClientError):
177+
test_instance.execute_query("SELECT bad")
178+
179+
mock_conn.close.assert_called_once()
180+
assert test_instance.conn is None
181+
assert "Error rolling back open transaction" in caplog.text
182+
assert (
183+
"Error executing test_database query 'SELECT bad'" in caplog.text
184+
)
185+
186+
def test_execute_query_without_connection(self, test_instance, caplog):
187+
assert test_instance.conn is None
188+
189+
with pytest.raises(AzureClientError):
190+
test_instance.execute_query("SELECT 1")
191+
192+
assert "No active database connection" in caplog.text
193+
194+
def test_close_connection_success(self, mock_azure_conn, test_instance):
195+
test_instance.connect()
196+
mock_conn = test_instance.conn
197+
198+
test_instance.close_connection()
199+
200+
mock_conn.close.assert_called_once()
201+
assert test_instance.conn is None
202+
203+
def test_close_connection_when_already_closed(self, test_instance):
204+
# no connection -> nothing to close, so nothing happens & no error
205+
assert test_instance.conn is None
206+
test_instance.close_connection()
207+
assert test_instance.conn is None

0 commit comments

Comments
 (0)