From 6af1b10a0bebc495d8d7581ec5d9d4d3c4ae33e7 Mon Sep 17 00:00:00 2001 From: Srinivas Chippagiri Date: Sat, 24 Oct 2026 13:54:00 -0700 Subject: [PATCH] test: cover init validation, util error paths and formatter fields Add unit tests for the argument validation and error paths of the public init API, the util helpers, and the optional component and props fields of the JSON formatters. Refs #137 Signed-off-by: Srinivas Chippagiri --- tests/test_formatters_fields.py | 72 +++++++++++++++++++++++++++++++ tests/test_init_validation.py | 75 +++++++++++++++++++++++++++++++++ tests/test_util_validation.py | 43 +++++++++++++++++++ 3 files changed, 190 insertions(+) create mode 100644 tests/test_formatters_fields.py create mode 100644 tests/test_init_validation.py create mode 100644 tests/test_util_validation.py diff --git a/tests/test_formatters_fields.py b/tests/test_formatters_fields.py new file mode 100644 index 0000000..e428a13 --- /dev/null +++ b/tests/test_formatters_fields.py @@ -0,0 +1,72 @@ +"""Optional fields and value handling in the JSON formatters (issue #137).""" +import json +import logging + +import pytest + +import json_logging +from json_logging.formatters import BaseJSONFormatter, JSONLogFormatter + + +@pytest.fixture +def component_settings(): + """Restore the module level component settings, which are read when a formatter is constructed.""" + saved = (json_logging.COMPONENT_ID, json_logging.COMPONENT_NAME, json_logging.COMPONENT_INSTANCE_INDEX) + # base_object_common is a class level dict that formatter construction writes into, so restore it as well + saved_common = dict(BaseJSONFormatter.base_object_common) + for key in ("component_id", "component_name", "component_instance_idx"): + BaseJSONFormatter.base_object_common.pop(key, None) + yield + BaseJSONFormatter.base_object_common.clear() + BaseJSONFormatter.base_object_common.update(saved_common) + json_logging.COMPONENT_ID, json_logging.COMPONENT_NAME, json_logging.COMPONENT_INSTANCE_INDEX = saved + + +def _format(**extra): + record = logging.LogRecord("formatter-test", logging.INFO, __file__, 1, "hello", None, None) + for key, value in extra.items(): + setattr(record, key, value) + return json.loads(JSONLogFormatter().format(record)) + + +def test_component_settings_are_added_when_set(component_settings): + json_logging.COMPONENT_ID = "id-1" + json_logging.COMPONENT_NAME = "name-1" + json_logging.COMPONENT_INSTANCE_INDEX = 2 + + output = _format() + + assert output["component_id"] == "id-1" + assert output["component_name"] == "name-1" + assert output["component_instance_idx"] == 2 + + +def test_component_settings_are_omitted_by_default(component_settings): + output = _format() + + assert "component_id" not in output + assert "component_name" not in output + assert "component_instance_idx" not in output + + +def test_non_serialisable_extra_value_falls_back_to_repr(): + class Unserialisable: + def __repr__(self): + return "" + + output = _format(thing=Unserialisable()) + + assert output["thing"] == "" + + +def test_props_dict_is_merged_into_the_root_of_the_log(): + output = _format(props={"user": "alice", "attempt": 3}) + + assert output["user"] == "alice" + assert output["attempt"] == 3 + + +def test_props_that_is_not_a_dict_is_not_merged(): + output = _format(props="not-a-dict") + + assert "not-a-dict" not in output.values() diff --git a/tests/test_init_validation.py b/tests/test_init_validation.py new file mode 100644 index 0000000..3323878 --- /dev/null +++ b/tests/test_init_validation.py @@ -0,0 +1,75 @@ +"""Argument validation and error paths of the public initialisation API (issue #137).""" +import logging + +import pytest +from helpers.imports import undo_imports_from_package + + +@pytest.fixture +def fresh_json_logging(): + """Import a pristine json_logging so module level init state does not leak between tests.""" + undo_imports_from_package("json_logging") + import json_logging + yield json_logging + undo_imports_from_package("json_logging") + + +def test_init_framework_twice_raises(fresh_json_logging): + fresh_json_logging.init_flask() + with pytest.raises(RuntimeError, match="more than once"): + fresh_json_logging.init_flask() + + +def test_init_rejects_custom_formatter_that_is_not_a_formatter(fresh_json_logging): + with pytest.raises(ValueError, match="custom_formatter"): + fresh_json_logging.init_non_web(custom_formatter=dict) + + +def test_init_rejects_unknown_framework(fresh_json_logging): + with pytest.raises(RuntimeError, match="not a supported framework"): + fresh_json_logging.init_non_web(framework_name="no-such-framework") + + +def test_init_request_instrument_before_init_raises(fresh_json_logging): + with pytest.raises(RuntimeError, match="init the logging first"): + fresh_json_logging.init_request_instrument(app=object()) + + +def test_init_request_instrument_after_non_web_init_raises(fresh_json_logging): + fresh_json_logging.init_non_web() + with pytest.raises(RuntimeError, match="init the logging first"): + fresh_json_logging.init_request_instrument(app=object()) + + +def test_init_request_instrument_rejects_bad_custom_formatter(fresh_json_logging): + fresh_json_logging.init_flask() + with pytest.raises(ValueError, match="custom_formatter"): + fresh_json_logging.init_request_instrument(app=object(), custom_formatter=dict) + + +def test_init_request_instrument_rejects_bad_request_response_dto_class(fresh_json_logging): + fresh_json_logging.init_flask() + with pytest.raises(ValueError, match="request_response_dto_class"): + fresh_json_logging.init_request_instrument(app=object(), request_response_dto_class=dict) + + +def test_get_request_logger_before_init_raises(fresh_json_logging): + with pytest.raises(RuntimeError, match="only available"): + fresh_json_logging.get_request_logger() + + +def test_config_root_logger_without_handlers_logs_error_and_returns(fresh_json_logging, monkeypatch): + fresh_json_logging.ENABLE_JSON_LOGGING = True + monkeypatch.setattr(logging.root, "handlers", []) + errors = [] + monkeypatch.setattr(fresh_json_logging._logger, "error", lambda msg, *a, **kw: errors.append(msg)) + + def fail(*args, **kwargs): + raise AssertionError("root logger must not be touched when it has no handlers") + + monkeypatch.setattr(fresh_json_logging.util, "update_formatter_for_loggers", fail) + + fresh_json_logging.config_root_logger() + + assert len(errors) == 1 + assert "No logging handlers found" in errors[0] diff --git a/tests/test_util_validation.py b/tests/test_util_validation.py new file mode 100644 index 0000000..082ded3 --- /dev/null +++ b/tests/test_util_validation.py @@ -0,0 +1,43 @@ +"""Error paths of the helpers in json_logging.util (issue #137).""" +import logging + +import pytest + +from json_logging import util + + +def test_update_formatter_for_loggers_rejects_non_logger(): + with pytest.raises(RuntimeError): + util.update_formatter_for_loggers(["not-a-logger"], logging.Formatter) + + +def test_update_formatter_for_loggers_replaces_other_formatters_only(): + class Wanted(logging.Formatter): + pass + + logger = logging.Logger("util-validation-test") + replaced = logging.StreamHandler() + kept = logging.StreamHandler() + already_wanted = Wanted() + kept.setFormatter(already_wanted) + logger.addHandler(replaced) + logger.addHandler(kept) + + util.update_formatter_for_loggers([logger], Wanted) + + assert isinstance(replaced.formatter, Wanted) + assert kept.formatter is already_wanted + + +def test_validate_subclass_accepts_subclass(): + assert util.validate_subclass(logging.StreamHandler, logging.Handler) is True + + +def test_validate_subclass_rejects_non_subclass(): + with pytest.raises(RuntimeError, match="is not a subclass of"): + util.validate_subclass(dict, logging.Handler) + + +@pytest.mark.parametrize("value, default, expected", [("7", 1, 7), ("x", 1, 1), (None, 3, 3), (4, 0, 4)]) +def test_parse_int_falls_back_to_default(value, default, expected): + assert util.parse_int(value, default) == expected