Skip to content

Commit cc90a09

Browse files
committed
Merge latest from main
2 parents 91cc58e + ed9ebb3 commit cc90a09

20 files changed

Lines changed: 665 additions & 268 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: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
# Changelog
22

3-
## v0.0.9 - 5/18/23
3+
## v1.0.2 - 5/18/23
44
- Add research_catalog_identifier_helper function
55

6-
## v0.0.8 - 3/3/23
7-
- Pass in all kwargs from PostgreSQLClient to ConnectionPool so that all ConnectionPool settings
8-
can be set from the wrapper
6+
## v1.0.1 - 4/3/23
7+
- Add transaction support to RedshiftClient
8+
9+
## v1.0.0 - 3/22/23
10+
- Improve Oauth2ApiClient token refresh and method responses
11+
- Create separate PostgreSQLClient and PostgreSQLPoolClient classes
12+
- Update PostgreSQL and MySQL clients to accept write queries implicitly
13+
- Update RedshiftClient to ensure SSL is being used
14+
- Separate dependencies to slim down package installation
915

1016
## v0.0.7 - 3/1/23
1117
- Added Oauth2ApiClient for oauth2 authenticated calls to our Platform API and Sierra

README.md

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ 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
1314
* Making requests to the Oauth2 authenticated APIs such as NYPL Platform API and Sierra
@@ -17,6 +18,25 @@ This package contains common Python utility classes and functions.
1718
* Creating a logger in the appropriate format
1819
* Obfuscating a value using bcrypt
1920

21+
## Usage
22+
```python
23+
# test_file.py
24+
from nypl_py_utils.classes.kinesis_client import KinesisClient
25+
from nypl_py_utils.functions.config_helper import load_env_file
26+
27+
load_env_file(...)
28+
kinesis_client = KinesisClient(...)
29+
```
30+
31+
```bash
32+
# requirements.txt
33+
34+
# Do not use any version below 1.0.0
35+
# All available optional dependencies can be found in pyproject.toml.
36+
# See the "Managing dependencies" section below for more details.
37+
nypl-py-utils[kinesis-client,config-helper]==1.0.1
38+
```
39+
2040
## Developing locally
2141
In order to use the local version of the package instead of the global version, use a virtual environment. To set up a virtual environment and install all the necessary dependencies, run:
2242

@@ -25,11 +45,16 @@ python3 -m venv testenv
2545
source testenv/bin/activate
2646
pip install --upgrade pip
2747
pip install .
28-
pip install '.[tests]'
48+
pip install '.[development]'
2949
deactivate && source testenv/bin/activate
3050
```
3151

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.
52+
## Managing dependencies
53+
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.
54+
55+
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.
56+
57+
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.
3358

3459
### Troubleshooting
3560
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.8"
7+
version = "1.0.1"
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)