From 2d972c9157247ea94caa19c8a72ac8a06de3fcd8 Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Sat, 6 Dec 2025 05:14:41 +0400 Subject: [PATCH 01/27] chore: update release configuration and workflow conditions - Modified the release message format in `.releaserc.json` to include a "Signed-off-by" line for better traceability. - Added a condition in the `release-stable.yaml` workflow to prevent releases from being triggered by commits that start with 'chore(release):', ensuring cleaner release management. --- .github/workflows/release-stable.yaml | 1 + .releaserc.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-stable.yaml b/.github/workflows/release-stable.yaml index ef6fd81..faea449 100644 --- a/.github/workflows/release-stable.yaml +++ b/.github/workflows/release-stable.yaml @@ -9,6 +9,7 @@ name: Release (stable) jobs: release: name: "Release" + if: ${{ !startsWith(github.event.head_commit.message, 'chore(release):') }} runs-on: ubuntu-latest permissions: contents: write diff --git a/.releaserc.json b/.releaserc.json index 36cd72c..dbfea86 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -29,7 +29,7 @@ }], ["@semantic-release/git", { "assets": ["pyproject.toml", "uv.lock"], - "message": "chore(release): ${nextRelease.version}\n\n${nextRelease.notes}" + "message": "chore(release): ${nextRelease.version}\n\n${nextRelease.notes}\n\nSigned-off-by: Release Bot " }], ["@semantic-release/github", {}] ] From f77fe06644e92763ed48780f78c0a9c516b34497 Mon Sep 17 00:00:00 2001 From: "ansible-repo-updater[bot]" Date: Sat, 6 Dec 2025 01:20:41 +0000 Subject: [PATCH 02/27] chore: sync from template v1.0.2 --- .github/workflows/release.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index dee15fa..76324f3 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -9,6 +9,7 @@ name: Release jobs: release: name: "Release" + if: ${{ !startsWith(github.event.head_commit.message, 'chore(release):') }} runs-on: ubuntu-latest permissions: contents: write From b56a22ff096d719f6ff248d4c42c925136ed0443 Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Sat, 6 Dec 2025 14:46:37 +0400 Subject: [PATCH 03/27] refactor(logging): update DataValidationError handling and logging behavior - Enhanced the DataValidationError class documentation to clarify its purpose and behavior, emphasizing that it logs validation issues as ERROR messages instead of raising exceptions. - Modified the SchemaLogger to log validation errors after emitting log records, ensuring compatibility with standard logger behavior. - Updated tests to reflect the new logging behavior, confirming that validation errors are logged correctly without raising exceptions. This change improves the clarity of error handling in the logging system and maintains compatibility with existing logging practices. --- README.md | 38 +++++---- src/logging_objects_with_schema/errors.py | 25 +++--- .../schema_logger.py | 59 +++++++++++-- tests/test_formatter_basic.py | 83 +++++++++++++++++-- tests/test_integration.py | 39 ++++----- 5 files changed, 180 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 5324733..512287e 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ to appear in logs and which types they must have. - Application code must only send `extra` fields that are described in the schema and match the declared Python types. Any deviation (unknown fields, wrong types, - `None` values, disallowed list elements) is reported via `DataValidationError` + `None` values, disallowed list elements) is logged as an ERROR message *after* the log record has been emitted. - The schema file (`logging_objects_with_schema.json`) is a shared, versioned artifact that defines the shape of structured log payloads for all downstream @@ -39,9 +39,9 @@ to appear in logs and which types they must have. - Any mismatch between runtime values and the declared types is also treated as a data error. - All validation problems (unknown fields, wrong types, disallowed list - elements, `None` values, etc.) are aggregated into a single - `DataValidationError` that is raised **after** the log record has been - emitted. + elements, `None` values, etc.) are aggregated and logged as a single + ERROR message **after** the log record has been emitted, ensuring 100% + compatibility with standard logger behavior (no exceptions are raised). - The schema is treated as the only source of truth for which `extra` fields are allowed to appear in logs. Any deviation from the schema is considered a contract violation between the producer of `extra` and the schema author. @@ -152,14 +152,11 @@ except (OSError, ValueError, RuntimeError) as e: print(f"System error during logger initialization: {e}") raise -# When logging, handle DataValidationError -try: - logger.info("processing", extra={"user_id": "not-an-int"}) # Wrong type -except DataValidationError as e: - print(f"Data validation failed: {e}") - for problem in e.problems: - print(f" - {problem.message}") - # Note: the valid part of the log was already emitted before the exception +# When logging with invalid data, validation errors are automatically +# logged as ERROR messages. No exception handling is needed. +logger.info("processing", extra={"user_id": "not-an-int"}) # Wrong type +# The valid part of the log is emitted, and validation errors are logged +# as ERROR messages with full exception information. ``` ### API compatibility with ``logging.Logger`` @@ -270,7 +267,7 @@ message similar to: > Field 'tags' is a list but contains elements with types [...]; expected all elements to be of type str -and a `DataValidationError` is raised **after** the log record has been emitted. +and an ERROR message is logged **after** the log record has been emitted. ### Multiple leaves with the same source @@ -284,7 +281,7 @@ When a `source` is referenced by multiple leaves: - The value is written only to those leaf locations where the runtime type matches the expected type. - For leaf locations where the type does not match, a `DataProblem` is added - to the `DataValidationError` that is raised after logging. + to the ERROR message that is logged after logging. Example schema with duplicate source usage: @@ -395,7 +392,7 @@ distinguish between schema validation failures and system-level errors. **it is simply not included in the final log record**. In all of these cases a `DataProblem` is recorded for each offending field, and -if at least one problem is present a single `DataValidationError` is raised +if at least one problem is present, a single ERROR message is logged **after** the log record has been emitted. - When a `source` is used in multiple leaves (see "Multiple leaves with the same @@ -412,7 +409,8 @@ High-level algorithm inside `SchemaLogger`: 2. A new structured payload is built from the schema and the given `extra`. 3. Only this structured payload is passed to the underlying stdlib logger. 4. After logging, if any validation problems were detected, a single - `DataValidationError` is raised with the full `problems` list. + ERROR message is logged with the full `problems` list (no exception + is raised, ensuring 100% compatibility with standard logger behavior). ## Exceptions @@ -421,9 +419,13 @@ High-level algorithm inside `SchemaLogger`: conflicting root fields, malformed structure, etc.). - Exposes a `problems` list describing each violation. - **`DataValidationError`**: - - Any problem with a specific `extra` payload during logging: + - Exception type used internally to represent validation problems with a + specific `extra` payload during logging: - the runtime type does not match the expected type; - a list contains non-primitive elements (only str, int, float, bool allowed); - the field is redundant and not described in the schema. - - Raised **after** the valid part of the payload has been logged. + - Logged as ERROR **after** the valid part of the payload has been logged. - Contains a `problems` list describing the offending fields. + - **Note**: This exception is never raised during normal operation. It is + created and logged automatically to maintain 100% compatibility with + standard logger behavior. Applications do not need to catch it. diff --git a/src/logging_objects_with_schema/errors.py b/src/logging_objects_with_schema/errors.py index 515e095..a007081 100644 --- a/src/logging_objects_with_schema/errors.py +++ b/src/logging_objects_with_schema/errors.py @@ -57,11 +57,17 @@ class DataProblem: class DataValidationError(Exception): - """Raised when log record data does not satisfy the configured schema. + """Exception type used to represent log data validation problems. - This exception is raised *after* the valid part of the log record - has already been formatted and sent to the underlying handler. This means - that even if validation fails, the valid fields will still appear in the log. + This exception is created when log record data does not satisfy the + configured schema. It is logged as an ERROR message *after* the valid + part of the log record has already been formatted and sent to the + underlying handler. This means that even if validation fails, the valid + fields will still appear in the log. + + The exception is not raised to maintain 100% compatibility with standard + logger behavior. Instead, it is logged with ERROR level and includes + exception information (exc_info) for formatters to process. The exception message provides a summary description of the validation failure, while detailed information about individual problems is exposed @@ -70,13 +76,10 @@ class DataValidationError(Exception): Attributes: problems: List of DataProblem instances describing each validation issue. - Example: - >>> try: - ... logger.info("processing", extra={"user_id": "not-an-int"}) - ... except DataValidationError as e: - ... for problem in e.problems: - ... print(f"Validation error: {problem.message}") - ... # Note: valid fields were already logged before this exception + Note: + This exception is used internally by SchemaLogger and is logged + automatically. Applications do not need to catch it, as it is never + raised during normal operation. """ def __init__(self, message: str, problems: list[DataProblem] | None = None) -> None: diff --git a/src/logging_objects_with_schema/schema_logger.py b/src/logging_objects_with_schema/schema_logger.py index 1c0b363..e4d798b 100644 --- a/src/logging_objects_with_schema/schema_logger.py +++ b/src/logging_objects_with_schema/schema_logger.py @@ -1,14 +1,16 @@ """Logger subclass that applies a JSON schema to extra fields. This class extends the standard ``logging.Logger`` to validate and filter -user-provided ``extra`` fields according to a compiled JSON schema. It raises -a DataValidationError *after* the log record has been emitted when problems -are detected. +user-provided ``extra`` fields according to a compiled JSON schema. When +validation problems are detected, they are logged as ERROR messages *after* +the log record has been emitted, ensuring 100% compatibility with standard +logger behavior. """ from __future__ import annotations import logging +import sys from collections.abc import Mapping from typing import Any @@ -101,7 +103,10 @@ def _log( """Log a message with the specified level and schema-validated extra. This method validates and filters the ``extra`` parameter according - to the compiled schema before delegating to the parent class. + to the compiled schema before delegating to the parent class. If + validation problems are detected, they are logged as ERROR messages + after the main log record has been emitted, ensuring compatibility + with standard logger behavior (no exceptions are raised). Args: level: Logging level. @@ -111,9 +116,6 @@ def _log( extra: Extra fields to include in the log record. stack_info: Whether to include stack information. stacklevel: Stack level for caller information. - - Raises: - DataValidationError: If the ``extra`` data does not match the schema. """ structured_extra, data_problems = _apply_schema_internal( self._schema, @@ -133,7 +135,48 @@ def _log( ) if data_problems: - raise DataValidationError( + # Log validation errors as ERROR messages with full traceback + # by temporarily raising and catching the exception. + validation_error = DataValidationError( "Log data does not match schema", problems=data_problems, ) + + # Temporarily raise exception to get traceback, then catch it + try: + raise validation_error + except DataValidationError: + exc_type, exc_value, exc_traceback = sys.exc_info() + # exc_type and exc_value are guaranteed to be not None here + # since we just caught the exception + assert exc_type is not None + assert exc_value is not None + + # Use stacklevel + 1 to account for this override frame, same as + # in the main logging call above, so caller info points to user code. + fn, lno, func, sinfo = self.findCaller( + stack_info=False, stacklevel=stacklevel + 1 + ) + error_record = self.makeRecord( + self.name, + logging.ERROR, + fn, + lno, + str(validation_error), + (), + ( + exc_type, + exc_value, + exc_traceback, + ), # exc_info with full traceback + func, # func - function name from findCaller + None, # extra - not needed + sinfo, # sinfo - stack info from findCaller + ) + try: + self.callHandlers(error_record) + except Exception: + # If handler failed, log error to stderr (standard logging behavior) + sys.stderr.write(f"Error in logging handler: {error_record}\n") + + # Don't re-raise to maintain compatibility with standard logger diff --git a/tests/test_formatter_basic.py b/tests/test_formatter_basic.py index 41f329e..271223e 100644 --- a/tests/test_formatter_basic.py +++ b/tests/test_formatter_basic.py @@ -25,11 +25,11 @@ def _configure_schema_logger(stream: StringIO) -> SchemaLogger: return logger -def test_schema_logger_type_mismatch_raises_data_error_after_logging( +def test_schema_logger_type_mismatch_logs_error_after_logging( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Type mismatches should raise DataValidationError after logging.""" + """Type mismatches should log ERROR message after logging.""" monkeypatch.chdir(tmp_path) @@ -46,14 +46,21 @@ def test_schema_logger_type_mismatch_raises_data_error_after_logging( ) stream = StringIO() - logger = _configure_schema_logger(stream) + handler = logging.StreamHandler(stream) + handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) + logger = SchemaLogger("schema-logger") + logger.addHandler(handler) + logger.setLevel(logging.INFO) - with pytest.raises(DataValidationError): - logger.info("msg", extra={"user_id": "not-an-int"}) + # Should not raise exception + logger.info("msg", extra={"user_id": "not-an-int"}) output = stream.getvalue() # No user_id should be present because it failed validation. assert "user_id" not in output + # Validation error should be logged as ERROR + assert "ERROR" in output + assert "Log data does not match schema" in output def test_schema_logger_valid_data_appears_in_log( @@ -94,3 +101,69 @@ def test_schema_logger_valid_data_appears_in_log( assert "abc-123" in output assert "UserID" in output assert "42" in output + + +def test_validation_error_record_has_function_name_and_traceback( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Validation error log records should have function name and traceback.""" + + monkeypatch.chdir(tmp_path) + + schema_path = tmp_path / SCHEMA_FILE_NAME + schema_path.write_text( + json.dumps( + { + "ServicePayload": { + "UserID": {"type": "int", "source": "user_id"}, + }, + }, + ), + encoding="utf-8", + ) + + # Custom handler that captures log records + captured_records: list[logging.LogRecord] = [] + + class RecordCapturingHandler(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + captured_records.append(record) + + logger = SchemaLogger("schema-logger") + handler = RecordCapturingHandler() + logger.addHandler(handler) + logger.setLevel(logging.INFO) + + # Log with invalid type to trigger validation error + def test_function() -> None: + logger.info("msg", extra={"user_id": "not-an-int"}) + + test_function() + + # Should have two records: one INFO and one ERROR for validation + assert len(captured_records) == 2 + info_record = captured_records[0] + error_record = captured_records[1] + + # Check INFO record + assert info_record.levelno == logging.INFO + assert info_record.msg == "msg" + + # Check ERROR record for validation error + assert error_record.levelno == logging.ERROR + assert "Log data does not match schema" in str(error_record.msg) + + # Verify that funcName is set (not None) - this is the main fix we made + # Previously, func was not passed to makeRecord, causing funcName to be None + assert error_record.funcName is not None + assert error_record.funcName == "test_function" + + # Verify that exc_info is set correctly with full traceback + # - this is the fix we made + assert error_record.exc_info is not None + exc_type, exc_value, exc_traceback = error_record.exc_info + assert exc_type == DataValidationError + assert isinstance(exc_value, DataValidationError) + # Traceback should be present since we temporarily raise the exception to get it + assert exc_traceback is not None diff --git a/tests/test_integration.py b/tests/test_integration.py index b39700c..013ab9d 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -13,10 +13,7 @@ import pytest from logging_objects_with_schema import SchemaLogger -from logging_objects_with_schema.errors import ( - DataValidationError, - SchemaValidationError, -) +from logging_objects_with_schema.errors import SchemaValidationError from logging_objects_with_schema.schema_loader import SCHEMA_FILE_NAME from tests.conftest import _write_schema @@ -245,7 +242,7 @@ def test_logger_validates_data_after_logging( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Logger should raise DataValidationError after logging invalid data.""" + """Logger should log validation errors as ERROR messages after logging.""" monkeypatch.chdir(tmp_path) _write_schema( @@ -259,21 +256,21 @@ def test_logger_validates_data_after_logging( stream = StringIO() handler = logging.StreamHandler(stream) - handler.setFormatter(logging.Formatter("%(message)s")) + handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) logger = SchemaLogger("test") logger.addHandler(handler) logger.setLevel(logging.INFO) - # Log with invalid type - with pytest.raises(DataValidationError) as exc_info: - logger.info("message", extra={"user_id": "not-an-int"}) + # Log with invalid type - should not raise exception + logger.info("message", extra={"user_id": "not-an-int"}) - # Message should be logged before exception + # Message should be logged output = stream.getvalue() assert "message" in output - # Exception should be DataValidationError - assert isinstance(exc_info.value, DataValidationError) + # Validation error should be logged as ERROR + assert "ERROR" in output + assert "Log data does not match schema" in output def test_logger_with_empty_schema( @@ -287,28 +284,26 @@ def test_logger_with_empty_schema( stream = StringIO() handler = logging.StreamHandler(stream) - handler.setFormatter(logging.Formatter("%(message)s")) + handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) logger = SchemaLogger("test") logger.addHandler(handler) logger.setLevel(logging.INFO) # Log with extra fields: they should still be ignored in payload, but # treated as data errors because schema defines no valid leaves. - with pytest.raises(DataValidationError) as exc_info: - logger.info("message", extra={"unknown_field": "value", "another": 42}) + logger.info("message", extra={"unknown_field": "value", "another": 42}) - # Message should be logged before exception is raised. + # Message should be logged output = stream.getvalue() assert "message" in output assert "unknown_field" not in output assert "another" not in output - # Both fields should be reported as problems. - messages = [p.message for p in exc_info.value.problems] - assert ( - "Field 'unknown_field' is not defined in schema and will be ignored" in messages - ) - assert "Field 'another' is not defined in schema and will be ignored" in messages + # Validation error should be logged as ERROR + assert "ERROR" in output + assert "Log data does not match schema" in output + # Traceback should be present in the output + assert "Traceback" in output def test_schema_file_permission_error_raises_schema_validation_error( From 5f9a3f967b0845cc856c172f07c0b85750f0c054 Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Sat, 6 Dec 2025 15:06:02 +0400 Subject: [PATCH 04/27] refactor(logging): remove DataValidationError and improve error logging - Removed the DataValidationError class to simplify error handling, as it was not raised during normal operation and was logged automatically. - Updated the SchemaLogger to log validation errors without raising exceptions, ensuring that valid log data is emitted alongside error messages. - Adjusted tests to verify that validation error details are included in the log output while maintaining compatibility with standard logger behavior. This change enhances the clarity and efficiency of the logging system by streamlining error handling. Signed-off-by: Dmitrii Safronov --- README.md | 15 +--- src/logging_objects_with_schema/__init__.py | 3 +- src/logging_objects_with_schema/errors.py | 31 -------- .../schema_applier.py | 4 +- .../schema_logger.py | 73 ++++++++----------- tests/test_formatter_basic.py | 23 +++--- tests/test_integration.py | 12 ++- 7 files changed, 52 insertions(+), 109 deletions(-) diff --git a/README.md b/README.md index 512287e..94bd1a5 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ logger.info( ### Error handling example ```python -from logging_objects_with_schema import SchemaLogger, DataValidationError, SchemaValidationError +from logging_objects_with_schema import SchemaLogger, SchemaValidationError try: # This will raise SchemaValidationError if schema file is missing or invalid @@ -156,7 +156,7 @@ except (OSError, ValueError, RuntimeError) as e: # logged as ERROR messages. No exception handling is needed. logger.info("processing", extra={"user_id": "not-an-int"}) # Wrong type # The valid part of the log is emitted, and validation errors are logged -# as ERROR messages with full exception information. +# as ERROR messages with details about the problems. ``` ### API compatibility with ``logging.Logger`` @@ -418,14 +418,3 @@ High-level algorithm inside `SchemaLogger`: - Any problem with the schema (missing file, broken JSON, invalid types, conflicting root fields, malformed structure, etc.). - Exposes a `problems` list describing each violation. -- **`DataValidationError`**: - - Exception type used internally to represent validation problems with a - specific `extra` payload during logging: - - the runtime type does not match the expected type; - - a list contains non-primitive elements (only str, int, float, bool allowed); - - the field is redundant and not described in the schema. - - Logged as ERROR **after** the valid part of the payload has been logged. - - Contains a `problems` list describing the offending fields. - - **Note**: This exception is never raised during normal operation. It is - created and logged automatically to maintain 100% compatibility with - standard logger behavior. Applications do not need to catch it. diff --git a/src/logging_objects_with_schema/__init__.py b/src/logging_objects_with_schema/__init__.py index 2f5372d..55ee385 100644 --- a/src/logging_objects_with_schema/__init__.py +++ b/src/logging_objects_with_schema/__init__.py @@ -7,11 +7,10 @@ from __future__ import annotations -from .errors import DataValidationError, SchemaValidationError +from .errors import SchemaValidationError from .schema_logger import SchemaLogger __all__ = [ "SchemaLogger", "SchemaValidationError", - "DataValidationError", ] diff --git a/src/logging_objects_with_schema/errors.py b/src/logging_objects_with_schema/errors.py index a007081..9a87fa9 100644 --- a/src/logging_objects_with_schema/errors.py +++ b/src/logging_objects_with_schema/errors.py @@ -54,34 +54,3 @@ class DataProblem: """ message: str - - -class DataValidationError(Exception): - """Exception type used to represent log data validation problems. - - This exception is created when log record data does not satisfy the - configured schema. It is logged as an ERROR message *after* the valid - part of the log record has already been formatted and sent to the - underlying handler. This means that even if validation fails, the valid - fields will still appear in the log. - - The exception is not raised to maintain 100% compatibility with standard - logger behavior. Instead, it is logged with ERROR level and includes - exception information (exc_info) for formatters to process. - - The exception message provides a summary description of the validation - failure, while detailed information about individual problems is exposed - via the ``problems`` attribute. - - Attributes: - problems: List of DataProblem instances describing each validation issue. - - Note: - This exception is used internally by SchemaLogger and is logged - automatically. Applications do not need to catch it, as it is never - raised during normal operation. - """ - - def __init__(self, message: str, problems: list[DataProblem] | None = None) -> None: - super().__init__(message) - self.problems: list[DataProblem] = problems or [] diff --git a/src/logging_objects_with_schema/schema_applier.py b/src/logging_objects_with_schema/schema_applier.py index 392d7eb..4aa658a 100644 --- a/src/logging_objects_with_schema/schema_applier.py +++ b/src/logging_objects_with_schema/schema_applier.py @@ -195,8 +195,8 @@ def _apply_schema_internal( where the type matches; mismatched locations produce ``DataProblem`` entries, but do not affect successful locations. - The function itself does not raise ``DataValidationError``; it only - accumulates :class:`DataProblem` instances for the caller to handle. + The function itself does not raise exceptions; it only accumulates + :class:`DataProblem` instances for the caller to handle. Note: This function is used internally by :class:`SchemaLogger` and is not diff --git a/src/logging_objects_with_schema/schema_logger.py b/src/logging_objects_with_schema/schema_logger.py index e4d798b..2ce5157 100644 --- a/src/logging_objects_with_schema/schema_logger.py +++ b/src/logging_objects_with_schema/schema_logger.py @@ -14,7 +14,7 @@ from collections.abc import Mapping from typing import Any -from .errors import DataValidationError, SchemaValidationError +from .errors import SchemaValidationError from .schema_applier import _apply_schema_internal from .schema_loader import CompiledSchema, _compile_schema_internal @@ -135,48 +135,33 @@ def _log( ) if data_problems: - # Log validation errors as ERROR messages with full traceback - # by temporarily raising and catching the exception. - validation_error = DataValidationError( - "Log data does not match schema", - problems=data_problems, + # Log validation errors as ERROR messages + # Use stacklevel + 1 to account for this override frame, same as + # in the main logging call above, so caller info points to user code. + fn, lno, func, sinfo = self.findCaller( + stack_info=False, stacklevel=stacklevel + 1 + ) + # Format error message with details of all problems + error_msg = "Log data does not match schema" + if data_problems: + problem_messages = [ + f" - {problem.message}" for problem in data_problems + ] + error_msg = f"{error_msg}\n" + "\n".join(problem_messages) + error_record = self.makeRecord( + self.name, + logging.ERROR, + fn, + lno, + error_msg, + (), + None, # exc_info - not needed + func, # func - function name from findCaller + None, # extra - not needed + sinfo, # sinfo - stack info from findCaller ) - - # Temporarily raise exception to get traceback, then catch it try: - raise validation_error - except DataValidationError: - exc_type, exc_value, exc_traceback = sys.exc_info() - # exc_type and exc_value are guaranteed to be not None here - # since we just caught the exception - assert exc_type is not None - assert exc_value is not None - - # Use stacklevel + 1 to account for this override frame, same as - # in the main logging call above, so caller info points to user code. - fn, lno, func, sinfo = self.findCaller( - stack_info=False, stacklevel=stacklevel + 1 - ) - error_record = self.makeRecord( - self.name, - logging.ERROR, - fn, - lno, - str(validation_error), - (), - ( - exc_type, - exc_value, - exc_traceback, - ), # exc_info with full traceback - func, # func - function name from findCaller - None, # extra - not needed - sinfo, # sinfo - stack info from findCaller - ) - try: - self.callHandlers(error_record) - except Exception: - # If handler failed, log error to stderr (standard logging behavior) - sys.stderr.write(f"Error in logging handler: {error_record}\n") - - # Don't re-raise to maintain compatibility with standard logger + self.callHandlers(error_record) + except Exception: + # If handler failed, log error to stderr (standard logging behavior) + sys.stderr.write(f"Error in logging handler: {error_record}\n") diff --git a/tests/test_formatter_basic.py b/tests/test_formatter_basic.py index 271223e..f803c3d 100644 --- a/tests/test_formatter_basic.py +++ b/tests/test_formatter_basic.py @@ -10,7 +10,6 @@ import pytest from logging_objects_with_schema import SchemaLogger -from logging_objects_with_schema.errors import DataValidationError from logging_objects_with_schema.schema_loader import SCHEMA_FILE_NAME @@ -56,11 +55,15 @@ def test_schema_logger_type_mismatch_logs_error_after_logging( logger.info("msg", extra={"user_id": "not-an-int"}) output = stream.getvalue() - # No user_id should be present because it failed validation. - assert "user_id" not in output + # user_id should not appear in the main log message (it failed validation) + # but it should appear in the validation error message + main_log = output.split("ERROR:")[0] + assert "user_id" not in main_log # Validation error should be logged as ERROR assert "ERROR" in output assert "Log data does not match schema" in output + # Details of the problem should be included in the error message + assert "user_id" in output def test_schema_logger_valid_data_appears_in_log( @@ -103,11 +106,11 @@ def test_schema_logger_valid_data_appears_in_log( assert "42" in output -def test_validation_error_record_has_function_name_and_traceback( +def test_validation_error_record_has_function_name( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Validation error log records should have function name and traceback.""" + """Validation error log records should have function name.""" monkeypatch.chdir(tmp_path) @@ -159,11 +162,5 @@ def test_function() -> None: assert error_record.funcName is not None assert error_record.funcName == "test_function" - # Verify that exc_info is set correctly with full traceback - # - this is the fix we made - assert error_record.exc_info is not None - exc_type, exc_value, exc_traceback = error_record.exc_info - assert exc_type == DataValidationError - assert isinstance(exc_value, DataValidationError) - # Traceback should be present since we temporarily raise the exception to get it - assert exc_traceback is not None + # Verify that exc_info is not set (we don't need traceback for validation errors) + assert error_record.exc_info is None diff --git a/tests/test_integration.py b/tests/test_integration.py index 013ab9d..c2a86a8 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -296,14 +296,18 @@ def test_logger_with_empty_schema( # Message should be logged output = stream.getvalue() assert "message" in output - assert "unknown_field" not in output - assert "another" not in output + # Fields should not appear in the main log message (they failed validation) + # but they should appear in the validation error message + main_log = output.split("ERROR:")[0] + assert "unknown_field" not in main_log + assert "another" not in main_log # Validation error should be logged as ERROR assert "ERROR" in output assert "Log data does not match schema" in output - # Traceback should be present in the output - assert "Traceback" in output + # Details of problems should be included in the error message + assert "unknown_field" in output + assert "another" in output def test_schema_file_permission_error_raises_schema_validation_error( From 5daee953ec61313db01f05cd6af4027cf45a6eb7 Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Sat, 6 Dec 2025 15:11:27 +0400 Subject: [PATCH 05/27] refactor(logging): simplify error messages in schema validation - Updated error messages in the schema applier to remove unnecessary wording, enhancing clarity. - Adjusted corresponding test assertions to reflect the simplified error messages. This change improves the readability of error outputs during schema validation processes. Signed-off-by: Dmitrii Safronov --- .../schema_applier.py | 7 +++--- tests/test_formatter.py | 22 ++++--------------- 2 files changed, 7 insertions(+), 22 deletions(-) diff --git a/src/logging_objects_with_schema/schema_applier.py b/src/logging_objects_with_schema/schema_applier.py index 4aa658a..f174ab3 100644 --- a/src/logging_objects_with_schema/schema_applier.py +++ b/src/logging_objects_with_schema/schema_applier.py @@ -34,8 +34,7 @@ def _validate_list_value( """ if item_expected_type is None: return DataProblem( - f"Field '{source}' is declared as list in schema but " - f"has no item type configured", + f"Field '{source}' is list but has no item type configured", ) if len(value) == 0: @@ -227,7 +226,7 @@ def _apply_schema_internal( if value is None: problems.append( DataProblem( - f"Field '{source}' is None, but None values are not allowed", + f"Field '{source}' is None", ), ) continue @@ -246,7 +245,7 @@ def _apply_schema_internal( for key in redundant_keys: problems.append( DataProblem( - f"Field '{key}' is not defined in schema and will be ignored", + f"Field '{key}' is not defined in schema", ), ) diff --git a/tests/test_formatter.py b/tests/test_formatter.py index 88ae29e..b96da13 100644 --- a/tests/test_formatter.py +++ b/tests/test_formatter.py @@ -79,14 +79,8 @@ def test_apply_schema_empty_schema_returns_empty() -> None: assert result == {} # All fields are considered redundant even when schema has no leaves. problem_messages = [p.message for p in problems] - assert ( - "Field 'field1' is not defined in schema and will be ignored" - in problem_messages - ) - assert ( - "Field 'field2' is not defined in schema and will be ignored" - in problem_messages - ) + assert "Field 'field1' is not defined in schema" in problem_messages + assert "Field 'field2' is not defined in schema" in problem_messages def test_apply_schema_nested_structure() -> None: @@ -313,7 +307,6 @@ def test_apply_schema_none_value_produces_problem() -> None: assert result == {} assert len(problems) == 1 assert "None" in problems[0].message - assert "not allowed" in problems[0].message def test_apply_schema_none_value_with_multiple_leaves_produces_single_problem() -> None: @@ -337,7 +330,6 @@ def test_apply_schema_none_value_with_multiple_leaves_produces_single_problem() assert result == {} assert len(problems) == 1 # Should be 1, not 2 assert "None" in problems[0].message - assert "not allowed" in problems[0].message assert "request_id" in problems[0].message @@ -410,14 +402,8 @@ def test_apply_schema_redundant_fields_with_empty_schema() -> None: assert result == {} # Both fields should be reported as redundant. problem_messages = [p.message for p in problems] - assert ( - "Field 'unknown_field' is not defined in schema and will be ignored" - in problem_messages - ) - assert ( - "Field 'another_unknown' is not defined in schema and will be ignored" - in problem_messages - ) + assert "Field 'unknown_field' is not defined in schema" in problem_messages + assert "Field 'another_unknown' is not defined in schema" in problem_messages def test_apply_schema_strips_empty_dicts() -> None: From deecdac5ff34dca1525101dd0cf09b56e252982d Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Sat, 6 Dec 2025 15:15:45 +0400 Subject: [PATCH 06/27] refactor(logging): streamline error message formatting in schema logging - Simplified the error message construction in SchemaLogger to enhance clarity by consolidating problem messages into a single line. - This change improves the readability of error outputs during schema validation processes. Signed-off-by: Dmitrii Safronov --- src/logging_objects_with_schema/schema_logger.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/logging_objects_with_schema/schema_logger.py b/src/logging_objects_with_schema/schema_logger.py index 2ce5157..a913fbb 100644 --- a/src/logging_objects_with_schema/schema_logger.py +++ b/src/logging_objects_with_schema/schema_logger.py @@ -142,12 +142,8 @@ def _log( stack_info=False, stacklevel=stacklevel + 1 ) # Format error message with details of all problems - error_msg = "Log data does not match schema" - if data_problems: - problem_messages = [ - f" - {problem.message}" for problem in data_problems - ] - error_msg = f"{error_msg}\n" + "\n".join(problem_messages) + problem_messages = [problem.message for problem in data_problems] + error_msg = f"Log data does not match schema: {'; '.join(problem_messages)}" error_record = self.makeRecord( self.name, logging.ERROR, From b27a555b9b66ffd80330fdd3c5cc90975af755fa Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Sat, 6 Dec 2025 15:24:23 +0400 Subject: [PATCH 07/27] docs(logging): clarify stacklevel usage in SchemaLogger - Updated comments in the SchemaLogger to enhance clarity regarding the stacklevel adjustment used in logging validation errors. - The changes ensure that the caller information points to the correct user code location, improving the maintainability of the logging implementation. Signed-off-by: Dmitrii Safronov --- src/logging_objects_with_schema/schema_logger.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/logging_objects_with_schema/schema_logger.py b/src/logging_objects_with_schema/schema_logger.py index a913fbb..3325ea2 100644 --- a/src/logging_objects_with_schema/schema_logger.py +++ b/src/logging_objects_with_schema/schema_logger.py @@ -136,8 +136,10 @@ def _log( if data_problems: # Log validation errors as ERROR messages - # Use stacklevel + 1 to account for this override frame, same as - # in the main logging call above, so caller info points to user code. + # Use the same stacklevel as for the main logging call (stacklevel + 1) + # to ensure caller info points to the same user code location. + # findCaller is called from within _log, but it accounts for its own frame, + # so we use the same stacklevel adjustment as super()._log() above. fn, lno, func, sinfo = self.findCaller( stack_info=False, stacklevel=stacklevel + 1 ) From f7e7df2e40171ec8f66a245a86f07473269f8836 Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Sat, 6 Dec 2025 15:33:21 +0400 Subject: [PATCH 08/27] refactor(logging): enhance caller information retrieval in SchemaLogger - Updated the SchemaLogger to use inspect.stack() for obtaining caller information, ensuring consistent behavior across Python versions. - Added comments to clarify the logic behind the stack frame indexing, improving maintainability and readability of the logging implementation. Signed-off-by: Dmitrii Safronov --- .../schema_logger.py | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/src/logging_objects_with_schema/schema_logger.py b/src/logging_objects_with_schema/schema_logger.py index 3325ea2..92be968 100644 --- a/src/logging_objects_with_schema/schema_logger.py +++ b/src/logging_objects_with_schema/schema_logger.py @@ -9,6 +9,7 @@ from __future__ import annotations +import inspect import logging import sys from collections.abc import Mapping @@ -136,13 +137,28 @@ def _log( if data_problems: # Log validation errors as ERROR messages - # Use the same stacklevel as for the main logging call (stacklevel + 1) - # to ensure caller info points to the same user code location. - # findCaller is called from within _log, but it accounts for its own frame, - # so we use the same stacklevel adjustment as super()._log() above. - fn, lno, func, sinfo = self.findCaller( - stack_info=False, stacklevel=stacklevel + 1 - ) + # Get caller information using inspect.stack() to ensure consistent behavior + # across different Python versions. The stack looks like: + # - Frame 0: this function (_log) + # - Frame 1: logger.info() wrapper + # - Frame 2: actual caller (test_function) + # We need to skip frame 0 (this function) and frame 1 (logger.info wrapper), + # so we use stacklevel + 1 to get to the actual caller. + stack = inspect.stack() + frame_idx = ( + stacklevel + 1 + ) # Skip this function (0) + logger.info wrapper (1) + if frame_idx < len(stack): + frame = stack[frame_idx] + fn = frame.filename + lno = frame.lineno + func = frame.function + sinfo = None + else: + # Fallback to findCaller if stack is shorter than expected + fn, lno, func, sinfo = self.findCaller( + stack_info=False, stacklevel=stacklevel + 1 + ) # Format error message with details of all problems problem_messages = [problem.message for problem in data_problems] error_msg = f"Log data does not match schema: {'; '.join(problem_messages)}" From 15c9cddf3c71bef36ba8c213f8ef58ac055b4673 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sat, 6 Dec 2025 11:35:48 +0000 Subject: [PATCH 09/27] chore(release): 0.1.2-rc.1 ## [0.1.2-rc.1](https://github.com/disafronov/python-logging-objects-with-schema/compare/v0.1.1...v0.1.2-rc.1) (2025-12-06) Signed-off-by: Release Bot --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d90e8f1..1d6c843 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "uv_build" [project] name = "logging-objects-with-schema" -version = "0.1.1" +version = "0.1.2rc1" description = "Proxy logging wrapper that validates extra fields against a JSON schema." readme = "README.md" requires-python = ">=3.10" diff --git a/uv.lock b/uv.lock index a08afba..0c7d968 100644 --- a/uv.lock +++ b/uv.lock @@ -1394,7 +1394,7 @@ version = "0.6.3" [[package]] name = "logging-objects-with-schema" -version = "0.1.1" +version = "0.1.2rc1" [package.source] editable = "." From 9f454defd3c26b8d55dd26bca6d013f494a7040b Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Sat, 6 Dec 2025 16:04:58 +0400 Subject: [PATCH 10/27] refactor(logging): update SchemaLogger error handling and remove SchemaValidationError - Refactored SchemaLogger to handle schema validation errors internally by logging to stderr and terminating the application instead of raising SchemaValidationError. - Updated documentation in README.md to reflect the new error handling approach. - Adjusted tests to verify that the application terminates correctly on schema issues, ensuring consistent behavior across various error scenarios. This change simplifies error management in the logging system and enhances clarity in handling schema-related problems. Signed-off-by: Dmitrii Safronov --- README.md | 74 +++----- src/logging_objects_with_schema/__init__.py | 2 - src/logging_objects_with_schema/errors.py | 19 +- .../schema_logger.py | 50 ++++-- tests/test_integration.py | 98 ++++++++--- tests/test_schema_logger_mimic.py | 165 +++++++++++++----- tests/test_thread_safety.py | 5 +- 7 files changed, 270 insertions(+), 143 deletions(-) diff --git a/README.md b/README.md index 94bd1a5..22b2589 100644 --- a/README.md +++ b/README.md @@ -132,25 +132,13 @@ logger.info( ### Error handling example ```python -from logging_objects_with_schema import SchemaLogger, SchemaValidationError - -try: - # This will raise SchemaValidationError if schema file is missing or invalid - logging.setLoggerClass(SchemaLogger) - logger = logging.getLogger("service") -except SchemaValidationError as e: - print(f"Schema validation failed: {e}") - for problem in e.problems: - print(f" - {problem.message}") - # Decide whether to abort or continue with default logger - raise -except (OSError, ValueError, RuntimeError) as e: - # System-level errors (e.g., inaccessible working directory when os.getcwd() - # fails, lock failures, path resolution issues) are raised directly, not - # wrapped in SchemaValidationError. Note: OSError when reading the schema - # file is converted to SchemaValidationError and handled above. - print(f"System error during logger initialization: {e}") - raise +from logging_objects_with_schema import SchemaLogger + +# SchemaLogger is a drop-in replacement - no exception handling needed. +# If the schema has problems, the application will be terminated after +# logging schema problems to stderr. +logging.setLoggerClass(SchemaLogger) +logger = logging.getLogger("service") # When logging with invalid data, validation errors are automatically # logged as ERROR messages. No exception handling is needed. @@ -178,12 +166,11 @@ logger.info("processing", extra={"user_id": "not-an-int"}) # Wrong type - Schema tree depth is limited to a maximum nesting level (currently 100). Any branch that exceeds this depth is ignored and reported as a schema problem. - If the schema file is not found, or cannot be read/parsed/validated, a - `SchemaValidationError("Schema has problems", problems=[...])` is raised when - a `SchemaLogger` instance is created. In this case the logger instance is not - created at all. The `problems` list contains a detailed description of the - issue, including the path where the file was expected (based on the current - working directory at schema discovery time). + If the schema file is not found, or cannot be read/parsed/validated, the + logger instance is not created, schema problems are logged to stderr, and + the application is terminated via `os._exit(1)`. The error message contains + a detailed description of all issues, including the path where the file was + expected (based on the current working directory at schema discovery time). An example schema: @@ -317,30 +304,19 @@ consistent type expectations when reusing a `source` field. - If there are **any** problems with the schema (missing file, broken JSON, invalid `type` values, conflicting root fields that match system logging fields, malformed structure, etc.): - - a `SchemaValidationError("Schema has problems", problems=[...])` is raised; - - the logger instance is not created. + - the logger instance is not created; + - schema problems are logged to stderr in the format: + `"Schema has problems: {problem1}; {problem2}; ..."`; + - the application is terminated via `os._exit(1)`. - If there are no problems: - the schema is compiled into a `CompiledSchema`; - the logger is created and starts using this schema to validate `extra` fields. -The application decides whether `SchemaValidationError` is a fatal error that -should abort startup, or whether it should be logged and ignored. - -**Note**: In rare cases, system-level errors may be raised directly instead of -being wrapped in `SchemaValidationError`. Specifically: - -- `OSError` when the current working directory is inaccessible or deleted (e.g., - when `os.getcwd()` fails) is propagated as-is to indicate environmental issues. - However, `OSError` that occurs when reading the schema file (e.g., permission - denied, I/O errors) is converted to `SchemaValidationError` and reported as a - schema problem. -- `ValueError` when path resolution fails due to invalid characters or malformed - paths during schema file discovery is propagated as-is. -- `RuntimeError` when thread lock acquisition fails is propagated as-is. - -Applications should handle these exceptions separately if they need to -distinguish between schema validation failures and system-level errors. +**Note**: System-level errors (OSError, ValueError, RuntimeError) that occur +during schema compilation are converted to `SchemaProblem` instances and +handled the same way as schema validation problems - the application is +terminated after logging the error to stderr. ## Schema caching and thread safety @@ -412,9 +388,9 @@ High-level algorithm inside `SchemaLogger`: ERROR message is logged with the full `problems` list (no exception is raised, ensuring 100% compatibility with standard logger behavior). -## Exceptions +## Error handling -- **`SchemaValidationError`**: - - Any problem with the schema (missing file, broken JSON, invalid types, - conflicting root fields, malformed structure, etc.). - - Exposes a `problems` list describing each violation. +- Schema problems are handled internally: errors are logged to stderr and + the application is terminated via `os._exit(1)`. +- No exceptions are raised by `SchemaLogger` during initialization, making + it a true drop-in replacement for `logging.Logger`. diff --git a/src/logging_objects_with_schema/__init__.py b/src/logging_objects_with_schema/__init__.py index 55ee385..d81e922 100644 --- a/src/logging_objects_with_schema/__init__.py +++ b/src/logging_objects_with_schema/__init__.py @@ -7,10 +7,8 @@ from __future__ import annotations -from .errors import SchemaValidationError from .schema_logger import SchemaLogger __all__ = [ "SchemaLogger", - "SchemaValidationError", ] diff --git a/src/logging_objects_with_schema/errors.py b/src/logging_objects_with_schema/errors.py index 9a87fa9..5b6d15c 100644 --- a/src/logging_objects_with_schema/errors.py +++ b/src/logging_objects_with_schema/errors.py @@ -17,11 +17,12 @@ class SchemaProblem: class SchemaValidationError(Exception): - """Raised when there are problems with the JSON schema definition. + """Exception for schema validation problems (deprecated). - This exception is raised during SchemaLogger initialization when the schema - file cannot be loaded, parsed, or validated. The logger instance will not - be created if this exception is raised. + This exception class is kept for backward compatibility but is no longer + raised by :class:`SchemaLogger` during initialization. Schema problems are + now handled internally: errors are logged to stderr and the application + is terminated via ``os._exit(1)``. The human-readable summary is stored in the exception message, while detailed information about each violation is available in the ``problems`` @@ -30,12 +31,10 @@ class SchemaValidationError(Exception): Attributes: problems: List of SchemaProblem instances describing each validation issue. - Example: - >>> try: - ... logger = SchemaLogger("my_logger") - ... except SchemaValidationError as e: - ... for problem in e.problems: - ... print(f"Schema error: {problem.message}") + Note: + This exception may still be used internally or in tests, but + applications should not expect to catch it when creating SchemaLogger + instances. """ def __init__( diff --git a/src/logging_objects_with_schema/schema_logger.py b/src/logging_objects_with_schema/schema_logger.py index 92be968..66f7476 100644 --- a/src/logging_objects_with_schema/schema_logger.py +++ b/src/logging_objects_with_schema/schema_logger.py @@ -11,11 +11,12 @@ import inspect import logging +import os import sys from collections.abc import Mapping from typing import Any -from .errors import SchemaValidationError +from .errors import SchemaProblem from .schema_applier import _apply_schema_internal from .schema_loader import CompiledSchema, _compile_schema_internal @@ -29,7 +30,8 @@ class SchemaLogger(logging.Logger): The schema is loaded from ``logging_objects_with_schema.json`` in the application root directory during initialization. If the schema cannot - be loaded or validated, a :class:`SchemaValidationError` is raised. + be loaded or validated, the logger instance is not created, schema + problems are logged to stderr, and the application is terminated. Example: >>> import logging @@ -43,8 +45,9 @@ def __init__(self, name: str, level: int = logging.NOTSET) -> None: """Initialise the schema-aware logger. The schema is compiled once during construction. If any - problems are detected in the schema, a SchemaValidationError - will be raised and this logger instance is not usable. + problems are detected in the schema, the logger instance is not + created, schema problems are logged to stderr, and the application + is terminated. Args: name: Logger name (same as :class:`logging.Logger`). @@ -54,13 +57,14 @@ def __init__(self, name: str, level: int = logging.NOTSET) -> None: try: compiled, problems = _compile_schema_internal() - except (OSError, ValueError, RuntimeError): - # Catch specific exceptions that can occur during schema compilation: + except (OSError, ValueError, RuntimeError) as exc: + # Convert system-level exceptions to SchemaProblem so they can be + # handled the same way as schema validation problems. # - OSError: system-level file system issues (e.g., os.getcwd() failures # when the current working directory is inaccessible or deleted). # Note: OSError that occurs when reading the schema file (e.g., permission - # denied, I/O errors) is converted to SchemaValidationError in - # _load_raw_schema() and does not reach this exception handler. + # denied, I/O errors) is converted to SchemaProblem in _load_raw_schema() + # and does not reach this exception handler. # - ValueError: path resolution issues (e.g., invalid path characters, # malformed paths during schema file discovery) # - RuntimeError: threading issues (e.g., lock acquisition problems) @@ -68,18 +72,15 @@ def __init__(self, name: str, level: int = logging.NOTSET) -> None: # converted to SchemaProblem instances and do not raise ValueError here. # Note: System exceptions (KeyboardInterrupt, SystemExit) are not # caught, which is the correct behavior. - # Ensure that a partially initialised logger instance is not left - # registered in the logging manager if schema compilation fails. - # Otherwise, subsequent logging.getLogger(name) calls could return - # this broken instance and lead to AttributeError at runtime. - self._cleanup_failed_logger() - raise + problems = [SchemaProblem(f"Schema compilation failed: {exc}")] + compiled = CompiledSchema(leaves=[]) if problems: # Schema is invalid; remove this instance from the logging manager - # cache before raising, for the same reason as above. + # cache before logging and terminating, to prevent broken logger + # instances from being cached and reused. self._cleanup_failed_logger() - raise SchemaValidationError("Schema has problems", problems=problems) + self._log_schema_problems_and_exit(problems, name) self._schema: CompiledSchema = compiled @@ -91,6 +92,23 @@ def _cleanup_failed_logger(self) -> None: """ self.manager.loggerDict.pop(self.name, None) + def _log_schema_problems_and_exit( + self, problems: list[SchemaProblem], logger_name: str + ) -> None: + """Log schema problems to stderr and terminate the application. + + Args: + problems: List of schema problems to log. + logger_name: Name of the logger that failed to initialize. + """ + # Format error message with details of all problems + # (same format as data problems) + problem_messages = [problem.message for problem in problems] + error_msg = f"Schema has problems: {'; '.join(problem_messages)}\n" + sys.stderr.write(error_msg) + sys.stderr.flush() + os._exit(1) + def _log( self, level: int, diff --git a/tests/test_integration.py b/tests/test_integration.py index c2a86a8..0fab690 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -13,7 +13,6 @@ import pytest from logging_objects_with_schema import SchemaLogger -from logging_objects_with_schema.errors import SchemaValidationError from logging_objects_with_schema.schema_loader import SCHEMA_FILE_NAME from tests.conftest import _write_schema @@ -132,27 +131,51 @@ def test_schema_file_in_current_working_directory( assert "test message" in output -def test_schema_file_not_found_raises_error( +def test_schema_file_not_found_terminates_application( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Schema file not found should raise SchemaValidationError.""" + """Schema file not found should terminate application.""" + + import os + import sys + from io import StringIO # Change to directory without schema file monkeypatch.chdir(tmp_path) - with pytest.raises(SchemaValidationError) as exc_info: + exit_called = False + exit_code = None + stderr_output = StringIO() + + def fake_exit(code: int) -> None: + nonlocal exit_called, exit_code + exit_called = True + exit_code = code + raise SystemExit(code) + + monkeypatch.setattr(os, "_exit", fake_exit) + monkeypatch.setattr(sys, "stderr", stderr_output) + + with pytest.raises(SystemExit): SchemaLogger("test") - assert exc_info.value.problems - assert any("not found" in str(p.message).lower() for p in exc_info.value.problems) + assert exit_called + assert exit_code == 1 + stderr_content = stderr_output.getvalue() + assert "Schema has problems" in stderr_content + assert "not found" in stderr_content.lower() -def test_schema_validation_error_on_invalid_schema( +def test_schema_validation_error_on_invalid_schema_terminates_application( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Invalid schema should raise SchemaValidationError.""" + """Invalid schema should terminate application.""" + + import os + import sys + from io import StringIO monkeypatch.chdir(tmp_path) _write_schema( @@ -164,14 +187,27 @@ def test_schema_validation_error_on_invalid_schema( }, ) - with pytest.raises(SchemaValidationError) as exc_info: + exit_called = False + exit_code = None + stderr_output = StringIO() + + def fake_exit(code: int) -> None: + nonlocal exit_called, exit_code + exit_called = True + exit_code = code + raise SystemExit(code) + + monkeypatch.setattr(os, "_exit", fake_exit) + monkeypatch.setattr(sys, "stderr", stderr_output) + + with pytest.raises(SystemExit): SchemaLogger("test") - assert exc_info.value.problems - assert any( - "conflicts with reserved logging fields" in p.message - for p in exc_info.value.problems - ) + assert exit_called + assert exit_code == 1 + stderr_content = stderr_output.getvalue() + assert "Schema has problems" in stderr_content + assert "conflicts with reserved logging fields" in stderr_content def test_logger_with_setloggerclass_creates_schema_logger( @@ -310,11 +346,15 @@ def test_logger_with_empty_schema( assert "another" in output -def test_schema_file_permission_error_raises_schema_validation_error( +def test_schema_file_permission_error_terminates_application( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Unreadable schema file should result in SchemaValidationError, not OSError.""" + """Unreadable schema file should terminate application.""" + + import os + import sys + from io import StringIO import logging_objects_with_schema.schema_loader as schema_loader @@ -339,12 +379,24 @@ def fake_open(self, *args, **kwargs): # type: ignore[override] monkeypatch.setattr(schema_loader.Path, "open", fake_open) - with pytest.raises(SchemaValidationError) as exc_info: + exit_called = False + exit_code = None + stderr_output = StringIO() + + def fake_exit(code: int) -> None: + nonlocal exit_called, exit_code + exit_called = True + exit_code = code + raise SystemExit(code) + + monkeypatch.setattr(os, "_exit", fake_exit) + monkeypatch.setattr(sys, "stderr", stderr_output) + + with pytest.raises(SystemExit): SchemaLogger("test-permission-error") - # Problems list should contain a message about failing to read the schema file - assert exc_info.value.problems - assert any( - "Failed to read schema file" in problem.message - for problem in exc_info.value.problems - ) + assert exit_called + assert exit_code == 1 + stderr_content = stderr_output.getvalue() + assert "Schema has problems" in stderr_content + assert "Failed to read schema file" in stderr_content diff --git a/tests/test_schema_logger_mimic.py b/tests/test_schema_logger_mimic.py index 9c54e82..69dee72 100644 --- a/tests/test_schema_logger_mimic.py +++ b/tests/test_schema_logger_mimic.py @@ -9,7 +9,6 @@ import pytest from logging_objects_with_schema import SchemaLogger -from logging_objects_with_schema.errors import SchemaValidationError from tests.conftest import _write_schema @@ -127,11 +126,15 @@ def test_schema_logger_validates_extra_fields( assert "msg" in output -def test_schema_logger_raises_schema_validation_error_on_bad_schema( +def test_schema_logger_terminates_on_bad_schema( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """SchemaLogger should raise SchemaValidationError when schema has problems.""" + """SchemaLogger should terminate application when schema has problems.""" + + import os + import sys + from io import StringIO monkeypatch.chdir(tmp_path) # Use a root key that conflicts with logging.LogRecord field @@ -144,9 +147,26 @@ def test_schema_logger_raises_schema_validation_error_on_bad_schema( }, ) - with pytest.raises(SchemaValidationError): + exit_called = False + exit_code = None + stderr_output = StringIO() + + def fake_exit(code: int) -> None: + nonlocal exit_called, exit_code + exit_called = True + exit_code = code + raise SystemExit(code) + + monkeypatch.setattr(os, "_exit", fake_exit) + monkeypatch.setattr(sys, "stderr", stderr_output) + + with pytest.raises(SystemExit): SchemaLogger("test-logger") + assert exit_called + assert exit_code == 1 + assert "Schema has problems" in stderr_output.getvalue() + def test_schema_logger_does_not_leave_partially_initialised_logger_in_cache( tmp_path: Path, @@ -162,9 +182,26 @@ def test_schema_logger_does_not_leave_partially_initialised_logger_in_cache( monkeypatch.chdir(tmp_path) + import os + import sys + from io import StringIO + + exit_called = False + exit_code = None + stderr_output = StringIO() + + def fake_exit(code: int) -> None: + nonlocal exit_called, exit_code + exit_called = True + exit_code = code + raise SystemExit(code) + + monkeypatch.setattr(os, "_exit", fake_exit) + monkeypatch.setattr(sys, "stderr", stderr_output) + logging.setLoggerClass(SchemaLogger) try: - # 1) Write an invalid schema that triggers SchemaValidationError. + # 1) Write an invalid schema that triggers termination. _write_schema( tmp_path, { @@ -174,26 +211,13 @@ def test_schema_logger_does_not_leave_partially_initialised_logger_in_cache( }, ) - # Attempting to create/get the logger should raise SchemaValidationError, + # Attempting to create/get the logger should terminate the application, # and the partially initialised instance must be removed from cache. - with pytest.raises(SchemaValidationError): + with pytest.raises(SystemExit): logging.getLogger("bad-schema-logger") - # 2) Fix the schema on disk. Because schema compilation result is cached - # within the same process, subsequent attempts should still raise - # SchemaValidationError, but must not leave a broken logger instance - # in logging's internal cache. - _write_schema( - tmp_path, - { - "ServicePayload": { - "RequestID": {"type": "str", "source": "request_id"}, - }, - }, - ) - - with pytest.raises(SchemaValidationError): - logging.getLogger("bad-schema-logger") + assert exit_called + assert exit_code == 1 # Ensure that the logger with this name is not left registered in the # logging manager cache after failed initialisation. @@ -202,17 +226,19 @@ def test_schema_logger_does_not_leave_partially_initialised_logger_in_cache( logging.setLoggerClass(logging.Logger) -def test_schema_logger_handles_oserror_from_getcwd_and_cleans_up( +def test_schema_logger_handles_oserror_from_getcwd_and_terminates( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """SchemaLogger should catch OSError from os.getcwd() and clean up logger. + """SchemaLogger should catch OSError from os.getcwd() and terminate. If os.getcwd() raises OSError (e.g., when CWD is deleted), the exception - should be caught, logger should be cleaned up from cache, and exception - should be re-raised. + should be converted to SchemaProblem, logger should be cleaned up from cache, + and application should be terminated. """ import os + import sys + from io import StringIO import logging_objects_with_schema.schema_loader as schema_loader @@ -226,6 +252,19 @@ def fake_getcwd() -> str: monkeypatch.setattr(os, "getcwd", fake_getcwd) + exit_called = False + exit_code = None + stderr_output = StringIO() + + def fake_exit(code: int) -> None: + nonlocal exit_called, exit_code + exit_called = True + exit_code = code + raise SystemExit(code) + + monkeypatch.setattr(os, "_exit", fake_exit) + monkeypatch.setattr(sys, "stderr", stderr_output) + logging.setLoggerClass(SchemaLogger) try: # Clear schema cache to force recompilation @@ -235,11 +274,15 @@ def fake_getcwd() -> str: schema_loader._resolved_schema_path = None schema_loader._cached_cwd = None - # Attempting to create/get the logger should raise OSError, + # Attempting to create/get the logger should terminate the application, # and the partially initialised instance must be removed from cache. - with pytest.raises(OSError, match="Current working directory"): + with pytest.raises(SystemExit): logging.getLogger("oserror-logger") + assert exit_called + assert exit_code == 1 + assert "Schema has problems" in stderr_output.getvalue() + # Ensure that the logger with this name is not left registered in the # logging manager cache after failed initialisation. assert "oserror-logger" not in logging.Logger.manager.loggerDict @@ -248,16 +291,20 @@ def fake_getcwd() -> str: logging.setLoggerClass(logging.Logger) -def test_schema_logger_handles_runtimeerror_from_lock_and_cleans_up( +def test_schema_logger_handles_runtimeerror_from_lock_and_terminates( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """SchemaLogger should catch RuntimeError from threading locks and clean up. + """SchemaLogger should catch RuntimeError from threading locks and terminate. If a threading lock raises RuntimeError (e.g., deadlock detection), - the exception should be caught, logger should be cleaned up from cache, - and exception should be re-raised. + the exception should be converted to SchemaProblem, logger should be cleaned + up from cache, and application should be terminated. """ + import os + import sys + from io import StringIO + import logging_objects_with_schema.schema_loader as schema_loader monkeypatch.chdir(tmp_path) @@ -275,6 +322,19 @@ def __exit__(self, *args: object) -> None: fake_lock = FakeLock() monkeypatch.setattr(schema_loader, "_cache_lock", fake_lock) + exit_called = False + exit_code = None + stderr_output = StringIO() + + def fake_exit(code: int) -> None: + nonlocal exit_called, exit_code + exit_called = True + exit_code = code + raise SystemExit(code) + + monkeypatch.setattr(os, "_exit", fake_exit) + monkeypatch.setattr(sys, "stderr", stderr_output) + logging.setLoggerClass(SchemaLogger) try: # Clear schema cache to force recompilation @@ -284,11 +344,15 @@ def __exit__(self, *args: object) -> None: schema_loader._resolved_schema_path = None schema_loader._cached_cwd = None - # Attempting to create/get the logger should raise RuntimeError, + # Attempting to create/get the logger should terminate the application, # and the partially initialised instance must be removed from cache. - with pytest.raises(RuntimeError, match="Lock acquisition failed"): + with pytest.raises(SystemExit): logging.getLogger("runtimeerror-logger") + assert exit_called + assert exit_code == 1 + assert "Schema has problems" in stderr_output.getvalue() + # Ensure that the logger with this name is not left registered in the # logging manager cache after failed initialisation. assert "runtimeerror-logger" not in logging.Logger.manager.loggerDict @@ -297,17 +361,21 @@ def __exit__(self, *args: object) -> None: logging.setLoggerClass(logging.Logger) -def test_schema_logger_handles_valueerror_and_cleans_up( +def test_schema_logger_handles_valueerror_and_terminates( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """SchemaLogger should catch ValueError and clean up logger. + """SchemaLogger should catch ValueError and terminate. If ValueError is raised during schema compilation (outside of the try-except block in _compile_schema_internal), the exception should be - caught, logger should be cleaned up from cache, and exception should be - re-raised. + converted to SchemaProblem, logger should be cleaned up from cache, + and application should be terminated. """ + import os + import sys + from io import StringIO + import logging_objects_with_schema.schema_loader as schema_loader monkeypatch.chdir(tmp_path) @@ -320,6 +388,19 @@ def fake_get_schema_path() -> Path: monkeypatch.setattr(schema_loader, "_get_schema_path", fake_get_schema_path) + exit_called = False + exit_code = None + stderr_output = StringIO() + + def fake_exit(code: int) -> None: + nonlocal exit_called, exit_code + exit_called = True + exit_code = code + raise SystemExit(code) + + monkeypatch.setattr(os, "_exit", fake_exit) + monkeypatch.setattr(sys, "stderr", stderr_output) + logging.setLoggerClass(SchemaLogger) try: # Clear schema cache to force recompilation @@ -329,11 +410,15 @@ def fake_get_schema_path() -> Path: schema_loader._resolved_schema_path = None schema_loader._cached_cwd = None - # Attempting to create/get the logger should raise ValueError, + # Attempting to create/get the logger should terminate the application, # and the partially initialised instance must be removed from cache. - with pytest.raises(ValueError, match="Unexpected value error"): + with pytest.raises(SystemExit): logging.getLogger("valueerror-logger") + assert exit_called + assert exit_code == 1 + assert "Schema has problems" in stderr_output.getvalue() + # Ensure that the logger with this name is not left registered in the # logging manager cache after failed initialisation. assert "valueerror-logger" not in logging.Logger.manager.loggerDict diff --git a/tests/test_thread_safety.py b/tests/test_thread_safety.py index 11681b7..20cd76b 100644 --- a/tests/test_thread_safety.py +++ b/tests/test_thread_safety.py @@ -9,7 +9,6 @@ import pytest from logging_objects_with_schema import SchemaLogger -from logging_objects_with_schema.errors import SchemaValidationError from tests.conftest import _write_schema @@ -94,8 +93,8 @@ def compile_schema() -> None: ) compiled, problems = compile_schema_internal() - if problems: - raise SchemaValidationError("Schema has problems", problems=problems) + # Schema should compile successfully (no problems) + assert not problems, f"Schema has problems: {problems}" with lock: compiled_schemas.append(compiled) except Exception as e: From 98eb7d582901e943c305eaf33807ad4950a7326c Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Sat, 6 Dec 2025 16:07:57 +0400 Subject: [PATCH 11/27] refactor(logging): remove deprecated SchemaValidationError and update error handling - Removed the deprecated SchemaValidationError class to streamline error management in the logging system. - Updated the SchemaLogger to log schema problems without raising exceptions, enhancing clarity in error handling. - Adjusted documentation to reflect the changes in error handling behavior. This refactor simplifies the logging process and improves the overall maintainability of the codebase. Signed-off-by: Dmitrii Safronov --- src/logging_objects_with_schema/errors.py | 28 ------------------- .../schema_loader.py | 7 ++--- .../schema_logger.py | 7 ++--- 3 files changed, 5 insertions(+), 37 deletions(-) diff --git a/src/logging_objects_with_schema/errors.py b/src/logging_objects_with_schema/errors.py index 5b6d15c..2746985 100644 --- a/src/logging_objects_with_schema/errors.py +++ b/src/logging_objects_with_schema/errors.py @@ -16,34 +16,6 @@ class SchemaProblem: message: str -class SchemaValidationError(Exception): - """Exception for schema validation problems (deprecated). - - This exception class is kept for backward compatibility but is no longer - raised by :class:`SchemaLogger` during initialization. Schema problems are - now handled internally: errors are logged to stderr and the application - is terminated via ``os._exit(1)``. - - The human-readable summary is stored in the exception message, while - detailed information about each violation is available in the ``problems`` - attribute. - - Attributes: - problems: List of SchemaProblem instances describing each validation issue. - - Note: - This exception may still be used internally or in tests, but - applications should not expect to catch it when creating SchemaLogger - instances. - """ - - def __init__( - self, message: str, problems: list[SchemaProblem] | None = None - ) -> None: - super().__init__(message) - self.problems: list[SchemaProblem] = problems or [] - - @dataclass class DataProblem: """Describes a single problem encountered while validating log data. diff --git a/src/logging_objects_with_schema/schema_loader.py b/src/logging_objects_with_schema/schema_loader.py index 639de0c..614b3de 100644 --- a/src/logging_objects_with_schema/schema_loader.py +++ b/src/logging_objects_with_schema/schema_loader.py @@ -559,10 +559,9 @@ def _compile_schema_internal() -> tuple[CompiledSchema, list[SchemaProblem]]: to the schema, the application must restart the process. See the README section \"Schema caching and thread safety\" for more details. - This function never raises ``SchemaValidationError``. It always returns - the best-effort compiled schema together with a list of problems detected - during processing (an empty ``CompiledSchema`` when the schema is missing - or invalid). + This function never raises exceptions. It always returns the best-effort + compiled schema together with a list of problems detected during processing + (an empty ``CompiledSchema`` when the schema is missing or invalid). Note: This function is used internally by :class:`SchemaLogger` and by the diff --git a/src/logging_objects_with_schema/schema_logger.py b/src/logging_objects_with_schema/schema_logger.py index 66f7476..170da50 100644 --- a/src/logging_objects_with_schema/schema_logger.py +++ b/src/logging_objects_with_schema/schema_logger.py @@ -80,7 +80,7 @@ def __init__(self, name: str, level: int = logging.NOTSET) -> None: # cache before logging and terminating, to prevent broken logger # instances from being cached and reused. self._cleanup_failed_logger() - self._log_schema_problems_and_exit(problems, name) + self._log_schema_problems_and_exit(problems) self._schema: CompiledSchema = compiled @@ -92,14 +92,11 @@ def _cleanup_failed_logger(self) -> None: """ self.manager.loggerDict.pop(self.name, None) - def _log_schema_problems_and_exit( - self, problems: list[SchemaProblem], logger_name: str - ) -> None: + def _log_schema_problems_and_exit(self, problems: list[SchemaProblem]) -> None: """Log schema problems to stderr and terminate the application. Args: problems: List of schema problems to log. - logger_name: Name of the logger that failed to initialize. """ # Format error message with details of all problems # (same format as data problems) From 07d804a01a67c7fdbe71f82e46df305ec5868f5f Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sat, 6 Dec 2025 12:34:23 +0000 Subject: [PATCH 12/27] chore(release): 0.1.2-rc.2 ## [0.1.2-rc.2](https://github.com/disafronov/python-logging-objects-with-schema/compare/v0.1.2-rc.1...v0.1.2-rc.2) (2025-12-06) Signed-off-by: Release Bot --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1d6c843..d61bc60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "uv_build" [project] name = "logging-objects-with-schema" -version = "0.1.2rc1" +version = "0.1.2rc2" description = "Proxy logging wrapper that validates extra fields against a JSON schema." readme = "README.md" requires-python = ">=3.10" diff --git a/uv.lock b/uv.lock index 0c7d968..81ffffd 100644 --- a/uv.lock +++ b/uv.lock @@ -1394,7 +1394,7 @@ version = "0.6.3" [[package]] name = "logging-objects-with-schema" -version = "0.1.2rc1" +version = "0.1.2rc2" [package.source] editable = "." From 13630c17ff38eb68c4b7a0a76f09dae5b5a58ff6 Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Sat, 6 Dec 2025 16:18:38 +0400 Subject: [PATCH 13/27] docs(logging): update README and tests for valid empty schema handling - Added documentation in README.md to clarify that a valid empty schema (e.g., `{}`) is acceptable and does not cause errors during logger creation. - Updated the SchemaLogger class to ensure that partially initialized instances are removed from the cache if schema validation fails. - Introduced a new test to verify that the SchemaLogger can be created successfully with a valid empty schema, ensuring it behaves as expected without raising exceptions. These changes enhance the clarity of schema handling in the logging system and improve test coverage for edge cases. Signed-off-by: Dmitrii Safronov --- README.md | 16 ++++++++++++- .../schema_logger.py | 4 ++++ tests/test_schema_logger_mimic.py | 24 +++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 22b2589..223427b 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,16 @@ An example schema: } ``` +An example of a valid empty schema (no leaves, no problems): + +```json +{} +``` + +An empty schema is valid and does not cause errors. When using an empty schema, +no `extra` fields will be included in log records, and any attempt to log with +`extra` fields will result in validation errors being logged as ERROR messages. + - An inner node is an object without `type` and `source`. - A leaf node is an object with both `type` and `source`. - `type` is one of the allowed Python type names: `"str"`, `"int"`, `"float"`, @@ -304,7 +314,8 @@ consistent type expectations when reusing a `source` field. - If there are **any** problems with the schema (missing file, broken JSON, invalid `type` values, conflicting root fields that match system logging fields, malformed structure, etc.): - - the logger instance is not created; + - the partially initialized logger instance is removed from the logging + manager cache to prevent broken instances from being reused; - schema problems are logged to stderr in the format: `"Schema has problems: {problem1}; {problem2}; ..."`; - the application is terminated via `os._exit(1)`. @@ -312,6 +323,9 @@ consistent type expectations when reusing a `source` field. - the schema is compiled into a `CompiledSchema`; - the logger is created and starts using this schema to validate `extra` fields. + - A valid empty schema (e.g., `{}` or a schema with only inner nodes and no + leaves) is treated as valid and does not cause errors. The logger is created + successfully, but no `extra` fields will be included in log records. **Note**: System-level errors (OSError, ValueError, RuntimeError) that occur during schema compilation are converted to `SchemaProblem` instances and diff --git a/src/logging_objects_with_schema/schema_logger.py b/src/logging_objects_with_schema/schema_logger.py index 170da50..d1da66d 100644 --- a/src/logging_objects_with_schema/schema_logger.py +++ b/src/logging_objects_with_schema/schema_logger.py @@ -54,6 +54,10 @@ def __init__(self, name: str, level: int = logging.NOTSET) -> None: level: Logger level (same as :class:`logging.Logger`). """ super().__init__(name, level) + # Note: The logger instance is created before checking for schema problems + # intentionally. This allows us to properly clean up the instance from the + # logging manager cache if schema validation fails, preventing broken logger + # instances from being cached and reused. try: compiled, problems = _compile_schema_internal() diff --git a/tests/test_schema_logger_mimic.py b/tests/test_schema_logger_mimic.py index 69dee72..e2909ff 100644 --- a/tests/test_schema_logger_mimic.py +++ b/tests/test_schema_logger_mimic.py @@ -168,6 +168,30 @@ def fake_exit(code: int) -> None: assert "Schema has problems" in stderr_output.getvalue() +def test_schema_logger_creates_successfully_with_valid_empty_schema( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """SchemaLogger should create successfully with a valid empty schema. + + A valid empty schema (e.g., {}) should not cause errors or terminate + the application. The logger should be created successfully. + """ + + monkeypatch.chdir(tmp_path) + _write_schema(tmp_path, {}) + + # Creating logger with empty schema should not raise exceptions or terminate + logger = SchemaLogger("test-logger") + + assert isinstance(logger, logging.Logger) + assert logger.name == "test-logger" + # Verify that the logger has the _schema attribute set + assert hasattr(logger, "_schema") + # Verify that the schema is empty (no leaves) + assert logger._schema.is_empty + + def test_schema_logger_does_not_leave_partially_initialised_logger_in_cache( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, From ba03bb51692d9b34095c34ed48fd64d7745cedc4 Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Sat, 6 Dec 2025 16:24:50 +0400 Subject: [PATCH 14/27] refactor(logging): improve schema validation handling in SchemaLogger - Updated the SchemaLogger to validate the schema before creating the logger instance, preventing broken instances from being cached. - Removed the cleanup method for failed logger instances, simplifying the error handling process. - Enhanced the README.md to reflect the new behavior of schema validation and logger initialization. These changes streamline the logging process and improve the clarity of schema handling in the logging system. Signed-off-by: Dmitrii Safronov --- README.md | 4 +- .../schema_logger.py | 56 ++++++++----------- 2 files changed, 25 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 223427b..2c6daf5 100644 --- a/README.md +++ b/README.md @@ -314,8 +314,8 @@ consistent type expectations when reusing a `source` field. - If there are **any** problems with the schema (missing file, broken JSON, invalid `type` values, conflicting root fields that match system logging fields, malformed structure, etc.): - - the partially initialized logger instance is removed from the logging - manager cache to prevent broken instances from being reused; + - the logger instance is not created (schema validation happens before + the logger is initialized); - schema problems are logged to stderr in the format: `"Schema has problems: {problem1}; {problem2}; ..."`; - the application is terminated via `os._exit(1)`. diff --git a/src/logging_objects_with_schema/schema_logger.py b/src/logging_objects_with_schema/schema_logger.py index d1da66d..a9ea4fb 100644 --- a/src/logging_objects_with_schema/schema_logger.py +++ b/src/logging_objects_with_schema/schema_logger.py @@ -21,6 +21,21 @@ from .schema_loader import CompiledSchema, _compile_schema_internal +def _log_schema_problems_and_exit(problems: list[SchemaProblem]) -> None: + """Log schema problems to stderr and terminate the application. + + Args: + problems: List of schema problems to log. + """ + # Format error message with details of all problems + # (same format as data problems) + problem_messages = [problem.message for problem in problems] + error_msg = f"Schema has problems: {'; '.join(problem_messages)}\n" + sys.stderr.write(error_msg) + sys.stderr.flush() + os._exit(1) + + class SchemaLogger(logging.Logger): """Logger subclass that enforces schema on extra fields. @@ -53,12 +68,9 @@ def __init__(self, name: str, level: int = logging.NOTSET) -> None: name: Logger name (same as :class:`logging.Logger`). level: Logger level (same as :class:`logging.Logger`). """ - super().__init__(name, level) - # Note: The logger instance is created before checking for schema problems - # intentionally. This allows us to properly clean up the instance from the - # logging manager cache if schema validation fails, preventing broken logger - # instances from being cached and reused. - + # Validate schema before creating the logger instance to avoid + # registering a broken logger in the logging manager cache. + # Schema is compiled and cached first, then problems are checked. try: compiled, problems = _compile_schema_internal() except (OSError, ValueError, RuntimeError) as exc: @@ -80,36 +92,14 @@ def __init__(self, name: str, level: int = logging.NOTSET) -> None: compiled = CompiledSchema(leaves=[]) if problems: - # Schema is invalid; remove this instance from the logging manager - # cache before logging and terminating, to prevent broken logger - # instances from being cached and reused. - self._cleanup_failed_logger() - self._log_schema_problems_and_exit(problems) + # Schema is invalid; log problems and terminate without creating + # the logger instance. + _log_schema_problems_and_exit(problems) + # Schema is valid; create the logger instance. + super().__init__(name, level) self._schema: CompiledSchema = compiled - def _cleanup_failed_logger(self) -> None: - """Remove this logger instance from the logging manager. - - Called when schema compilation fails to prevent broken logger - instances from being cached and reused. - """ - self.manager.loggerDict.pop(self.name, None) - - def _log_schema_problems_and_exit(self, problems: list[SchemaProblem]) -> None: - """Log schema problems to stderr and terminate the application. - - Args: - problems: List of schema problems to log. - """ - # Format error message with details of all problems - # (same format as data problems) - problem_messages = [problem.message for problem in problems] - error_msg = f"Schema has problems: {'; '.join(problem_messages)}\n" - sys.stderr.write(error_msg) - sys.stderr.flush() - os._exit(1) - def _log( self, level: int, From d4ef000fafac8e7f6ccde31d09507e822ba1c210 Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Sat, 6 Dec 2025 16:37:14 +0400 Subject: [PATCH 15/27] docs(logging): clarify stack handling in SchemaLogger comments - Updated comments in the SchemaLogger to explain the use of inspect.stack() for caller information retrieval, particularly addressing issues with Python 3.10's findCaller() method. - Enhanced clarity regarding the fallback mechanism for obtaining caller information, improving maintainability of the logging implementation. These changes ensure that the logging behavior is well-documented and consistent across different Python versions. Signed-off-by: Dmitrii Safronov --- src/logging_objects_with_schema/schema_logger.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/logging_objects_with_schema/schema_logger.py b/src/logging_objects_with_schema/schema_logger.py index a9ea4fb..4129d1e 100644 --- a/src/logging_objects_with_schema/schema_logger.py +++ b/src/logging_objects_with_schema/schema_logger.py @@ -147,7 +147,9 @@ def _log( if data_problems: # Log validation errors as ERROR messages # Get caller information using inspect.stack() to ensure consistent behavior - # across different Python versions. The stack looks like: + # across different Python versions. Python 3.10 has issues with findCaller() + # and stacklevel parameter, so we use inspect.stack() as the primary method + # with findCaller() as a fallback. The stack looks like: # - Frame 0: this function (_log) # - Frame 1: logger.info() wrapper # - Frame 2: actual caller (test_function) From b60294b2d76e9214c7dfdf96e735fede590b7d5c Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Sat, 6 Dec 2025 16:49:50 +0400 Subject: [PATCH 16/27] docs(logging): refine comments on caller information retrieval in SchemaLogger - Clarified comments in the SchemaLogger regarding the use of inspect.stack() for obtaining caller information, specifically addressing compatibility with Python 3.10's findCaller() method. - Enhanced the explanation of the fallback mechanism to improve understanding and maintainability of the logging implementation. These updates ensure that the logging behavior is well-documented and consistent across different Python versions. Signed-off-by: Dmitrii Safronov --- src/logging_objects_with_schema/schema_logger.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/logging_objects_with_schema/schema_logger.py b/src/logging_objects_with_schema/schema_logger.py index 4129d1e..d47403d 100644 --- a/src/logging_objects_with_schema/schema_logger.py +++ b/src/logging_objects_with_schema/schema_logger.py @@ -147,9 +147,9 @@ def _log( if data_problems: # Log validation errors as ERROR messages # Get caller information using inspect.stack() to ensure consistent behavior - # across different Python versions. Python 3.10 has issues with findCaller() - # and stacklevel parameter, so we use inspect.stack() as the primary method - # with findCaller() as a fallback. The stack looks like: + # across all Python versions. Python 3.10 had issues with findCaller() and + # stacklevel parameter, so we use inspect.stack() as the primary method + # with findCaller() as a fallback for compatibility. The stack looks like: # - Frame 0: this function (_log) # - Frame 1: logger.info() wrapper # - Frame 2: actual caller (test_function) From 29a3e3f990f8b129eab728dadb1b0e2f8e020fce Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Sat, 6 Dec 2025 16:53:09 +0400 Subject: [PATCH 17/27] docs(logging): update comment for cache invalidation in schema loader - Revised the comment in the _check_cached_found_file_path function to clarify the reason for cache invalidation when the cached schema path no longer exists. This enhances understanding of the function's behavior and its implications for schema searching. These changes improve the documentation and maintainability of the schema loading process. Signed-off-by: Dmitrii Safronov --- src/logging_objects_with_schema/schema_loader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/logging_objects_with_schema/schema_loader.py b/src/logging_objects_with_schema/schema_loader.py index 614b3de..83db942 100644 --- a/src/logging_objects_with_schema/schema_loader.py +++ b/src/logging_objects_with_schema/schema_loader.py @@ -152,7 +152,7 @@ def _check_cached_found_file_path() -> Path | None: if _resolved_schema_path.exists(): return _resolved_schema_path - # If cached path doesn't exist, re-search (schema might have been moved) + # Cached file no longer exists; invalidate cache so caller will re-search _resolved_schema_path = None return None From 6da365527fa74e3682aed2d547f4efee891631ca Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Sat, 6 Dec 2025 16:57:38 +0400 Subject: [PATCH 18/27] refactor(logging): update import statements in schema applier and loader - Replaced imports from `typing` with `collections.abc` for `Mapping` and `MutableMapping` in both `schema_applier.py` and `schema_loader.py`. - This change aligns with modern Python practices and improves code clarity. These updates enhance the maintainability of the logging system by adhering to updated import conventions. Signed-off-by: Dmitrii Safronov --- src/logging_objects_with_schema/schema_applier.py | 3 ++- src/logging_objects_with_schema/schema_loader.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/logging_objects_with_schema/schema_applier.py b/src/logging_objects_with_schema/schema_applier.py index f174ab3..e6f544c 100644 --- a/src/logging_objects_with_schema/schema_applier.py +++ b/src/logging_objects_with_schema/schema_applier.py @@ -7,7 +7,8 @@ from __future__ import annotations from collections import defaultdict -from typing import Any, Mapping, MutableMapping +from collections.abc import Mapping, MutableMapping +from typing import Any from .errors import DataProblem from .schema_loader import CompiledSchema, SchemaLeaf diff --git a/src/logging_objects_with_schema/schema_loader.py b/src/logging_objects_with_schema/schema_loader.py index 83db942..14367c1 100644 --- a/src/logging_objects_with_schema/schema_loader.py +++ b/src/logging_objects_with_schema/schema_loader.py @@ -13,9 +13,10 @@ import logging import os import threading +from collections.abc import Iterable, Mapping, MutableMapping from dataclasses import dataclass from pathlib import Path -from typing import Any, Iterable, Mapping, MutableMapping +from typing import Any from .errors import SchemaProblem From 64bdf125a24323014b9043c08bceaac29cd76411 Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Sat, 6 Dec 2025 16:59:43 +0400 Subject: [PATCH 19/27] refactor(tests): update import statements for _write_schema in test files - Replaced imports of _write_schema from tests.conftest with direct imports from conftest in multiple test files, including test_integration.py, test_schema_loader.py, test_schema_logger_mimic.py, and test_thread_safety.py. - This change improves consistency in import paths and aligns with the project's structure. These updates enhance the maintainability of the test suite by standardizing import practices. Signed-off-by: Dmitrii Safronov --- tests/test_integration.py | 2 +- tests/test_schema_loader.py | 2 +- tests/test_schema_logger_mimic.py | 2 +- tests/test_thread_safety.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index 0fab690..50a54c4 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -11,10 +11,10 @@ from pathlib import Path import pytest +from conftest import _write_schema from logging_objects_with_schema import SchemaLogger from logging_objects_with_schema.schema_loader import SCHEMA_FILE_NAME -from tests.conftest import _write_schema def test_multiple_logger_instances_share_schema( diff --git a/tests/test_schema_loader.py b/tests/test_schema_loader.py index 80434ed..d6b49ef 100644 --- a/tests/test_schema_loader.py +++ b/tests/test_schema_loader.py @@ -10,6 +10,7 @@ from typing import Any import pytest +from conftest import _write_schema import logging_objects_with_schema.schema_loader as schema_loader from logging_objects_with_schema.errors import SchemaProblem @@ -63,7 +64,6 @@ from logging_objects_with_schema.schema_loader import ( get_builtin_logrecord_attributes, ) -from tests.conftest import _write_schema def test_missing_schema_file_produces_empty_schema_and_problem( diff --git a/tests/test_schema_logger_mimic.py b/tests/test_schema_logger_mimic.py index e2909ff..df7b65d 100644 --- a/tests/test_schema_logger_mimic.py +++ b/tests/test_schema_logger_mimic.py @@ -7,9 +7,9 @@ from pathlib import Path import pytest +from conftest import _write_schema from logging_objects_with_schema import SchemaLogger -from tests.conftest import _write_schema def test_schema_logger_is_logging_logger_instance( diff --git a/tests/test_thread_safety.py b/tests/test_thread_safety.py index 20cd76b..e38a085 100644 --- a/tests/test_thread_safety.py +++ b/tests/test_thread_safety.py @@ -7,9 +7,9 @@ from pathlib import Path import pytest +from conftest import _write_schema from logging_objects_with_schema import SchemaLogger -from tests.conftest import _write_schema def test_concurrent_logger_creation( From 3469c0a3dbc825c4fbb3dc8ebf0d23ff29521813 Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Sat, 6 Dec 2025 17:01:02 +0400 Subject: [PATCH 20/27] docs(logging): enhance comment for excessive nesting depth check in schema loader - Updated the comment in the _compile_schema_tree function to provide a clearer explanation of the excessive nesting depth check, emphasizing its role as a DoS protection mechanism against stack overflow and excessive memory usage. This change improves the documentation and understanding of the schema validation process in the logging system. Signed-off-by: Dmitrii Safronov --- src/logging_objects_with_schema/schema_loader.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/logging_objects_with_schema/schema_loader.py b/src/logging_objects_with_schema/schema_loader.py index 14367c1..81a3497 100644 --- a/src/logging_objects_with_schema/schema_loader.py +++ b/src/logging_objects_with_schema/schema_loader.py @@ -445,7 +445,8 @@ def _compile_schema_tree( Yields: SchemaLeaf objects found in the tree. """ - # Check for excessive nesting depth + # Check for excessive nesting depth (DoS protection: prevent deeply nested + # schemas that could cause stack overflow or excessive memory usage) if len(path) > MAX_SCHEMA_DEPTH: problems.append( SchemaProblem( From 0a6cdd554b0a1b739b9a395d24dcc03ab2429432 Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Sat, 6 Dec 2025 17:07:57 +0400 Subject: [PATCH 21/27] refactor(logging): enhance caller information retrieval in SchemaLogger - Introduced a version check for Python 3.11+ to utilize the improved findCaller() method for obtaining caller information, enhancing performance and reliability. - Implemented a fallback to inspect.stack() for Python versions below 3.11, ensuring consistent behavior across different environments. - Updated comments to clarify the logic behind caller information retrieval and the fallback mechanism. These changes improve the efficiency and maintainability of the logging system by leveraging modern Python capabilities while ensuring compatibility with older versions. Signed-off-by: Dmitrii Safronov --- .../schema_logger.py | 61 ++++++++++++------- 1 file changed, 38 insertions(+), 23 deletions(-) diff --git a/src/logging_objects_with_schema/schema_logger.py b/src/logging_objects_with_schema/schema_logger.py index d47403d..caffcd1 100644 --- a/src/logging_objects_with_schema/schema_logger.py +++ b/src/logging_objects_with_schema/schema_logger.py @@ -20,6 +20,11 @@ from .schema_applier import _apply_schema_internal from .schema_loader import CompiledSchema, _compile_schema_internal +# Python 3.11+ has improved findCaller() implementation with proper stacklevel support. +# For Python < 3.11, we use inspect.stack() as a fallback due to known issues with +# findCaller() and stacklevel parameter. +_USE_FINDCALLER = sys.version_info >= (3, 11) + def _log_schema_problems_and_exit(problems: list[SchemaProblem]) -> None: """Log schema problems to stderr and terminate the application. @@ -146,30 +151,40 @@ def _log( if data_problems: # Log validation errors as ERROR messages - # Get caller information using inspect.stack() to ensure consistent behavior - # across all Python versions. Python 3.10 had issues with findCaller() and - # stacklevel parameter, so we use inspect.stack() as the primary method - # with findCaller() as a fallback for compatibility. The stack looks like: - # - Frame 0: this function (_log) - # - Frame 1: logger.info() wrapper - # - Frame 2: actual caller (test_function) - # We need to skip frame 0 (this function) and frame 1 (logger.info wrapper), - # so we use stacklevel + 1 to get to the actual caller. - stack = inspect.stack() - frame_idx = ( - stacklevel + 1 - ) # Skip this function (0) + logger.info wrapper (1) - if frame_idx < len(stack): - frame = stack[frame_idx] - fn = frame.filename - lno = frame.lineno - func = frame.function - sinfo = None - else: - # Fallback to findCaller if stack is shorter than expected + # Get caller information for the error log record. + # Python 3.11+ has improved findCaller() with proper stacklevel support, + # so we use it as the primary method for better performance. + # For Python < 3.11, we fall back to inspect.stack() due to known issues + # with findCaller() and stacklevel parameter. + if _USE_FINDCALLER: + # Use findCaller() for Python 3.11+ (more efficient) fn, lno, func, sinfo = self.findCaller( stack_info=False, stacklevel=stacklevel + 1 ) + else: + # Fallback to inspect.stack() for Python < 3.11 + # The stack looks like: + # - Frame 0: this function (_log) + # - Frame 1: logger.info() wrapper + # - Frame 2: actual caller (test_function) + # We need to skip frame 0 (this function) and frame 1 + # (logger.info wrapper), so we use stacklevel + 1 to get to + # the actual caller. + stack = inspect.stack() + frame_idx = ( + stacklevel + 1 + ) # Skip this function (0) + logger.info wrapper (1) + if frame_idx < len(stack): + frame = stack[frame_idx] + fn = frame.filename + lno = frame.lineno + func = frame.function + sinfo = None + else: + # Fallback to findCaller if stack is shorter than expected + fn, lno, func, sinfo = self.findCaller( + stack_info=False, stacklevel=stacklevel + 1 + ) # Format error message with details of all problems problem_messages = [problem.message for problem in data_problems] error_msg = f"Log data does not match schema: {'; '.join(problem_messages)}" @@ -181,9 +196,9 @@ def _log( error_msg, (), None, # exc_info - not needed - func, # func - function name from findCaller + func, # func - function name from caller None, # extra - not needed - sinfo, # sinfo - stack info from findCaller + sinfo, # sinfo - stack info from caller ) try: self.callHandlers(error_record) From c0d747c0b96df0b59bde1021651eb60569ee6620 Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Sat, 6 Dec 2025 17:10:45 +0400 Subject: [PATCH 22/27] docs(README): clarify definitions of inner and leaf nodes in schema - Enhanced the descriptions of inner and leaf nodes in the README.md to provide clearer definitions, specifying the requirements for each type of node. - Added details on the validation process for leaf nodes, emphasizing the necessity of both `type` and `source` fields for a valid leaf node. These updates improve the documentation and understanding of schema structure in the logging system. Signed-off-by: Dmitrii Safronov --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2c6daf5..d289e4b 100644 --- a/README.md +++ b/README.md @@ -201,8 +201,11 @@ An empty schema is valid and does not cause errors. When using an empty schema, no `extra` fields will be included in log records, and any attempt to log with `extra` fields will result in validation errors being logged as ERROR messages. -- An inner node is an object without `type` and `source`. -- A leaf node is an object with both `type` and `source`. +- An inner node is an object without `type` and `source` (neither field is present). +- A leaf node is an object that has at least one of `type` or `source` fields. + However, a valid leaf node must have both `type` and `source` fields. If a + leaf node is missing either field or has an empty value, it will be reported + as a schema problem during validation. - `type` is one of the allowed Python type names: `"str"`, `"int"`, `"float"`, `"bool"`, or `"list"`. - For `"list"` type, an additional `item_type` field is required to declare From cd39bd23e3161212dee06c68b61dcee70ba610a6 Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Sat, 6 Dec 2025 17:13:57 +0400 Subject: [PATCH 23/27] docs(README): update error message format for schema validation - Clarified the format of the ERROR message logged after emitting a log record when validation problems are detected. The new format is: `"Log data does not match schema: {problem1}; {problem2}; ..."`. - Enhanced documentation to ensure consistency in understanding how errors are reported during schema validation. These updates improve the clarity of the logging system's error handling documentation. Signed-off-by: Dmitrii Safronov --- README.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d289e4b..5e79a8d 100644 --- a/README.md +++ b/README.md @@ -267,7 +267,8 @@ message similar to: > Field 'tags' is a list but contains elements with types [...]; expected all elements to be of type str -and an ERROR message is logged **after** the log record has been emitted. +and an ERROR message is logged **after** the log record has been emitted with +the format: `"Log data does not match schema: {problem1}; {problem2}; ..."`. ### Multiple leaves with the same source @@ -386,7 +387,8 @@ terminated after logging the error to stderr. In all of these cases a `DataProblem` is recorded for each offending field, and if at least one problem is present, a single ERROR message is logged -**after** the log record has been emitted. +**after** the log record has been emitted. The error message format is: +`"Log data does not match schema: {problem1}; {problem2}; ..."`. - When a `source` is used in multiple leaves (see "Multiple leaves with the same source" above), the value is validated and written independently for each leaf @@ -402,8 +404,9 @@ High-level algorithm inside `SchemaLogger`: 2. A new structured payload is built from the schema and the given `extra`. 3. Only this structured payload is passed to the underlying stdlib logger. 4. After logging, if any validation problems were detected, a single - ERROR message is logged with the full `problems` list (no exception - is raised, ensuring 100% compatibility with standard logger behavior). + ERROR message is logged with the format: + `"Log data does not match schema: {problem1}; {problem2}; ..."` + (no exception is raised, ensuring 100% compatibility with standard logger behavior). ## Error handling From 7fc22dbb0f918c008eecf449584f2925e18d268f Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Sat, 6 Dec 2025 17:21:45 +0400 Subject: [PATCH 24/27] refactor(logging): replace Lock with RLock for path cache synchronization - Updated the path cache lock from threading.Lock to threading.RLock to allow recursive locking during schema path checks. - Enhanced thread safety in the _check_cached_found_file_path and _check_cached_missing_file_path functions by ensuring that access to shared variables is properly synchronized. These changes improve the reliability of the schema loading process in multi-threaded environments. Signed-off-by: Dmitrii Safronov --- .../schema_loader.py | 50 +++++++++++-------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/src/logging_objects_with_schema/schema_loader.py b/src/logging_objects_with_schema/schema_loader.py index 81a3497..c53ba66 100644 --- a/src/logging_objects_with_schema/schema_loader.py +++ b/src/logging_objects_with_schema/schema_loader.py @@ -95,7 +95,11 @@ def _create_empty_compiled_schema_with_problems( # Non-None means the cached path is an absolute path based on CWD # when file was not found _cached_cwd: Path | None = None -_path_cache_lock = threading.Lock() +# RLock for thread-safe access to path cache variables. +# RLock is used to allow recursive locking when helper functions +# (_check_cached_found_file_path, _check_cached_missing_file_path) are called +# from _get_schema_path() which already holds the lock. +_path_cache_lock = threading.RLock() def _find_schema_file() -> Path | None: @@ -145,17 +149,18 @@ def _check_cached_found_file_path() -> Path | None: """ global _resolved_schema_path - if _resolved_schema_path is None: - return None + with _path_cache_lock: + if _resolved_schema_path is None: + return None - # Schema file was found, absolute path doesn't depend on CWD - # Return it if it still exists - if _resolved_schema_path.exists(): - return _resolved_schema_path + # Schema file was found, absolute path doesn't depend on CWD + # Return it if it still exists + if _resolved_schema_path.exists(): + return _resolved_schema_path - # Cached file no longer exists; invalidate cache so caller will re-search - _resolved_schema_path = None - return None + # Cached file no longer exists; invalidate cache so caller will re-search + _resolved_schema_path = None + return None def _check_cached_missing_file_path() -> Path | None: @@ -169,20 +174,21 @@ def _check_cached_missing_file_path() -> Path | None: """ global _resolved_schema_path, _cached_cwd - if _resolved_schema_path is None or _cached_cwd is None: - return None + with _path_cache_lock: + if _resolved_schema_path is None or _cached_cwd is None: + return None - # Cached path is based on CWD when file was not found, - # check if CWD changed - current_cwd = _get_current_working_directory() - if current_cwd != _cached_cwd: - # CWD changed, invalidate cache and re-search from new CWD - _resolved_schema_path = None - _cached_cwd = None - return None + # Cached path is based on CWD when file was not found, + # check if CWD changed + current_cwd = _get_current_working_directory() + if current_cwd != _cached_cwd: + # CWD changed, invalidate cache and re-search from new CWD + _resolved_schema_path = None + _cached_cwd = None + return None - # CWD unchanged, return cached path - return _resolved_schema_path + # CWD unchanged, return cached path + return _resolved_schema_path def _cache_and_return_found_path(found_path: Path) -> Path: From 51fb405dc64a91b2f504ad4dc30dc575643da878 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sat, 6 Dec 2025 13:24:00 +0000 Subject: [PATCH 25/27] chore(release): 0.1.2-rc.3 ## [0.1.2-rc.3](https://github.com/disafronov/python-logging-objects-with-schema/compare/v0.1.2-rc.2...v0.1.2-rc.3) (2025-12-06) Signed-off-by: Release Bot --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d61bc60..a264b2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "uv_build" [project] name = "logging-objects-with-schema" -version = "0.1.2rc2" +version = "0.1.2rc3" description = "Proxy logging wrapper that validates extra fields against a JSON schema." readme = "README.md" requires-python = ">=3.10" diff --git a/uv.lock b/uv.lock index 81ffffd..7c231ec 100644 --- a/uv.lock +++ b/uv.lock @@ -1394,7 +1394,7 @@ version = "0.6.3" [[package]] name = "logging-objects-with-schema" -version = "0.1.2rc2" +version = "0.1.2rc3" [package.source] editable = "." From 01c734e6a9e6fcd8314ca35d702c79a03641a86f Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Sat, 6 Dec 2025 17:28:06 +0400 Subject: [PATCH 26/27] chore(workflow): add release branch to lint and test workflow - Updated the GitHub Actions workflow to include the 'release' branch in the pull request trigger configuration for linting and testing. This change ensures that code quality checks are performed on both the main and release branches. Signed-off-by: Dmitrii Safronov --- .github/workflows/lint_and_test.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/lint_and_test.yaml b/.github/workflows/lint_and_test.yaml index 08e0a6f..1d870d5 100644 --- a/.github/workflows/lint_and_test.yaml +++ b/.github/workflows/lint_and_test.yaml @@ -5,6 +5,7 @@ name: Lint and test pull_request: branches: - main + - release jobs: lint_and_test: From 5ee6d98e0d4fbb1311d396f1597050ff8fa2cc54 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sat, 6 Dec 2025 13:29:53 +0000 Subject: [PATCH 27/27] chore(release): 0.1.2 ## [0.1.2](https://github.com/disafronov/python-logging-objects-with-schema/compare/v0.1.1...v0.1.2) (2025-12-06) Signed-off-by: Release Bot --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a264b2c..0fa9734 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "uv_build" [project] name = "logging-objects-with-schema" -version = "0.1.2rc3" +version = "0.1.2" description = "Proxy logging wrapper that validates extra fields against a JSON schema." readme = "README.md" requires-python = ">=3.10" diff --git a/uv.lock b/uv.lock index 7c231ec..d3c00de 100644 --- a/uv.lock +++ b/uv.lock @@ -1394,7 +1394,7 @@ version = "0.6.3" [[package]] name = "logging-objects-with-schema" -version = "0.1.2rc3" +version = "0.1.2" [package.source] editable = "."