Skip to content

Commit 41bf522

Browse files
Update database clients
1 parent 7aca508 commit 41bf522

11 files changed

Lines changed: 228 additions & 351 deletions

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
# Changelog
2+
## v1.5.0 11/15/24
3+
- Use executemany instead of execute when appropriate in PostgreSQLClient
4+
- Add capability to retry connecting to a database to the MySQL, PostgreSQL, and Redshift clients
5+
- Automatically close database connection upon error in the MySQL, PostgreSQL, and Redshift clients
6+
- Delete old PostgreSQLPoolClient, which was not production ready
7+
28
## v1.4.0 9/23/24
39
- Added SFTP client
410

README.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ This package contains common Python utility classes and functions.
1111
* Downloading files from a remote SSH SFTP server
1212
* Connecting to and querying a MySQL database
1313
* Connecting to and querying a PostgreSQL database
14-
* Connecting to and querying a PostgreSQL database using a connection pool
1514
* Connecting to and querying Redshift
1615
* Making requests to the Oauth2 authenticated APIs such as NYPL Platform API and Sierra
1716

@@ -37,7 +36,7 @@ kinesis_client = KinesisClient(...)
3736
# Do not use any version below 1.0.0
3837
# All available optional dependencies can be found in pyproject.toml.
3938
# See the "Managing dependencies" section below for more details.
40-
nypl-py-utils[kinesis-client,config-helper]==1.4.0
39+
nypl-py-utils[kinesis-client,config-helper]==1.5.0
4140
```
4241

4342
## Developing locally
@@ -63,7 +62,7 @@ The optional dependency sets also give the developer the option to manually list
6362
### Using PostgreSQLClient in an AWS Lambda
6463
Because `psycopg` requires a statically linked version of the `libpq` library, the `PostgreSQLClient` cannot be installed as-is in an AWS Lambda function. Instead, it must be packaged as follows:
6564
```bash
66-
pip install --target ./package nypl-py-utils[postgresql-client]==1.4.0
65+
pip install --target ./package nypl-py-utils[postgresql-client]==1.5.0
6766

6867
pip install \
6968
--platform manylinux2014_x86_64 \

pyproject.toml

