Skip to content

Commit 8e29458

Browse files
Merge branch 'main' into qa - v1.0.0
2 parents 7bba3a2 + 2b70e38 commit 8e29458

20 files changed

Lines changed: 566 additions & 269 deletions

.github/workflows/run-unit-tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ jobs:
2828
run: |
2929
python -m pip install --upgrade pip
3030
pip install .
31-
pip install '.[tests]'
31+
pip install '.[development]'
3232
3333
- name: Run linter and test suite
3434
run: |

CHANGELOG.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## v1.0.0 - 3/22/23
4+
- Improve Oauth2ApiClient token refresh and method responses
5+
- Create separate PostgreSQLClient and PostgreSQLPoolClient classes
6+
- Update PostgreSQL and MySQL clients to accept write queries implicitly
7+
- Update RedshiftClient to ensure SSL is being used
8+
- Separate dependencies to slim down package installation
9+
310
## v0.0.7 - 3/1/23
411
- Added Oauth2ApiClient for oauth2 authenticated calls to our Platform API and Sierra
512
- Set PostgreSQL connection pool to have a default pool size minimum of 0
@@ -13,17 +20,14 @@
1320
- Updated README with deployment information
1421

1522
## v0.0.3 - 2/10/23
16-
1723
- Added GitHub Actions workflow for deploying to production
1824
- Switched PostgreSQLClient to use connection pooling
1925

2026
## v0.0.2 - 2/6/23
21-
2227
- Added CODEOWNERS
2328
- Added GitHub Actions workflows for running tests and deploying to QA
2429
- Added tests for helper functions
2530
- Updated Avro encoder to avoid dependency on pandas
2631

2732
## v0.0.1 - 1/26/23
28-
2933
Initial version. Includes the `avro_encoder`, `kinesis_client`, `mysql_client`, `postgresql_client`, `redshift_client`, and `s3_client` classes as well as the `config_helper`, `kms_helper`, `log_helper`, and `obfuscation_helper` functions.

README.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ This package contains common Python utility classes and functions.
88
* Decrypting values with KMS
99
* Encoding and decoding records using a given Avro schema
1010
* Connecting to and querying a MySQL database
11+
* Connecting to and querying a PostgreSQL database
1112
* Connecting to and querying a PostgreSQL database using a connection pool
1213
* Connecting to and querying Redshift
13-
* Making requests to the Oauth2 authenticated APIS such as NYPL Platform API and Sierra
14+
* Making requests to the Oauth2 authenticated APIs such as NYPL Platform API and Sierra
1415

