Skip to content

Commit 1160ae8

Browse files
authored
Support missing rename fields and preserve order (#13)
Fixes #6 Fixes #7 Changes also prevent double renaming of fields. ## Test plan Run tests
1 parent b37c54b commit 1160ae8

File tree

4 files changed

+116
-34
lines changed

4 files changed

+116
-34
lines changed

CHANGELOG.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file.
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7-
## [3.1.0.rc2](https://github.com/nhairs/python-json-logger/compare/v3.0.1...v3.1.0.rc2) - 2023-05-03
7+
## [3.1.0.rc3](https://github.com/nhairs/python-json-logger/compare/v3.0.1...v3.1.0.rc3) - 2023-05-03
88

99
This splits common funcitonality out to allow supporting other JSON encoders. Although this is a large refactor, backwards compatibility has been maintained.
1010

@@ -40,6 +40,7 @@ This splits common funcitonality out to allow supporting other JSON encoders. Al
4040
- Dataclasses are now supported
4141
- Enum values now use their value, Enum classes now return all values as a list.
4242
- Will fallback on `__str__` if available, else `__repr__` if available, else will use `__could_not_encode__`
43+
- Renaming fields now preserves order (#7) and ignores missing fields (#6).
4344

4445
### Deprecated
4546
- `.jsonlogger` is now `.json`

pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "python-json-logger"
7-
version = "3.1.0.rc2"
7+
version = "3.1.0.rc3"
88
description = "JSON Log Formatter for the Python Logging Package"
99
authors = [
1010
{name = "Zakaria Zajac", email = "zak@madzak.com"},

src/pythonjsonlogger/core.py

+33-21
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ def __init__(
138138
*,
139139
prefix: str = "",
140140
rename_fields: Optional[Dict[str, str]] = None,
141+
rename_fields_keep_missing: bool = False,
141142
static_fields: Optional[Dict[str, Any]] = None,
142143
reserved_attrs: Optional[Sequence[str]] = None,
143144
timestamp: Union[bool, str] = False,
@@ -154,7 +155,8 @@ def __init__(
154155
prefix: an optional string prefix added at the beginning of
155156
the formatted string
156157
rename_fields: an optional dict, used to rename field names in the output.
157-
Rename message to @message: {'message': '@message'}
158+
Rename `message` to `@message`: `{'message': '@message'}`
159+
rename_fields_keep_missing: When renaming fields, include missing fields in the output.
158160
static_fields: an optional dict, used to add fields with static values to all logs
159161
reserved_attrs: an optional list of fields that will be skipped when
160162
outputting json log record. Defaults to all log record attributes:
@@ -164,14 +166,18 @@ def __init__(
164166
to log record using string as key. If True boolean is passed, timestamp key
165167
will be "timestamp". Defaults to False/off.
166168
167-
*Changed in 3.1*: you can now use custom values for style by setting validate to `False`.
168-
The value is stored in `self._style` as a string. The `parse` method will need to be
169-
overridden in order to support the new style.
169+
*Changed in 3.1*:
170+
171+
- you can now use custom values for style by setting validate to `False`.
172+
The value is stored in `self._style` as a string. The `parse` method will need to be
173+
overridden in order to support the new style.
174+
- Renaming fields now preserves the order that fields were added in and avoids adding
175+
missing fields. The original behaviour, missing fields have a value of `None`, is still
176+
available by setting `rename_fields_keep_missing` to `True`.
170177
"""
171178
## logging.Formatter compatibility
172179
## ---------------------------------------------------------------------
173-
# Note: validate added in 3.8
174-
# Note: defaults added in 3.10
180+
# Note: validate added in 3.8, defaults added in 3.10
175181
if style in logging._STYLES:
176182
_style = logging._STYLES[style][0](fmt) # type: ignore[operator]
177183
if validate:
@@ -192,6 +198,7 @@ def __init__(
192198
## ---------------------------------------------------------------------
193199
self.prefix = prefix
194200
self.rename_fields = rename_fields if rename_fields is not None else {}
201+
self.rename_fields_keep_missing = rename_fields_keep_missing
195202
self.static_fields = static_fields if static_fields is not None else {}
196203
self.reserved_attrs = set(reserved_attrs if reserved_attrs is not None else RESERVED_ATTRS)
197204
self.timestamp = timestamp
@@ -215,6 +222,7 @@ def format(self, record: logging.LogRecord) -> str:
215222
record.message = ""
216223
else:
217224
record.message = record.getMessage()
225+
218226
# only format time if needed
219227
if "asctime" in self._required_fields:
220228
record.asctime = self.formatTime(record, self.datefmt)
@@ -225,6 +233,7 @@ def format(self, record: logging.LogRecord) -> str:
225233
message_dict["exc_info"] = self.formatException(record.exc_info)
226234
if not message_dict.get("exc_info") and record.exc_text:
227235
message_dict["exc_info"] = record.exc_text
236+
228237
# Display formatted record of stack frames
229238
# default format is a string returned from :func:`traceback.print_stack`
230239
if record.stack_info and not message_dict.get("stack_info"):
@@ -289,13 +298,16 @@ def add_fields(
289298
Args:
290299
log_record: data that will be logged
291300
record: the record to extract data from
292-
message_dict: ???
301+
message_dict: dictionary that was logged instead of a message. e.g
302+
`logger.info({"is_this_message_dict": True})`
293303
"""
294304
for field in self._required_fields:
295-
log_record[field] = record.__dict__.get(field)
305+
log_record[self._get_rename(field)] = record.__dict__.get(field)
306+
307+
for data_dict in [self.static_fields, message_dict]:
308+
for key, value in data_dict.items():
309+
log_record[self._get_rename(key)] = value
296310

297-
log_record.update(self.static_fields)
298-
log_record.update(message_dict)
299311
merge_record_extra(
300312
record,
301313
log_record,
@@ -304,19 +316,19 @@ def add_fields(
304316
)
305317

306318
if self.timestamp:
307-
# TODO: Can this use isinstance instead?
308-
# pylint: disable=unidiomatic-typecheck
309-
key = self.timestamp if type(self.timestamp) == str else "timestamp"
310-
log_record[key] = datetime.fromtimestamp(record.created, tz=timezone.utc)
311-
312-
self._perform_rename_log_fields(log_record)
319+
key = self.timestamp if isinstance(self.timestamp, str) else "timestamp"
320+
log_record[self._get_rename(key)] = datetime.fromtimestamp(
321+
record.created, tz=timezone.utc
322+
)
323+
324+
if self.rename_fields_keep_missing:
325+
for field in self.rename_fields.values():
326+
if field not in log_record:
327+
log_record[field] = None
313328
return
314329

315-
def _perform_rename_log_fields(self, log_record: Dict[str, Any]) -> None:
316-
for old_field_name, new_field_name in self.rename_fields.items():
317-
log_record[new_field_name] = log_record[old_field_name]
318-
del log_record[old_field_name]
319-
return
330+
def _get_rename(self, key: str) -> str:
331+
return self.rename_fields.get(key, key)
320332

321333
# Child Methods
322334
# ..........................................................................

tests/test_formatters.py

+80-11
Original file line numberDiff line numberDiff line change
@@ -187,15 +187,64 @@ def test_rename_base_field(env: LoggingEnvironment, class_: type[BaseJsonFormatt
187187

188188

189189
@pytest.mark.parametrize("class_", ALL_FORMATTERS)
190-
def test_rename_nonexistent_field(env: LoggingEnvironment, class_: type[BaseJsonFormatter]):
191-
env.set_formatter(class_(rename_fields={"nonexistent_key": "new_name"}))
190+
def test_rename_missing(env: LoggingEnvironment, class_: type[BaseJsonFormatter]):
191+
env.set_formatter(class_(rename_fields={"missing_field": "new_field"}))
192192

193-
stderr_watcher = io.StringIO()
194-
sys.stderr = stderr_watcher
195-
env.logger.info("testing logging rename")
196-
sys.stderr == sys.__stderr__
193+
msg = "test rename missing field"
194+
env.logger.info(msg)
195+
log_json = env.load_json()
196+
197+
assert log_json["message"] == msg
198+
assert "missing_field" not in log_json
199+
assert "new_field" not in log_json
200+
return
201+
202+
203+
@pytest.mark.parametrize("class_", ALL_FORMATTERS)
204+
def test_rename_keep_missing(env: LoggingEnvironment, class_: type[BaseJsonFormatter]):
205+
env.set_formatter(
206+
class_(rename_fields={"missing_field": "new_field"}, rename_fields_keep_missing=True)
207+
)
208+
209+
msg = "test keep rename missing field"
210+
env.logger.info(msg)
211+
log_json = env.load_json()
212+
213+
assert log_json["message"] == msg
214+
assert "missing_field" not in log_json
215+
assert log_json["new_field"] is None
216+
return
217+
218+
219+
@pytest.mark.parametrize("class_", ALL_FORMATTERS)
220+
def test_rename_preserve_order(env: LoggingEnvironment, class_: type[BaseJsonFormatter]):
221+
env.set_formatter(
222+
class_("{levelname}{message}{asctime}", style="{", rename_fields={"levelname": "LEVEL"})
223+
)
224+
225+
env.logger.info("testing logging rename order")
226+
log_json = env.load_json()
227+
228+
assert list(log_json.keys())[0] == "LEVEL"
229+
return
230+
231+
232+
@pytest.mark.parametrize("class_", ALL_FORMATTERS)
233+
def test_rename_once(env: LoggingEnvironment, class_: type[BaseJsonFormatter]):
234+
env.set_formatter(
235+
class_(
236+
"{levelname}{message}{asctime}",
237+
style="{",
238+
rename_fields={"levelname": "LEVEL", "message": "levelname"},
239+
)
240+
)
241+
242+
msg = "something"
243+
env.logger.info(msg)
244+
log_json = env.load_json()
197245

198-
assert "KeyError: 'nonexistent_key'" in stderr_watcher.getvalue()
246+
assert log_json["LEVEL"] == "INFO"
247+
assert log_json["levelname"] == msg
199248
return
200249

201250

@@ -327,6 +376,30 @@ def test_exc_info_renamed(env: LoggingEnvironment, class_: type[BaseJsonFormatte
327376
return
328377

329378

379+
@pytest.mark.parametrize("class_", ALL_FORMATTERS)
380+
def test_exc_info_renamed_not_required(env: LoggingEnvironment, class_: type[BaseJsonFormatter]):
381+
env.set_formatter(class_(rename_fields={"exc_info": "stack_trace"}))
382+
383+
expected_value = get_traceback_from_exception_followed_by_log_call(env)
384+
log_json = env.load_json()
385+
386+
assert log_json["stack_trace"] == expected_value
387+
assert "exc_info" not in log_json
388+
return
389+
390+
391+
@pytest.mark.parametrize("class_", ALL_FORMATTERS)
392+
def test_exc_info_renamed_no_error(env: LoggingEnvironment, class_: type[BaseJsonFormatter]):
393+
env.set_formatter(class_(rename_fields={"exc_info": "stack_trace"}))
394+
395+
env.logger.info("message")
396+
log_json = env.load_json()
397+
398+
assert "stack_trace" not in log_json
399+
assert "exc_info" not in log_json
400+
return
401+
402+
330403
@pytest.mark.parametrize("class_", ALL_FORMATTERS)
331404
def test_custom_object_serialization(env: LoggingEnvironment, class_: type[BaseJsonFormatter]):
332405
def encode_complex(z):
@@ -368,10 +441,6 @@ def test_rename_reserved_attrs(env: LoggingEnvironment, class_: type[BaseJsonFor
368441
env.logger.info("message")
369442
log_json = env.load_json()
370443

371-
# Note: this check is fragile if we make the following changes in the future (we might):
372-
# - renaming fields no longer requires the field to be present (#6)
373-
# - we add the ability (and data above) to rename a field to an existing field name
374-
# e.g. {"exc_info": "trace_original", "@custom_trace": "exc_info"}
375444
for old_name, new_name in reserved_attrs_map.items():
376445
assert new_name in log_json
377446
assert old_name not in log_json

0 commit comments

Comments
 (0)