Lines changed: 2 additions & 5 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.4.0"
7+
version = "1.5.0"
88
authors = [
99
{ name="Aaron Friedman", email="aaronfriedman@nypl.org" },
1010
]
@@ -45,9 +45,6 @@ oauth2-api-client = [
4545
postgresql-client = [
4646
"psycopg[binary]>=3.1.6"
4747
]
48-
postgresql-pool-client = [
49-
"psycopg[binary,pool]>=3.1.6"
50-
]
5148
redshift-client = [
5249
"botocore>=1.29.5",
5350
"redshift-connector>=2.0.909"
@@ -74,7 +71,7 @@ research-catalog-identifier-helper = [
7471
"requests>=2.28.1"
7572
]
7673
development = [
77-
"nypl_py_utils[avro-client,kinesis-client,kms-client,mysql-client,oauth2-api-client,postgresql-client,postgresql-pool-client,redshift-client,s3-client,secrets-manager-client,sftp-client,config-helper,obfuscation-helper,research-catalog-identifier-helper]",
74+
"nypl_py_utils[avro-client,kinesis-client,kms-client,mysql-client,oauth2-api-client,postgresql-client,redshift-client,s3-client,secrets-manager-client,sftp-client,config-helper,obfuscation-helper,research-catalog-identifier-helper]",
7875
"flake8>=6.0.0",
7976
"freezegun>=1.2.2",
8077
"mock>=4.0.3",

src/nypl_py_utils/classes/mysql_client.py

Lines changed: 42 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import mysql.connector
2+
import time
23

34
from nypl_py_utils.functions.log_helper import create_log
45

@@ -15,35 +16,50 @@ def __init__(self, host, port, database, user, password):
1516
self.user = user
1617
self.password = password
1718

18-
def connect(self, **kwargs):
19+
def connect(self, retry_count=0, backoff_factor=5, **kwargs):
1920
"""
2021
Connects to a MySQL database using the given credentials.
2122
22-
Keyword args can be passed into the connection to set certain options.
23-
All possible arguments can be found here:
24-
https://dev.mysql.com/doc/connector-python/en/connector-python-connectargs.html.
25-
26-
Common arguments include:
27-
autocommit: bool
28-
Whether to automatically commit each query rather than running
29-
them as part of a transaction. By default False.
23+
Parameters
24+
----------
25+
retry_count: int, optional
26+
The number of times to retry connecting before throwing an error.
27+
By default no retry occurs.
28+
backoff_factor: int, optional
29+
The backoff factor when retrying. The amount of time to wait before
30+
retrying is backoff_factor ** number_of_retries_made.
31+
kwargs:
32+
All possible arguments can be found here:
33+
https://dev.mysql.com/doc/connector-python/en/connector-python-connectargs.html
3034
"""
3135
self.logger.info('Connecting to {} database'.format(self.database))
32-
try:
33-
self.conn = mysql.connector.connect(
34-
host=self.host,
35-
port=self.port,
36-
database=self.database,
37-
user=self.user,
38-
password=self.password,
39-
**kwargs)
40-
except mysql.connector.Error as e:
41-
self.logger.error(
42-
'Error connecting to {name} database: {error}'.format(
43-
name=self.database, error=e))
44-
raise MySQLClientError(
45-
'Error connecting to {name} database: {error}'.format(
46-
name=self.database, error=e)) from None
36+
attempt_count = 0
37+
while attempt_count <= retry_count:
38+
try:
39+
try:
40+
self.conn = mysql.connector.connect(
41+
host=self.host,
42+
port=self.port,
43+
database=self.database,
44+
user=self.user,
45+
password=self.password,
46+
**kwargs)
47+
except (mysql.connector.Error):
48+
if attempt_count < retry_count:
49+
self.logger.info('Failed to connect -- retrying')
50+
time.sleep(backoff_factor ** attempt_count)
51+
attempt_count += 1
52+
else:
53+
raise
54+
else:
55+
break
56+
except Exception as e:
57+
self.logger.error(
58+
'Error connecting to {name} database: {error}'.format(
59+
name=self.database, error=e))
60+
raise MySQLClientError(
61+
'Error connecting to {name} database: {error}'.format(
62+
name=self.database, error=e)) from None
4763

4864
def execute_query(self, query, query_params=None, **kwargs):
4965
"""
@@ -83,6 +99,8 @@ def execute_query(self, query, query_params=None, **kwargs):
8399
return cursor.fetchall()
84100
except Exception as e:
85101
self.conn.rollback()
102+
cursor.close()
103+
self.close_connection()
86104
self.logger.error(
87105
('Error executing {name} database query \'{query}\': {error}')
88106
.format(name=self.database, query=query, error=e))

src/nypl_py_utils/classes/postgresql_client.py

Lines changed: 59 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,60 @@
11
import psycopg
2+
import time
23

34
from nypl_py_utils.functions.log_helper import create_log
45

56

67
class PostgreSQLClient:
78
"""Client for managing individual connections to a PostgreSQL database"""
89

9-
def __init__(self, host, port, db_name, user, password):
10+
def __init__(self, host, port, database, user, password):
1011
self.logger = create_log('postgresql_client')
1112
self.conn = None
1213
self.conn_info = ('postgresql://{user}:{password}@{host}:{port}/'
13-
'{db_name}').format(user=user, password=password,
14-
host=host, port=port,
15-
db_name=db_name)
14+
'{database}').format(user=user, password=password,
15+
host=host, port=port,
16+
database=database)
17+
self.database = database
1618

17-
self.db_name = db_name
18-
19-
def connect(self, **kwargs):
19+
def connect(self, retry_count=0, backoff_factor=5, **kwargs):
2020
"""
2121
Connects to a PostgreSQL database using the given credentials.
2222
23-
Keyword args can be passed into the connection to set certain options.
24-
All possible arguments can be found here:
25-
https://www.psycopg.org/psycopg3/docs/api/connections.html#psycopg.Connection.connect.
26-
27-
Common arguments include:
28-
autocommit: bool
29-
Whether to automatically commit each query rather than running
30-
them as part of a transaction. By default False.
31-
row_factory: RowFactory
32-
A psycopg RowFactory that determines how the data will be
33-
returned. Defaults to tuple_row, which returns the rows as a
34-
list of tuples.
23+
Parameters
24+
----------
25+
retry_count: int, optional
26+
The number of times to retry connecting before throwing an error.
27+
By default no retry occurs.
28+
backoff_factor: int, optional
29+
The backoff factor when retrying. The amount of time to wait before
30+
retrying is backoff_factor ** number_of_retries_made.
31+
kwargs:
32+
All possible arguments (such as the row_factory) can be found here:
33+
https://www.psycopg.org/psycopg3/docs/api/connections.html#psycopg.Connection.connect
3534
"""
36-
self.logger.info('Connecting to {} database'.format(self.db_name))
37-
try:
38-
self.conn = psycopg.connect(self.conn_info, **kwargs)
39-
except psycopg.Error as e:
40-
self.logger.error(
41-
'Error connecting to {name} database: {error}'.format(
42-
name=self.db_name, error=e))
43-
raise PostgreSQLClientError(
44-
'Error connecting to {name} database: {error}'.format(
45-
name=self.db_name, error=e)) from None
35+
self.logger.info('Connecting to {} database'.format(self.database))
36+
attempt_count = 0
37+
while attempt_count <= retry_count:
38+
try:
39+
try:
40+
self.conn = psycopg.connect(self.conn_info, **kwargs)
41+
except (psycopg.OperationalError,
42+
psycopg.errors.ConnectionTimeout):
43+
if attempt_count < retry_count:
44+
self.logger.info('Failed to connect -- retrying')
45+
time.sleep(backoff_factor ** attempt_count)
46+
attempt_count += 1
47+
else:
48+
raise
49+
else:
50+
break
51+
except Exception as e:
52+
self.logger.error(
53+
'Error connecting to {name} database: {error}'.format(
54+
name=self.database, error=e))
55+
raise PostgreSQLClientError(
56+
'Error connecting to {name} database: {error}'.format(
57+
name=self.database, error=e)) from None
4658

4759
def execute_query(self, query, query_params=None, **kwargs):
4860
"""
@@ -53,7 +65,11 @@ def execute_query(self, query, query_params=None, **kwargs):
5365
query: str
5466
The query to execute
5567
query_params: sequence, optional
56-
The values to be used in a parameterized query
68+
The values to be used in a parameterized query. The values can be
69+
for a single insert query -- e.g. execute_query(
70+
"INSERT INTO x VALUES (%s, %s)", (1, "a"))
71+
or for multiple -- e.g execute_transaction(
72+
"INSERT INTO x VALUES (%s, %s)", [(1, "a"), (2, "b")])
5773
kwargs:
5874
All possible arguments can be found here:
5975
https://www.psycopg.org/psycopg3/docs/api/cursors.html#psycopg.Cursor.execute
@@ -65,30 +81,38 @@ def execute_query(self, query, query_params=None, **kwargs):
6581
based on the connection's row_factory if there's something to
6682
return (even if the result set is empty).
6783
"""
68-
self.logger.info('Querying {} database'.format(self.db_name))
84+
self.logger.info('Querying {} database'.format(self.database))
6985
self.logger.debug('Executing query {}'.format(query))
7086
try:
7187
cursor = self.conn.cursor()
72-
cursor.execute(query, query_params, **kwargs)
88+
if query_params is not None and all(
89+
isinstance(param, tuple) or isinstance(param, list)
90+
for param in query_params
91+
):
92+
cursor.executemany(query, query_params, **kwargs)
93+
else:
94+
cursor.execute(query, query_params, **kwargs)
7395
self.conn.commit()
7496
return None if cursor.description is None else cursor.fetchall()
7597
except Exception as e:
7698
self.conn.rollback()
99+
cursor.close()
100+
self.close_connection()
77101
self.logger.error(
78102
('Error executing {name} database query \'{query}\': '
79103
'{error}').format(
80-
name=self.db_name, query=query, error=e))
104+
name=self.database, query=query, error=e))
81105
raise PostgreSQLClientError(
82106
('Error executing {name} database query \'{query}\': '
83107
'{error}').format(
84-
name=self.db_name, query=query, error=e)) from None
108+
name=self.database, query=query, error=e)) from None
85109
finally:
86110
cursor.close()
87111

88112
def close_connection(self):
89113
"""Closes the database connection"""
90114
self.logger.debug('Closing {} database connection'.format(
91-
self.db_name))
115+
self.database))
92116
self.conn.close()
93117

94118

0 commit comments

Comments
 (0)