1516
## Functions
1617
* Reading a YAML config file and putting the contents in os.environ
@@ -25,11 +26,16 @@ python3 -m venv testenv
2526
source testenv/bin/activate
2627
pip install --upgrade pip
2728
pip install .
28-
pip install '.[tests]'
29+
pip install '.[development]'
2930
deactivate && source testenv/bin/activate
3031
```
3132

32-
Add any new dependencies required by code in the `nypl_py_utils` directory to the `dependencies` section of `pyproject.toml`. Add dependencies only required by code in the `tests` directory to the `[project.optional-dependencies]` section.
33+
## Managing dependencies
34+
In order to prevent dependency bloat, this package has no required dependencies. Instead, each class and helper file has its own optional dependency set. For instance, if an app needs to use the KMS client and the obfuscation helper, it should add `nypl-py-utils[kms-client, obfuscation-helper]` to the app's requirements. This way, only the required dependencies are installed.
35+
36+
When a new client or helper file is created, a new optional dependency set should be added to `pyproject.toml`. The `development` dependency set, which includes all the dependencies required by all of the classes and tests, should also be updated.
37+
38+
The optional dependency sets also give the developer the option to manually list out the dependencies of the clients rather than relying upon what the package thinks is required, which can be beneficial in certain circumstances. For instance, AWS lambda functions come with `boto3` and `botocore` pre-installed, so it's not necessary to include these (rather hefty) dependencies in the lambda deployment package.
3339

3440
### Troubleshooting
3541
If running `main.py` in this virtual environment produces the following error:

pyproject.toml

Lines changed: 45 additions & 16 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 = "0.0.7"
7+
version = "1.0.0"
88
authors = [
99
{ name="Aaron Friedman", email="aaronfriedman@nypl.org" },
1010
]
@@ -16,30 +16,59 @@ classifiers = [
1616
"License :: OSI Approved :: MIT License",
1717
"Operating System :: OS Independent",
1818
]
19-
dependencies = [
19+
dependencies = []
20+
21+
[project.urls]
22+
"Homepage" = "https://github.com/NYPL/python-utils"
23+
"Bug Tracker" = "https://github.com/NYPL/python-utils/issues"
24+
25+
[project.optional-dependencies]
26+
avro-encoder = [
2027
"avro>=1.11.1",
21-
"bcrypt>=4.0.1",
28+
"requests>=2.28.1"
29+
]
30+
kinesis-client = [
2231
"boto3>=1.26.5",
23-
"botocore>=1.29.5",
24-
"mysql-connector-python>=8.0.32",
32+
"botocore>=1.29.5"
33+
]
34+
kms-client = [
35+
"boto3>=1.26.5",
36+
"botocore>=1.29.5"
37+
]
38+
mysql-client = [
39+
"mysql-connector-python>=8.0.32"
40+
]
41+
oauth2-api-client = [
2542
"oauthlib>=3.2.2",
26-
"psycopg[binary,pool]>=3.1.6",
27-
"PyYAML>=6.0",
28-
"redshift-connector>=2.0.909",
29-
"requests>=2.28.1",
3043
"requests_oauthlib>=1.3.1"
3144
]
32-
33-
[project.optional-dependencies]
34-
tests = [
45+
postgresql-client = [
46+
"psycopg[binary]>=3.1.6"
47+
]
48+
postgresql-pool-client = [
49+
"psycopg[binary,pool]>=3.1.6"
50+
]
51+
redshift-client = [
52+
"botocore>=1.29.5",
53+
"redshift-connector>=2.0.909"
54+
]
55+
s3-client = [
56+
"boto3>=1.26.5",
57+
"botocore>=1.29.5"
58+
]
59+
config-helper = [
60+
"nypl_py_utils[kms-client]",
61+
"PyYAML>=6.0"
62+
]
63+
obfuscation-helper = [
64+
"bcrypt>=4.0.1"
65+
]
66+
development = [
67+
"nypl_py_utils[avro-encoder,kinesis-client,kms-client,mysql-client,oauth2-api-client,postgresql-client,postgresql-pool-client,redshift-client,s3-client,config-helper,obfuscation-helper]",
3568
"flake8>=6.0.0",
3669
"freezegun>=1.2.2",
3770
"mock>=4.0.3",
3871
"pytest>=7.2.0",
3972
"pytest-mock>=3.10.0",
4073
"requests-mock>=1.10.0"
4174
]
42-
43-
[project.urls]
44-
"Homepage" = "https://github.com/NYPL/python-utils"
45-
"Bug Tracker" = "https://github.com/NYPL/python-utils/issues"

src/nypl_py_utils/__init__.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +0,0 @@
1-
from .classes.avro_encoder import AvroEncoder, AvroEncoderError # noqa
2-
from .classes.kinesis_client import KinesisClient, KinesisClientError # noqa
3-
from .classes.kms_client import KmsClient, KmsClientError # noqa
4-
from .classes.mysql_client import MySQLClient, MySQLClientError # noqa
5-
from .classes.oauth2_api_client import Oauth2ApiClient # noqa
6-
from .classes.postgresql_client import PostgreSQLClient, PostgreSQLClientError # noqa
7-
from .classes.redshift_client import RedshiftClient, RedshiftClientError # noqa
8-
from .classes.s3_client import S3Client, S3ClientError # noqa

src/nypl_py_utils/classes/mysql_client.py

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,28 @@ def __init__(self, host, port, database, user, password):
1515
self.user = user
1616
self.password = password
1717

18-
def connect(self):
19-
"""Connects to a MySQL database using the given credentials"""
18+
def connect(self, **kwargs):
19+
"""
20+
Connects to a MySQL database using the given credentials.
21+
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.
30+
"""
2031
self.logger.info('Connecting to {} database'.format(self.database))
2132
try:
2233
self.conn = mysql.connector.connect(
2334
host=self.host,
2435
port=self.port,
2536
database=self.database,
2637
user=self.user,
27-
password=self.password)
38+
password=self.password,
39+
**kwargs)
2840
except mysql.connector.Error as e:
2941
self.logger.error(
3042
'Error connecting to {name} database: {error}'.format(
@@ -33,37 +45,38 @@ def connect(self):
3345
'Error connecting to {name} database: {error}'.format(
3446
name=self.database, error=e)) from None
3547

36-
def execute_query(self, query, is_write_query=False, query_params=None,
37-
dictionary=False):
48+
def execute_query(self, query, query_params=None, **kwargs):
3849
"""
3950
Executes an arbitrary query against the given database connection.
4051
4152
Parameters
4253
----------
4354
query: str
4455
The query to execute
45-
is_write_query: bool, optional
46-
Whether or not the query is writing to the database, in which case
47-
the transaction needs to be committed and None should be returned
4856
query_params: sequence, optional
4957
The values to be used in a parameterized query
50-
dictionary: bool, optional
51-
Whether the data will be returned as a dictionary. Defaults to
52-
False, which means the data is returned as a list of tuples.
58+
kwargs:
59+
All possible arguments can be found here:
60+
https://dev.mysql.com/doc/connector-python/en/connector-python-api-mysqlconnection-cursor.html.
61+
62+
Common arguments include:
63+
dictionary: bool
64+
Whether the data will be returned as a dictionary. Defaults
65+
to False, meaning the data is returned as a list of tuples.
5366
5467
Returns
5568
-------
5669
None or sequence
57-
None if is_write_query is True. A list of either tuples or
58-
dictionaries (based on the dictionary input) if is_write_query is
59-
False.
70+
None if the cursor has nothing to return. A list of either tuples
71+
or dictionaries (based on the dictionary input) if there's
72+
something to return (even if the result set is empty).
6073
"""
6174
self.logger.info('Querying {} database'.format(self.database))
6275
self.logger.debug('Executing query {}'.format(query))
6376
try:
64-
cursor = self.conn.cursor(dictionary=dictionary)
77+
cursor = self.conn.cursor(**kwargs)
6578
cursor.execute(query, query_params)
66-
if is_write_query:
79+
if cursor.description is None:
6780
self.conn.commit()
6881
return None
6982
else:

src/nypl_py_utils/classes/oauth2_api_client.py

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,7 @@ def __init__(self, client_id=None, client_secret=None, base_url=None,
2121
self.base_url = base_url \
2222
or os.environ.get('NYPL_API_BASE_URL', None)
2323

24-
self.client = None
25-
self.token = None
24+
self.oauth_client = None
2625

2726
self.logger = create_log('oauth2_api_client')
2827

@@ -56,39 +55,52 @@ def _do_http_method(self, method, request_path, **kwargs):
5655
"""
5756
Issue an HTTP method call on on the given request_path
5857
"""
59-
if not self.client:
58+
if not self.oauth_client:
6059
self._create_oauth_client()
6160

6261
url = f'{self.base_url}/{request_path}'
6362
self.logger.debug(f'{method} {url}')
6463

6564
try:
66-
return self.oauth_client.request(method, url, **kwargs).json()
67-
except TokenExpiredError as e:
68-
self.logger.debug(f'TokenExpiredError encountered: {e}')
69-
self._generate_access_token()
65+
# Build kwargs cleaned of local variables:
66+
kwargs_cleaned = {k: kwargs[k] for k in kwargs
67+
if not k.startswith('_do_http_method_')}
68+
resp = self.oauth_client.request(method, url, **kwargs_cleaned)
69+
resp.raise_for_status()
70+
return resp
71+
except TokenExpiredError:
72+
self.logger.debug('TokenExpiredError encountered')
73+
74+
# Raise error after 3 successive token refreshes
75+
kwargs['_do_http_method_token_refreshes'] = \
76+
kwargs.get('_do_http_method_token_refreshes', 0) + 1
77+
if kwargs['_do_http_method_token_refreshes'] > 3:
78+
raise Oauth2ApiClientError('Exhausted token refreshes') \
79+
from None
7080

81+
self._generate_access_token()
7182
return self._do_http_method(method, request_path, **kwargs)
72-
except TimeoutError as e:
73-
self.logger.error(f'TimeoutError encountered: {e}')
74-
return {}
7583

7684
def _create_oauth_client(self):
7785
"""
7886
Creates an authenticated a OAuth2Session instance for later requests
7987
"""
88+
client = BackendApplicationClient(client_id=self.client_id)
89+
self.oauth_client = OAuth2Session(client=client)
8090
self._generate_access_token()
81-
self.oauth_client = OAuth2Session(self.client_id, token=self.token)
8291

8392
def _generate_access_token(self):
8493
"""
8594
Fetch and store a fresh token
8695
"""
87-
client = BackendApplicationClient(client_id=self.client_id)
88-
oauth = OAuth2Session(client=client)
8996
self.logger.debug(f'Refreshing token via @{self.token_url}')
90-
self.token = oauth.fetch_token(
97+
self.oauth_client.fetch_token(
9198
token_url=self.token_url,
9299
client_id=self.client_id,
93100
client_secret=self.client_secret
94101
)
102+
103+
104+
class Oauth2ApiClientError(Exception):
105+
def __init__(self, message=None):
106+
self.message = message

0 commit comments

Comments
 (0)