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: 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/.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 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", {}] ] diff --git a/README.md b/README.md index 5324733..5e79a8d 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. @@ -132,34 +132,19 @@ logger.info( ### Error handling example ```python -from logging_objects_with_schema import SchemaLogger, DataValidationError, 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 - -# 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 +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. +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 details about the problems. ``` ### API compatibility with ``logging.Logger`` @@ -181,12 +166,11 @@ except DataValidationError as e: - 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: @@ -207,8 +191,21 @@ An example schema: } ``` -- An inner node is an object without `type` and `source`. -- A leaf node is an object with both `type` and `source`. +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` (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 @@ -270,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 a `DataValidationError` is raised **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 @@ -284,7 +282,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: @@ -320,30 +318,23 @@ 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 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)`. - 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. + - 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. -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 @@ -395,8 +386,9 @@ 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 -**after** the log record has been emitted. +if at least one problem is present, a single ERROR message is logged +**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 @@ -412,18 +404,13 @@ 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. - -## Exceptions - -- **`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. -- **`DataValidationError`**: - - Any problem 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. - - Contains a `problems` list describing the offending fields. + 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 + +- 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/pyproject.toml b/pyproject.toml index d90e8f1..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.1" +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/src/logging_objects_with_schema/__init__.py b/src/logging_objects_with_schema/__init__.py index 2f5372d..d81e922 100644 --- a/src/logging_objects_with_schema/__init__.py +++ b/src/logging_objects_with_schema/__init__.py @@ -7,11 +7,8 @@ from __future__ import annotations -from .errors import DataValidationError, 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 515e095..2746985 100644 --- a/src/logging_objects_with_schema/errors.py +++ b/src/logging_objects_with_schema/errors.py @@ -16,35 +16,6 @@ class SchemaProblem: message: str -class SchemaValidationError(Exception): - """Raised when there are problems with the JSON schema definition. - - 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. - - 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. - - Example: - >>> try: - ... logger = SchemaLogger("my_logger") - ... except SchemaValidationError as e: - ... for problem in e.problems: - ... print(f"Schema error: {problem.message}") - """ - - 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. @@ -54,31 +25,3 @@ class DataProblem: """ message: str - - -class DataValidationError(Exception): - """Raised when log record data does not satisfy the configured schema. - - 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. - - 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. - - 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 - """ - - 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..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 @@ -34,8 +35,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: @@ -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 @@ -227,7 +227,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 +246,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/src/logging_objects_with_schema/schema_loader.py b/src/logging_objects_with_schema/schema_loader.py index 639de0c..c53ba66 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 @@ -94,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: @@ -144,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 - # If cached path doesn't exist, re-search (schema might have been moved) - _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: @@ -168,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: @@ -444,7 +451,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( @@ -559,10 +567,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 1c0b363..caffcd1 100644 --- a/src/logging_objects_with_schema/schema_logger.py +++ b/src/logging_objects_with_schema/schema_logger.py @@ -1,21 +1,45 @@ """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 inspect import logging +import os +import sys from collections.abc import Mapping from typing import Any -from .errors import DataValidationError, SchemaValidationError +from .errors import SchemaProblem 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. + + 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. @@ -26,7 +50,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 @@ -40,24 +65,27 @@ 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`). level: Logger level (same as :class:`logging.Logger`). """ - super().__init__(name, level) - + # 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): - # 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) @@ -65,29 +93,18 @@ 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. - self._cleanup_failed_logger() - raise SchemaValidationError("Schema has problems", problems=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( self, level: int, @@ -101,7 +118,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 +131,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 +150,58 @@ def _log( ) if data_problems: - raise DataValidationError( - "Log data does not match schema", - problems=data_problems, + # Log validation errors as ERROR messages + # 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)}" + error_record = self.makeRecord( + self.name, + logging.ERROR, + fn, + lno, + error_msg, + (), + None, # exc_info - not needed + func, # func - function name from caller + None, # extra - not needed + sinfo, # sinfo - stack info from caller ) + 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") 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: diff --git a/tests/test_formatter_basic.py b/tests/test_formatter_basic.py index 41f329e..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 @@ -25,11 +24,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 +45,25 @@ 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 + # 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( @@ -94,3 +104,63 @@ 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( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Validation error log records should have function name.""" + + 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 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 b39700c..50a54c4 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -11,14 +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.errors import ( - DataValidationError, - SchemaValidationError, -) from logging_objects_with_schema.schema_loader import SCHEMA_FILE_NAME -from tests.conftest import _write_schema def test_multiple_logger_instances_share_schema( @@ -135,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( @@ -167,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( @@ -245,7 +278,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 +292,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,35 +320,41 @@ 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 + # 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 - # 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 + # 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( +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 @@ -340,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_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 9c54e82..df7b65d 100644 --- a/tests/test_schema_logger_mimic.py +++ b/tests/test_schema_logger_mimic.py @@ -7,10 +7,9 @@ from pathlib import Path import pytest +from conftest import _write_schema from logging_objects_with_schema import SchemaLogger -from logging_objects_with_schema.errors import SchemaValidationError -from tests.conftest import _write_schema def test_schema_logger_is_logging_logger_instance( @@ -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,50 @@ 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_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, @@ -162,9 +206,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 +235,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 +250,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 +276,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 +298,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 +315,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 +346,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 +368,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 +385,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 +412,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 +434,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..e38a085 100644 --- a/tests/test_thread_safety.py +++ b/tests/test_thread_safety.py @@ -7,10 +7,9 @@ from pathlib import Path import pytest +from conftest import _write_schema from logging_objects_with_schema import SchemaLogger -from logging_objects_with_schema.errors import SchemaValidationError -from tests.conftest import _write_schema def test_concurrent_logger_creation( @@ -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: diff --git a/uv.lock b/uv.lock index a08afba..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.1" +version = "0.1.2rc3" [package.source] editable = "."