Skip to content

Commit 10550cf

Browse files
Merge branch 'main' into qa
2 parents 0f3152b + 5bd57aa commit 10550cf

9 files changed

Lines changed: 169 additions & 27 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
# Changelog
2+
## v1.3.0 7/30/24
3+
- Added SecretsManager client
4+
5+
## v1.2.1 7/25/24
6+
- Add retry for fetching Avro schemas
7+
28
## v1.2.0 7/17/24
3-
- Generalized Avro functions and separated encoding/decoding behavior.
9+
- Generalized Avro functions and separated encoding/decoding behavior
410

511
## v1.1.6 7/12/24
612
- Add put functionality to Oauth2 Client

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ This package contains common Python utility classes and functions.
77
* Setting and retrieving a resource in S3
88
* Decrypting values with KMS
99
* Encoding and decoding records using a given Avro schema
10+
* Retrieving secrets from AWS Secrets Manager
1011
* Connecting to and querying a MySQL database
1112
* Connecting to and querying a PostgreSQL database
1213
* Connecting to and querying a PostgreSQL database using a connection pool
@@ -35,7 +36,7 @@ kinesis_client = KinesisClient(...)
3536
# Do not use any version below 1.0.0
3637
# All available optional dependencies can be found in pyproject.toml.
3738
# See the "Managing dependencies" section below for more details.
38-
nypl-py-utils[kinesis-client,config-helper]==1.1.2
39+
nypl-py-utils[kinesis-client,config-helper]==1.3.0
3940
```
4041

4142
## Developing locally

pyproject.toml

Lines changed: 6 additions & 2 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.2.0"
7+
version = "1.3.0"
88
authors = [
99
{ name="Aaron Friedman", email="aaronfriedman@nypl.org" },
1010
]
@@ -56,6 +56,10 @@ s3-client = [
5656
"boto3>=1.26.5",
5757
"botocore>=1.29.5"
5858
]
59+
secrets-manager-client = [
60+
"boto3>=1.26.5",
61+
"botocore>=1.29.5"
62+
]
5963
config-helper = [
6064
"nypl_py_utils[kms-client]",
6165
"PyYAML>=6.0"
@@ -67,7 +71,7 @@ research-catalog-identifier-helper = [
6771
"requests>=2.28.1"
6872
]
6973
development = [
70-
"nypl_py_utils[avro-client,kinesis-client,kms-client,mysql-client,oauth2-api-client,postgresql-client,postgresql-pool-client,redshift-client,s3-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,postgresql-pool-client,redshift-client,s3-client,secrets-manager-client,config-helper,obfuscation-helper,research-catalog-identifier-helper]",
7175
"flake8>=6.0.0",
7276
"freezegun>=1.2.2",
7377
"mock>=4.0.3",

src/nypl_py_utils/classes/avro_client.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
from avro.io import BinaryDecoder, BinaryEncoder, DatumReader, DatumWriter
66
from io import BytesIO
77
from nypl_py_utils.functions.log_helper import create_log
8-
from requests.exceptions import JSONDecodeError, RequestException
8+
from requests.adapters import HTTPAdapter, Retry
9+
from requests.exceptions import JSONDecodeError
910

1011

1112
class AvroClient:
@@ -15,7 +16,13 @@ class AvroClient:
1516
"""
1617

1718
def __init__(self, platform_schema_url):
18-
self.logger = create_log("avro_encoder")
19+
self.logger = create_log("avro_client")
20+
retry_policy = Retry(total=3, backoff_factor=45,
21+
status_forcelist=[500, 502, 503, 504],
22+
allowed_methods=frozenset(['GET']))
23+
self.session = requests.Session()
24+
self.session.mount("https://",
25+
HTTPAdapter(max_retries=retry_policy))
1926
self.schema = avro.schema.parse(
2027
self.get_json_schema(platform_schema_url))
2128

@@ -27,9 +34,11 @@ def get_json_schema(self, platform_schema_url):
2734
self.logger.info(
2835
"Fetching Avro schema from {}".format(platform_schema_url))
2936
try:
30-
response = requests.get(platform_schema_url)
37+
38+
response = self.session.get(url=platform_schema_url,
39+
timeout=60)
3140
response.raise_for_status()
32-
except RequestException as e:
41+
except Exception as e:
3342
self.logger.error(
3443
"Failed to retrieve schema from {url}: {error}".format(
3544
url=platform_schema_url, error=e
@@ -39,7 +48,7 @@ def get_json_schema(self, platform_schema_url):
3948
"Failed to retrieve schema from {url}: {error}".format(
4049
url=platform_schema_url, error=e
4150
)
42-
) from None
51+
)
4352

4453
try:
4554
json_response = response.json()
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import boto3
2+
import json
3+
import os
4+
5+
from botocore.exceptions import ClientError
6+
from nypl_py_utils.functions.log_helper import create_log
7+
8+
9+
class SecretsManagerClient:
10+
"""Client for interacting with AWS Secrets Manager"""
11+
12+
def __init__(self):
13+
self.logger = create_log('secrets_manager_client')
14+
15+
try:
16+
self.secrets_manager_client = boto3.client(
17+
'secretsmanager', region_name=os.environ.get('AWS_REGION',
18+
'us-east-1'))
19+
except ClientError as e:
20+
self.logger.error(
21+
'Could not create Secrets Manager client: {err}'.format(
22+
err=e))
23+
raise SecretsManagerClientError(
24+
'Could not create Secrets Manager client: {err}'.format(
25+
err=e)) from None
26+
27+
def close(self):
28+
self.secrets_manager_client.close()
29+
30+
def get_secret(self, secret_name, is_json=True):
31+
"""
32+
Retrieves secret with the given name from the Secrets Manager.
33+
34+
Parameters
35+
----------
36+
secret_name: str
37+
The name of the secret to retrieve
38+
is_json: bool, optional
39+
Whether the value of the secret is a JSON string that should be
40+
returned as a dictionary
41+
42+
Returns
43+
-------
44+
dict or str
45+
Dictionary if `is_json` is True; string if `is_json` is False
46+
"""
47+
self.logger.debug('Retrieving \'{}\' from Secrets Manager'.format(
48+
secret_name))
49+
try:
50+
response = self.secrets_manager_client.get_secret_value(
51+
SecretId=secret_name)
52+
if is_json:
53+
return json.loads(response['SecretString'])
54+
else:
55+
return response['SecretString']
56+
except ClientError as e:
57+
self.logger.error(
58+
('Could not retrieve \'{secret}\' from Secrets Manager: {err}')
59+
.format(secret=secret_name, err=e))
60+
raise SecretsManagerClientError(
61+
('Could not retrieve \'{secret}\' from Secrets Manager: {err}')
62+
.format(secret=secret_name, err=e)) from None
63+
64+
65+
class SecretsManagerClientError(Exception):
66+
def __init__(self, message=None):
67+
self.message = message

tests/test_avro_client.py

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,26 +25,26 @@ class TestAvroClient:
2525
@pytest.fixture
2626
def test_avro_encoder_instance(self, requests_mock):
2727
requests_mock.get(
28-
'https://test_schema_url', text=json.dumps(_TEST_SCHEMA))
29-
return AvroEncoder('https://test_schema_url')
28+
"https://test_schema_url", text=json.dumps(_TEST_SCHEMA))
29+
return AvroEncoder("https://test_schema_url")
3030

3131
@pytest.fixture
3232
def test_avro_decoder_instance(self, requests_mock):
3333
requests_mock.get(
34-
'https://test_schema_url', text=json.dumps(_TEST_SCHEMA))
35-
return AvroDecoder('https://test_schema_url')
36-
37-
def test_get_json_schema(self, test_avro_encoder_instance,
38-
test_avro_decoder_instance):
39-
assert test_avro_encoder_instance.schema == _TEST_SCHEMA['data'][
40-
'schema']
41-
assert test_avro_decoder_instance.schema == _TEST_SCHEMA['data'][
42-
'schema']
43-
44-
def test_request_error(self, requests_mock):
45-
requests_mock.get('https://test_schema_url', exc=ConnectTimeout)
34+
"https://test_schema_url", text=json.dumps(_TEST_SCHEMA))
35+
return AvroDecoder("https://test_schema_url")
36+
37+
def test_get_json_schema_success(self, test_avro_encoder_instance,
38+
test_avro_decoder_instance):
39+
assert test_avro_encoder_instance.schema == _TEST_SCHEMA["data"][
40+
"schema"]
41+
assert test_avro_decoder_instance.schema == _TEST_SCHEMA["data"][
42+
"schema"]
43+
44+
def test_get_json_schema_error(self, requests_mock):
45+
requests_mock.get("https://test_schema_url", exc=ConnectTimeout)
4646
with pytest.raises(AvroClientError):
47-
AvroEncoder('https://test_schema_url')
47+
AvroEncoder("https://test_schema_url")
4848

4949
def test_bad_json_error(self, requests_mock):
5050
requests_mock.get(

tests/test_kms_client.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,10 @@ def test_instance(self, mocker):
2121

2222
def test_decrypt(self, test_instance):
2323
test_instance.kms_client.decrypt.return_value = _TEST_DECRYPTION
24-
assert test_instance.kms_client.decrypt.called_once_with(
25-
CiphertextBlob=b'test-encrypted-value')
2624
assert test_instance.decrypt(
2725
_TEST_ENCRYPTED_VALUE) == 'test-decrypted-value'
26+
test_instance.kms_client.decrypt.assert_called_once_with(
27+
CiphertextBlob=b'test-encrypted-value')
2828

2929
def test_base64_error(self, test_instance):
3030
with pytest.raises(KmsClientError):

tests/test_mysql_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ def test_execute_write_query_with_params(self, mock_mysql_conn,
6060
'test query %s %s', query_params=('a', 1)) is None
6161
mock_cursor.execute.assert_called_once_with('test query %s %s',
6262
('a', 1))
63-
test_instance.conn.commit.called_once()
63+
test_instance.conn.commit.assert_called_once()
6464
mock_cursor.close.assert_called_once()
6565

6666
def test_execute_query_with_exception(
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import pytest
2+
3+
from botocore.exceptions import ClientError
4+
from datetime import datetime
5+
from nypl_py_utils.classes.secrets_manager_client import (
6+
SecretsManagerClient, SecretsManagerClientError)
7+
8+
_TEST_RESPONSE = {
9+
'ARN': 'test_arn',
10+
'Name': 'test_secret',
11+
'VersionId': 'test_version',
12+
'SecretString': '{\n "key1": "value1",\n "key2": "value2"\n}',
13+
'VersionStages': ['AWSCURRENT'],
14+
'CreatedDate': datetime(2024, 1, 1, 1, 1, 1, 1),
15+
'ResponseMetadata': {
16+
'RequestId': 'test-request-id',
17+
'HTTPStatusCode': 200,
18+
'HTTPHeaders': {
19+
'x-amzn-requestid': 'test-request-id',
20+
'content-type': 'application/x-amz-json-1.1',
21+
'content-length': '155',
22+
'date': 'Mon, 1 Jan 2024 07:01:01 GMT'
23+
},
24+
'RetryAttempts': 0}
25+
}
26+
27+
28+
class TestSecretsManagerClient:
29+
30+
@pytest.fixture
31+
def test_instance(self, mocker):
32+
mocker.patch('boto3.client')
33+
return SecretsManagerClient()
34+
35+
def test_get_secret(self, test_instance):
36+
test_instance.secrets_manager_client.get_secret_value.return_value = \
37+
_TEST_RESPONSE
38+
assert test_instance.get_secret('test_secret') == {
39+
'key1': 'value1', 'key2': 'value2'}
40+
test_instance.secrets_manager_client.get_secret_value\
41+
.assert_called_once_with(SecretId='test_secret')
42+
43+
def test_get_secret_non_json(self, test_instance):
44+
test_instance.secrets_manager_client.get_secret_value.return_value = \
45+
_TEST_RESPONSE
46+
assert test_instance.get_secret('test_secret', is_json=False) == (
47+
'{\n "key1": "value1",\n "key2": "value2"\n}')
48+
test_instance.secrets_manager_client.get_secret_value\
49+
.assert_called_once_with(SecretId='test_secret')
50+
51+
def test_get_secret_error(self, test_instance):
52+
test_instance.secrets_manager_client.get_secret_value.side_effect = \
53+
ClientError({}, 'GetSecretValue')
54+
with pytest.raises(SecretsManagerClientError):
55+
test_instance.get_secret('test_secret')

0 commit comments

Comments
 (0)