Skip to content

Commit 510c456

Browse files
authored
Merge pull request #49 from NYPL/main
Merging to prod
2 parents 21b219f + c931322 commit 510c456

5 files changed

Lines changed: 72 additions & 10 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.8.0 8/19/25
3+
- Add optional JSON structured logging
4+
25
## v1.7.0 6/13/25
36
- Use fastavro for avro encoding/decoding
47

pyproject.toml

Lines changed: 5 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.7.0"
7+
version = "1.8.0"
88
authors = [
99
{ name="Aaron Friedman", email="aaronfriedman@nypl.org" },
1010
]
@@ -38,6 +38,9 @@ kms-client = [
3838
"boto3>=1.26.5",
3939
"botocore>=1.29.5"
4040
]
41+
log_helper = [
42+
"structlog>=25.4.0"
43+
]
4144
mysql-client = [
4245
"mysql-connector-python>=8.0.32"
4346
]
@@ -78,7 +81,7 @@ research-catalog-identifier-helper = [
7881
"requests>=2.28.1"
7982
]
8083
development = [
81-
"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,patron-data-helper,research-catalog-identifier-helper]",
84+
"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,patron-data-helper,research-catalog-identifier-helper,log_helper]",
8285
"flake8>=6.0.0",
8386
"freezegun>=1.2.2",
8487
"mock>=4.0.3",

src/nypl_py_utils/functions/log_helper.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import structlog
2+
13
import logging
24
import os
35
import sys
@@ -11,7 +13,31 @@
1113
}
1214

1315

14-
def create_log(module):
16+
# Configure structlog to be machine-readable first and foremost
17+
# while still making it easy for humans to parse
18+
# End result (without additional bindings) is JSON like this:
19+
#
20+
# { "logger": "module param"
21+
# "message": "this is a test log event",
22+
# "level": "info",
23+
# "timestamp": "2023-11-01 18:50:47"}
24+
#
25+
def get_structlog(module):
26+
structlog.configure(
27+
processors=[
28+
structlog.processors.add_log_level,
29+
structlog.processors.TimeStamper(fmt="iso"),
30+
structlog.processors.EventRenamer("message"),
31+
structlog.processors.JSONRenderer(),
32+
],
33+
context_class=dict,
34+
logger_factory=structlog.PrintLoggerFactory(),
35+
)
36+
37+
return structlog.get_logger(module)
38+
39+
40+
def standard_logger(module):
1541
logger = logging.getLogger(module)
1642
if logger.hasHandlers():
1743
logger.handlers = []
@@ -28,5 +54,11 @@ def create_log(module):
2854
console_log.setFormatter(formatter)
2955

3056
logger.addHandler(console_log)
31-
3257
return logger
58+
59+
60+
def create_log(module, json=False):
61+
if (json):
62+
return get_structlog(module)
63+
else:
64+
return standard_logger(module)

tests/test_log_helper.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,23 @@
1+
import json
12
import logging
23
import os
34
import time
45

56
from freezegun import freeze_time
7+
68
from nypl_py_utils.functions.log_helper import create_log
79

810

911
@freeze_time('2023-01-01 19:00:00')
1012
class TestLogHelper:
13+
def test_json_logging(self, capsys):
14+
logger = create_log('test_log', json=True)
15+
logger.info('test', some="json")
16+
output = json.loads(capsys.readouterr().out)
17+
assert output.get("message") == 'test'
18+
assert output.get("some") == 'json'
19+
assert output.get('level') == 'info'
20+
assert output.get('timestamp') == '2023-01-01T19:00:00Z'
1121

1222
def test_default_logging(self, caplog):
1323
logger = create_log('test_log')

tests/test_oauth2_api_client.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import time
33
import json
44
import pytest
5+
from oauthlib.oauth2 import OAuth2Token
56
from requests_oauthlib import OAuth2Session
67
from requests import HTTPError, JSONDecodeError, Response
78

@@ -110,7 +111,7 @@ def test_token_expiration(self, requests_mock, test_instance,
110111
.headers['Authorization'] == 'Bearer super-secret-token'
111112

112113
# The token obtained above expires in 1s, so wait out expiration:
113-
time.sleep(1.1)
114+
time.sleep(2)
114115

115116
# Register new token response:
116117
second_token_response = dict(_TOKEN_RESPONSE)
@@ -138,7 +139,7 @@ def test_error_status_raises_error(self, requests_mock, test_instance,
138139
test_instance._do_http_method('GET', 'foo')
139140

140141
def test_token_refresh_failure_raises_error(
141-
self, requests_mock, test_instance, token_server_post):
142+
self, requests_mock, test_instance, token_server_post, mocker):
142143
"""
143144
Failure to fetch a token can raise a number of errors including:
144145
- requests.exceptions.HTTPError for invalid access_token
@@ -150,12 +151,25 @@ def test_token_refresh_failure_raises_error(
150151
a new valid token in response to token expiration. This test asserts
151152
that the client will not allow more than successive 3 retries.
152153
"""
153-
requests_mock.get(f'{BASE_URL}/foo', json={'foo': 'bar'})
154+
test_instance._create_oauth_client()
155+
156+
def set_token(*args, scope):
157+
test_instance.oauth_client.token = OAuth2Token(
158+
json.loads(args[0]))
159+
test_instance.oauth_client._client.populate_token_attributes(
160+
json.loads(args[0]))
154161

162+
requests_mock.get(f'{BASE_URL}/foo', json={'foo': 'bar'})
155163
token_response = dict(_TOKEN_RESPONSE)
156-
token_response['expires_in'] = 0
157-
token_server_post = requests_mock\
158-
.post(TOKEN_URL, text=json.dumps(token_response))
164+
token_response["expires_in"] = 0
165+
token_response["expires_at"] = 1000000000
166+
token_server_post = requests_mock.post(
167+
TOKEN_URL, text=json.dumps(token_response))
168+
169+
test_instance.oauth_client._client.parse_request_body_response = (
170+
mocker.MagicMock(name="method", side_effect=set_token)
171+
)
172+
test_instance._generate_access_token()
159173

160174
with pytest.raises(Oauth2ApiClientError):
161175
test_instance._do_http_method('GET', 'foo')

0 commit comments

Comments
 (0)