logging-objects-with-schema 0.1.1__tar.gz → 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: logging-objects-with-schema
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Proxy logging wrapper that validates extra fields against a JSON schema.
5
5
  Keywords: logging,schema,validation,structured-logging,structured-logs
6
6
  Author: Dmitrii Safronov
@@ -57,7 +57,7 @@ to appear in logs and which types they must have.
57
57
 
58
58
  - Application code must only send `extra` fields that are described in the schema
59
59
  and match the declared Python types. Any deviation (unknown fields, wrong types,
60
- `None` values, disallowed list elements) is reported via `DataValidationError`
60
+ `None` values, disallowed list elements) is logged as an ERROR message
61
61
  *after* the log record has been emitted.
62
62
  - The schema file (`logging_objects_with_schema.json`) is a shared, versioned
63
63
  artifact that defines the shape of structured log payloads for all downstream
@@ -72,9 +72,9 @@ to appear in logs and which types they must have.
72
72
  - Any mismatch between runtime values and the declared types is also treated as
73
73
  a data error.
74
74
  - All validation problems (unknown fields, wrong types, disallowed list
75
- elements, `None` values, etc.) are aggregated into a single
76
- `DataValidationError` that is raised **after** the log record has been
77
- emitted.
75
+ elements, `None` values, etc.) are aggregated and logged as a single
76
+ ERROR message **after** the log record has been emitted, ensuring 100%
77
+ compatibility with standard logger behavior (no exceptions are raised).
78
78
  - The schema is treated as the only source of truth for which `extra` fields
79
79
  are allowed to appear in logs. Any deviation from the schema is considered a
80
80
  contract violation between the producer of `extra` and the schema author.
@@ -165,34 +165,19 @@ logger.info(
165
165
  ### Error handling example
166
166
 
167
167
  ```python
168
- from logging_objects_with_schema import SchemaLogger, DataValidationError, SchemaValidationError
169
-
170
- try:
171
- # This will raise SchemaValidationError if schema file is missing or invalid
172
- logging.setLoggerClass(SchemaLogger)
173
- logger = logging.getLogger("service")
174
- except SchemaValidationError as e:
175
- print(f"Schema validation failed: {e}")
176
- for problem in e.problems:
177
- print(f" - {problem.message}")
178
- # Decide whether to abort or continue with default logger
179
- raise
180
- except (OSError, ValueError, RuntimeError) as e:
181
- # System-level errors (e.g., inaccessible working directory when os.getcwd()
182
- # fails, lock failures, path resolution issues) are raised directly, not
183
- # wrapped in SchemaValidationError. Note: OSError when reading the schema
184
- # file is converted to SchemaValidationError and handled above.
185
- print(f"System error during logger initialization: {e}")
186
- raise
187
-
188
- # When logging, handle DataValidationError
189
- try:
190
- logger.info("processing", extra={"user_id": "not-an-int"}) # Wrong type
191
- except DataValidationError as e:
192
- print(f"Data validation failed: {e}")
193
- for problem in e.problems:
194
- print(f" - {problem.message}")
195
- # Note: the valid part of the log was already emitted before the exception
168
+ from logging_objects_with_schema import SchemaLogger
169
+
170
+ # SchemaLogger is a drop-in replacement - no exception handling needed.
171
+ # If the schema has problems, the application will be terminated after
172
+ # logging schema problems to stderr.
173
+ logging.setLoggerClass(SchemaLogger)
174
+ logger = logging.getLogger("service")
175
+
176
+ # When logging with invalid data, validation errors are automatically
177
+ # logged as ERROR messages. No exception handling is needed.
178
+ logger.info("processing", extra={"user_id": "not-an-int"}) # Wrong type
179
+ # The valid part of the log is emitted, and validation errors are logged
180
+ # as ERROR messages with details about the problems.
196
181
  ```
197
182
 
198
183
  ### API compatibility with ``logging.Logger``
@@ -214,12 +199,11 @@ except DataValidationError as e:
214
199
  - Schema tree depth is limited to a maximum nesting level (currently 100). Any
215
200
  branch that exceeds this depth is ignored and reported as a schema problem.
216
201
 
217
- If the schema file is not found, or cannot be read/parsed/validated, a
218
- `SchemaValidationError("Schema has problems", problems=[...])` is raised when
219
- a `SchemaLogger` instance is created. In this case the logger instance is not
220
- created at all. The `problems` list contains a detailed description of the
221
- issue, including the path where the file was expected (based on the current
222
- working directory at schema discovery time).
202
+ If the schema file is not found, or cannot be read/parsed/validated, the
203
+ logger instance is not created, schema problems are logged to stderr, and
204
+ the application is terminated via `os._exit(1)`. The error message contains
205
+ a detailed description of all issues, including the path where the file was
206
+ expected (based on the current working directory at schema discovery time).
223
207
 
224
208
  An example schema:
225
209
 
@@ -240,8 +224,21 @@ An example schema:
240
224
  }
241
225
  ```
242
226
 
243
- - An inner node is an object without `type` and `source`.
244
- - A leaf node is an object with both `type` and `source`.
227
+ An example of a valid empty schema (no leaves, no problems):
228
+
229
+ ```json
230
+ {}
231
+ ```
232
+
233
+ An empty schema is valid and does not cause errors. When using an empty schema,
234
+ no `extra` fields will be included in log records, and any attempt to log with
235
+ `extra` fields will result in validation errors being logged as ERROR messages.
236
+
237
+ - An inner node is an object without `type` and `source` (neither field is present).
238
+ - A leaf node is an object that has at least one of `type` or `source` fields.
239
+ However, a valid leaf node must have both `type` and `source` fields. If a
240
+ leaf node is missing either field or has an empty value, it will be reported
241
+ as a schema problem during validation.
245
242
  - `type` is one of the allowed Python type names: `"str"`, `"int"`, `"float"`,
246
243
  `"bool"`, or `"list"`.
247
244
  - For `"list"` type, an additional `item_type` field is required to declare
@@ -303,7 +300,8 @@ message similar to:
303
300
 
304
301
  > Field 'tags' is a list but contains elements with types [...]; expected all elements to be of type str
305
302
 
306
- and a `DataValidationError` is raised **after** the log record has been emitted.
303
+ and an ERROR message is logged **after** the log record has been emitted with
304
+ the format: `"Log data does not match schema: {problem1}; {problem2}; ..."`.
307
305
 
308
306
  ### Multiple leaves with the same source
309
307
 
@@ -317,7 +315,7 @@ When a `source` is referenced by multiple leaves:
317
315
  - The value is written only to those leaf locations where the runtime type
318
316
  matches the expected type.
319
317
  - For leaf locations where the type does not match, a `DataProblem` is added
320
- to the `DataValidationError` that is raised after logging.
318
+ to the ERROR message that is logged after logging.
321
319
 
322
320
  Example schema with duplicate source usage:
323
321
 
@@ -353,30 +351,23 @@ consistent type expectations when reusing a `source` field.
353
351
  - If there are **any** problems with the schema (missing file, broken JSON,
354
352
  invalid `type` values, conflicting root fields that match system logging
355
353
  fields, malformed structure, etc.):
356
- - a `SchemaValidationError("Schema has problems", problems=[...])` is raised;
357
- - the logger instance is not created.
354
+ - the logger instance is not created (schema validation happens before
355
+ the logger is initialized);
356
+ - schema problems are logged to stderr in the format:
357
+ `"Schema has problems: {problem1}; {problem2}; ..."`;
358
+ - the application is terminated via `os._exit(1)`.
358
359
  - If there are no problems:
359
360
  - the schema is compiled into a `CompiledSchema`;
360
361
  - the logger is created and starts using this schema to validate `extra`
361
362
  fields.
363
+ - A valid empty schema (e.g., `{}` or a schema with only inner nodes and no
364
+ leaves) is treated as valid and does not cause errors. The logger is created
365
+ successfully, but no `extra` fields will be included in log records.
362
366
 
363
- The application decides whether `SchemaValidationError` is a fatal error that
364
- should abort startup, or whether it should be logged and ignored.
365
-
366
- **Note**: In rare cases, system-level errors may be raised directly instead of
367
- being wrapped in `SchemaValidationError`. Specifically:
368
-
369
- - `OSError` when the current working directory is inaccessible or deleted (e.g.,
370
- when `os.getcwd()` fails) is propagated as-is to indicate environmental issues.
371
- However, `OSError` that occurs when reading the schema file (e.g., permission
372
- denied, I/O errors) is converted to `SchemaValidationError` and reported as a
373
- schema problem.
374
- - `ValueError` when path resolution fails due to invalid characters or malformed
375
- paths during schema file discovery is propagated as-is.
376
- - `RuntimeError` when thread lock acquisition fails is propagated as-is.
377
-
378
- Applications should handle these exceptions separately if they need to
379
- distinguish between schema validation failures and system-level errors.
367
+ **Note**: System-level errors (OSError, ValueError, RuntimeError) that occur
368
+ during schema compilation are converted to `SchemaProblem` instances and
369
+ handled the same way as schema validation problems - the application is
370
+ terminated after logging the error to stderr.
380
371
 
381
372
  ## Schema caching and thread safety
382
373
 
@@ -428,8 +419,9 @@ distinguish between schema validation failures and system-level errors.
428
419
  **it is simply not included in the final log record**.
429
420
 
430
421
  In all of these cases a `DataProblem` is recorded for each offending field, and
431
- if at least one problem is present a single `DataValidationError` is raised
432
- **after** the log record has been emitted.
422
+ if at least one problem is present, a single ERROR message is logged
423
+ **after** the log record has been emitted. The error message format is:
424
+ `"Log data does not match schema: {problem1}; {problem2}; ..."`.
433
425
 
434
426
  - When a `source` is used in multiple leaves (see "Multiple leaves with the same
435
427
  source" above), the value is validated and written independently for each leaf
@@ -445,18 +437,13 @@ High-level algorithm inside `SchemaLogger`:
445
437
  2. A new structured payload is built from the schema and the given `extra`.
446
438
  3. Only this structured payload is passed to the underlying stdlib logger.
447
439
  4. After logging, if any validation problems were detected, a single
448
- `DataValidationError` is raised with the full `problems` list.
449
-
450
- ## Exceptions
451
-
452
- - **`SchemaValidationError`**:
453
- - Any problem with the schema (missing file, broken JSON, invalid types,
454
- conflicting root fields, malformed structure, etc.).
455
- - Exposes a `problems` list describing each violation.
456
- - **`DataValidationError`**:
457
- - Any problem with a specific `extra` payload during logging:
458
- - the runtime type does not match the expected type;
459
- - a list contains non-primitive elements (only str, int, float, bool allowed);
460
- - the field is redundant and not described in the schema.
461
- - Raised **after** the valid part of the payload has been logged.
462
- - Contains a `problems` list describing the offending fields.
440
+ ERROR message is logged with the format:
441
+ `"Log data does not match schema: {problem1}; {problem2}; ..."`
442
+ (no exception is raised, ensuring 100% compatibility with standard logger behavior).
443
+
444
+ ## Error handling
445
+
446
+ - Schema problems are handled internally: errors are logged to stderr and
447
+ the application is terminated via `os._exit(1)`.
448
+ - No exceptions are raised by `SchemaLogger` during initialization, making
449
+ it a true drop-in replacement for `logging.Logger`.
@@ -24,7 +24,7 @@ to appear in logs and which types they must have.
24
24
 
25
25
  - Application code must only send `extra` fields that are described in the schema
26
26
  and match the declared Python types. Any deviation (unknown fields, wrong types,
27
- `None` values, disallowed list elements) is reported via `DataValidationError`
27
+ `None` values, disallowed list elements) is logged as an ERROR message
28
28
  *after* the log record has been emitted.
29
29
  - The schema file (`logging_objects_with_schema.json`) is a shared, versioned
30
30
  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.
39
39
  - Any mismatch between runtime values and the declared types is also treated as
40
40
  a data error.
41
41
  - All validation problems (unknown fields, wrong types, disallowed list
42
- elements, `None` values, etc.) are aggregated into a single
43
- `DataValidationError` that is raised **after** the log record has been
44
- emitted.
42
+ elements, `None` values, etc.) are aggregated and logged as a single
43
+ ERROR message **after** the log record has been emitted, ensuring 100%
44
+ compatibility with standard logger behavior (no exceptions are raised).
45
45
  - The schema is treated as the only source of truth for which `extra` fields
46
46
  are allowed to appear in logs. Any deviation from the schema is considered a
47
47
  contract violation between the producer of `extra` and the schema author.
@@ -132,34 +132,19 @@ logger.info(
132
132
  ### Error handling example
133
133
 
134
134
  ```python
135
- from logging_objects_with_schema import SchemaLogger, DataValidationError, SchemaValidationError
136
-
137
- try:
138
- # This will raise SchemaValidationError if schema file is missing or invalid
139
- logging.setLoggerClass(SchemaLogger)
140
- logger = logging.getLogger("service")
141
- except SchemaValidationError as e:
142
- print(f"Schema validation failed: {e}")
143
- for problem in e.problems:
144
- print(f" - {problem.message}")
145
- # Decide whether to abort or continue with default logger
146
- raise
147
- except (OSError, ValueError, RuntimeError) as e:
148
- # System-level errors (e.g., inaccessible working directory when os.getcwd()
149
- # fails, lock failures, path resolution issues) are raised directly, not
150
- # wrapped in SchemaValidationError. Note: OSError when reading the schema
151
- # file is converted to SchemaValidationError and handled above.
152
- print(f"System error during logger initialization: {e}")
153
- raise
154
-
155
- # When logging, handle DataValidationError
156
- try:
157
- logger.info("processing", extra={"user_id": "not-an-int"}) # Wrong type
158
- except DataValidationError as e:
159
- print(f"Data validation failed: {e}")
160
- for problem in e.problems:
161
- print(f" - {problem.message}")
162
- # Note: the valid part of the log was already emitted before the exception
135
+ from logging_objects_with_schema import SchemaLogger
136
+
137
+ # SchemaLogger is a drop-in replacement - no exception handling needed.
138
+ # If the schema has problems, the application will be terminated after
139
+ # logging schema problems to stderr.
140
+ logging.setLoggerClass(SchemaLogger)
141
+ logger = logging.getLogger("service")
142
+
143
+ # When logging with invalid data, validation errors are automatically
144
+ # logged as ERROR messages. No exception handling is needed.
145
+ logger.info("processing", extra={"user_id": "not-an-int"}) # Wrong type
146
+ # The valid part of the log is emitted, and validation errors are logged
147
+ # as ERROR messages with details about the problems.
163
148
  ```
164
149
 
165
150
  ### API compatibility with ``logging.Logger``
@@ -181,12 +166,11 @@ except DataValidationError as e:
181
166
  - Schema tree depth is limited to a maximum nesting level (currently 100). Any
182
167
  branch that exceeds this depth is ignored and reported as a schema problem.
183
168
 
184
- If the schema file is not found, or cannot be read/parsed/validated, a
185
- `SchemaValidationError("Schema has problems", problems=[...])` is raised when
186
- a `SchemaLogger` instance is created. In this case the logger instance is not
187
- created at all. The `problems` list contains a detailed description of the
188
- issue, including the path where the file was expected (based on the current
189
- working directory at schema discovery time).
169
+ If the schema file is not found, or cannot be read/parsed/validated, the
170
+ logger instance is not created, schema problems are logged to stderr, and
171
+ the application is terminated via `os._exit(1)`. The error message contains
172
+ a detailed description of all issues, including the path where the file was
173
+ expected (based on the current working directory at schema discovery time).
190
174
 
191
175
  An example schema:
192
176
 
@@ -207,8 +191,21 @@ An example schema:
207
191
  }
208
192
  ```
209
193
 
210
- - An inner node is an object without `type` and `source`.
211
- - A leaf node is an object with both `type` and `source`.
194
+ An example of a valid empty schema (no leaves, no problems):
195
+
196
+ ```json
197
+ {}
198
+ ```
199
+
200
+ An empty schema is valid and does not cause errors. When using an empty schema,
201
+ no `extra` fields will be included in log records, and any attempt to log with
202
+ `extra` fields will result in validation errors being logged as ERROR messages.
203
+
204
+ - An inner node is an object without `type` and `source` (neither field is present).
205
+ - A leaf node is an object that has at least one of `type` or `source` fields.
206
+ However, a valid leaf node must have both `type` and `source` fields. If a
207
+ leaf node is missing either field or has an empty value, it will be reported
208
+ as a schema problem during validation.
212
209
  - `type` is one of the allowed Python type names: `"str"`, `"int"`, `"float"`,
213
210
  `"bool"`, or `"list"`.
214
211
  - For `"list"` type, an additional `item_type` field is required to declare
@@ -270,7 +267,8 @@ message similar to:
270
267
 
271
268
  > Field 'tags' is a list but contains elements with types [...]; expected all elements to be of type str
272
269
 
273
- and a `DataValidationError` is raised **after** the log record has been emitted.
270
+ and an ERROR message is logged **after** the log record has been emitted with
271
+ the format: `"Log data does not match schema: {problem1}; {problem2}; ..."`.
274
272
 
275
273
  ### Multiple leaves with the same source
276
274
 
@@ -284,7 +282,7 @@ When a `source` is referenced by multiple leaves:
284
282
  - The value is written only to those leaf locations where the runtime type
285
283
  matches the expected type.
286
284
  - For leaf locations where the type does not match, a `DataProblem` is added
287
- to the `DataValidationError` that is raised after logging.
285
+ to the ERROR message that is logged after logging.
288
286
 
289
287
  Example schema with duplicate source usage:
290
288
 
@@ -320,30 +318,23 @@ consistent type expectations when reusing a `source` field.
320
318
  - If there are **any** problems with the schema (missing file, broken JSON,
321
319
  invalid `type` values, conflicting root fields that match system logging
322
320
  fields, malformed structure, etc.):
323
- - a `SchemaValidationError("Schema has problems", problems=[...])` is raised;
324
- - the logger instance is not created.
321
+ - the logger instance is not created (schema validation happens before
322
+ the logger is initialized);
323
+ - schema problems are logged to stderr in the format:
324
+ `"Schema has problems: {problem1}; {problem2}; ..."`;
325
+ - the application is terminated via `os._exit(1)`.
325
326
  - If there are no problems:
326
327
  - the schema is compiled into a `CompiledSchema`;
327
328
  - the logger is created and starts using this schema to validate `extra`
328
329
  fields.
330
+ - A valid empty schema (e.g., `{}` or a schema with only inner nodes and no
331
+ leaves) is treated as valid and does not cause errors. The logger is created
332
+ successfully, but no `extra` fields will be included in log records.
329
333
 
330
- The application decides whether `SchemaValidationError` is a fatal error that
331
- should abort startup, or whether it should be logged and ignored.
332
-
333
- **Note**: In rare cases, system-level errors may be raised directly instead of
334
- being wrapped in `SchemaValidationError`. Specifically:
335
-
336
- - `OSError` when the current working directory is inaccessible or deleted (e.g.,
337
- when `os.getcwd()` fails) is propagated as-is to indicate environmental issues.
338
- However, `OSError` that occurs when reading the schema file (e.g., permission
339
- denied, I/O errors) is converted to `SchemaValidationError` and reported as a
340
- schema problem.
341
- - `ValueError` when path resolution fails due to invalid characters or malformed
342
- paths during schema file discovery is propagated as-is.
343
- - `RuntimeError` when thread lock acquisition fails is propagated as-is.
344
-
345
- Applications should handle these exceptions separately if they need to
346
- distinguish between schema validation failures and system-level errors.
334
+ **Note**: System-level errors (OSError, ValueError, RuntimeError) that occur
335
+ during schema compilation are converted to `SchemaProblem` instances and
336
+ handled the same way as schema validation problems - the application is
337
+ terminated after logging the error to stderr.
347
338
 
348
339
  ## Schema caching and thread safety
349
340
 
@@ -395,8 +386,9 @@ distinguish between schema validation failures and system-level errors.
395
386
  **it is simply not included in the final log record**.
396
387
 
397
388
  In all of these cases a `DataProblem` is recorded for each offending field, and
398
- if at least one problem is present a single `DataValidationError` is raised
399
- **after** the log record has been emitted.
389
+ if at least one problem is present, a single ERROR message is logged
390
+ **after** the log record has been emitted. The error message format is:
391
+ `"Log data does not match schema: {problem1}; {problem2}; ..."`.
400
392
 
401
393
  - When a `source` is used in multiple leaves (see "Multiple leaves with the same
402
394
  source" above), the value is validated and written independently for each leaf
@@ -412,18 +404,13 @@ High-level algorithm inside `SchemaLogger`:
412
404
  2. A new structured payload is built from the schema and the given `extra`.
413
405
  3. Only this structured payload is passed to the underlying stdlib logger.
414
406
  4. After logging, if any validation problems were detected, a single
415
- `DataValidationError` is raised with the full `problems` list.
416
-
417
- ## Exceptions
418
-
419
- - **`SchemaValidationError`**:
420
- - Any problem with the schema (missing file, broken JSON, invalid types,
421
- conflicting root fields, malformed structure, etc.).
422
- - Exposes a `problems` list describing each violation.
423
- - **`DataValidationError`**:
424
- - Any problem with a specific `extra` payload during logging:
425
- - the runtime type does not match the expected type;
426
- - a list contains non-primitive elements (only str, int, float, bool allowed);
427
- - the field is redundant and not described in the schema.
428
- - Raised **after** the valid part of the payload has been logged.
429
- - Contains a `problems` list describing the offending fields.
407
+ ERROR message is logged with the format:
408
+ `"Log data does not match schema: {problem1}; {problem2}; ..."`
409
+ (no exception is raised, ensuring 100% compatibility with standard logger behavior).
410
+
411
+ ## Error handling
412
+
413
+ - Schema problems are handled internally: errors are logged to stderr and
414
+ the application is terminated via `os._exit(1)`.
415
+ - No exceptions are raised by `SchemaLogger` during initialization, making
416
+ it a true drop-in replacement for `logging.Logger`.
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "logging-objects-with-schema"
7
- version = "0.1.1"
7
+ version = "0.1.2"
8
8
  description = "Proxy logging wrapper that validates extra fields against a JSON schema."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -7,11 +7,8 @@ according to an application-defined JSON schema.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
- from .errors import DataValidationError, SchemaValidationError
11
10
  from .schema_logger import SchemaLogger
12
11
 
13
12
  __all__ = [
14
13
  "SchemaLogger",
15
- "SchemaValidationError",
16
- "DataValidationError",
17
14
  ]
@@ -0,0 +1,27 @@
1
+ """Custom exception types used by logging_objects_with_schema."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass
9
+ class SchemaProblem:
10
+ """Describes a single problem encountered while loading the schema.
11
+
12
+ Attributes:
13
+ message: Human-readable description of the problem.
14
+ """
15
+
16
+ message: str
17
+
18
+
19
+ @dataclass
20
+ class DataProblem:
21
+ """Describes a single problem encountered while validating log data.
22
+
23
+ Attributes:
24
+ message: Human-readable description of the validation problem.
25
+ """
26
+
27
+ message: str
@@ -7,7 +7,8 @@ to user-provided extra fields, used by SchemaLogger.
7
7
  from __future__ import annotations
8
8
 
9
9
  from collections import defaultdict
10
- from typing import Any, Mapping, MutableMapping
10
+ from collections.abc import Mapping, MutableMapping
11
+ from typing import Any
11
12
 
12
13
  from .errors import DataProblem
13
14
  from .schema_loader import CompiledSchema, SchemaLeaf
@@ -34,8 +35,7 @@ def _validate_list_value(
34
35
  """
35
36
  if item_expected_type is None:
36
37
  return DataProblem(
37
- f"Field '{source}' is declared as list in schema but "
38
- f"has no item type configured",
38
+ f"Field '{source}' is list but has no item type configured",
39
39
  )
40
40
 
41
41
  if len(value) == 0:
@@ -195,8 +195,8 @@ def _apply_schema_internal(
195
195
  where the type matches; mismatched locations produce ``DataProblem``
196
196
  entries, but do not affect successful locations.
197
197
 
198
- The function itself does not raise ``DataValidationError``; it only
199
- accumulates :class:`DataProblem` instances for the caller to handle.
198
+ The function itself does not raise exceptions; it only accumulates
199
+ :class:`DataProblem` instances for the caller to handle.
200
200
 
201
201
  Note:
202
202
  This function is used internally by :class:`SchemaLogger` and is not
@@ -227,7 +227,7 @@ def _apply_schema_internal(
227
227
  if value is None:
228
228
  problems.append(
229
229
  DataProblem(
230
- f"Field '{source}' is None, but None values are not allowed",
230
+ f"Field '{source}' is None",
231
231
  ),
232
232
  )
233
233
  continue
@@ -246,7 +246,7 @@ def _apply_schema_internal(
246
246
  for key in redundant_keys:
247
247
  problems.append(
248
248
  DataProblem(
249
- f"Field '{key}' is not defined in schema and will be ignored",
249
+ f"Field '{key}' is not defined in schema",
250
250
  ),
251
251
  )
252
252
 
@@ -13,9 +13,10 @@ import json
13
13
  import logging
14
14
  import os
15
15
  import threading
16
+ from collections.abc import Iterable, Mapping, MutableMapping
16
17
  from dataclasses import dataclass
17
18
  from pathlib import Path
18
- from typing import Any, Iterable, Mapping, MutableMapping
19
+ from typing import Any
19
20
 
20
21
  from .errors import SchemaProblem
21
22
 
@@ -94,7 +95,11 @@ _resolved_schema_path: Path | None = None
94
95
  # Non-None means the cached path is an absolute path based on CWD
95
96
  # when file was not found
96
97
  _cached_cwd: Path | None = None
97
- _path_cache_lock = threading.Lock()
98
+ # RLock for thread-safe access to path cache variables.
99
+ # RLock is used to allow recursive locking when helper functions
100
+ # (_check_cached_found_file_path, _check_cached_missing_file_path) are called
101
+ # from _get_schema_path() which already holds the lock.
102
+ _path_cache_lock = threading.RLock()
98
103
 
99
104
 
100
105
  def _find_schema_file() -> Path | None:
@@ -144,17 +149,18 @@ def _check_cached_found_file_path() -> Path | None:
144
149
  """
145
150
  global _resolved_schema_path
146
151
 
147
- if _resolved_schema_path is None:
148
- return None
152
+ with _path_cache_lock:
153
+ if _resolved_schema_path is None:
154
+ return None
149
155
 
150
- # Schema file was found, absolute path doesn't depend on CWD
151
- # Return it if it still exists
152
- if _resolved_schema_path.exists():
153
- return _resolved_schema_path
156
+ # Schema file was found, absolute path doesn't depend on CWD
157
+ # Return it if it still exists
158
+ if _resolved_schema_path.exists():
159
+ return _resolved_schema_path
154
160
 
155
- # If cached path doesn't exist, re-search (schema might have been moved)
156
- _resolved_schema_path = None
157
- return None
161
+ # Cached file no longer exists; invalidate cache so caller will re-search
162
+ _resolved_schema_path = None
163
+ return None
158
164
 
159
165
 
160
166
  def _check_cached_missing_file_path() -> Path | None:
@@ -168,20 +174,21 @@ def _check_cached_missing_file_path() -> Path | None:
168
174
  """
169
175
  global _resolved_schema_path, _cached_cwd
170
176
 
171
- if _resolved_schema_path is None or _cached_cwd is None:
172
- return None
177
+ with _path_cache_lock:
178
+ if _resolved_schema_path is None or _cached_cwd is None:
179
+ return None
173
180
 
174
- # Cached path is based on CWD when file was not found,
175
- # check if CWD changed
176
- current_cwd = _get_current_working_directory()
177
- if current_cwd != _cached_cwd:
178
- # CWD changed, invalidate cache and re-search from new CWD
179
- _resolved_schema_path = None
180
- _cached_cwd = None
181
- return None
181
+ # Cached path is based on CWD when file was not found,
182
+ # check if CWD changed
183
+ current_cwd = _get_current_working_directory()
184
+ if current_cwd != _cached_cwd:
185
+ # CWD changed, invalidate cache and re-search from new CWD
186
+ _resolved_schema_path = None
187
+ _cached_cwd = None
188
+ return None
182
189
 
183
- # CWD unchanged, return cached path
184
- return _resolved_schema_path
190
+ # CWD unchanged, return cached path
191
+ return _resolved_schema_path
185
192
 
186
193
 
187
194
  def _cache_and_return_found_path(found_path: Path) -> Path:
@@ -444,7 +451,8 @@ def _compile_schema_tree(
444
451
  Yields:
445
452
  SchemaLeaf objects found in the tree.
446
453
  """
447
- # Check for excessive nesting depth
454
+ # Check for excessive nesting depth (DoS protection: prevent deeply nested
455
+ # schemas that could cause stack overflow or excessive memory usage)
448
456
  if len(path) > MAX_SCHEMA_DEPTH:
449
457
  problems.append(
450
458
  SchemaProblem(
@@ -559,10 +567,9 @@ def _compile_schema_internal() -> tuple[CompiledSchema, list[SchemaProblem]]:
559
567
  to the schema, the application must restart the process. See the README
560
568
  section \"Schema caching and thread safety\" for more details.
561
569
 
562
- This function never raises ``SchemaValidationError``. It always returns
563
- the best-effort compiled schema together with a list of problems detected
564
- during processing (an empty ``CompiledSchema`` when the schema is missing
565
- or invalid).
570
+ This function never raises exceptions. It always returns the best-effort
571
+ compiled schema together with a list of problems detected during processing
572
+ (an empty ``CompiledSchema`` when the schema is missing or invalid).
566
573
 
567
574
  Note:
568
575
  This function is used internally by :class:`SchemaLogger` and by the
@@ -0,0 +1,207 @@
1
+ """Logger subclass that applies a JSON schema to extra fields.
2
+
3
+ This class extends the standard ``logging.Logger`` to validate and filter
4
+ user-provided ``extra`` fields according to a compiled JSON schema. When
5
+ validation problems are detected, they are logged as ERROR messages *after*
6
+ the log record has been emitted, ensuring 100% compatibility with standard
7
+ logger behavior.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import inspect
13
+ import logging
14
+ import os
15
+ import sys
16
+ from collections.abc import Mapping
17
+ from typing import Any
18
+
19
+ from .errors import SchemaProblem
20
+ from .schema_applier import _apply_schema_internal
21
+ from .schema_loader import CompiledSchema, _compile_schema_internal
22
+
23
+ # Python 3.11+ has improved findCaller() implementation with proper stacklevel support.
24
+ # For Python < 3.11, we use inspect.stack() as a fallback due to known issues with
25
+ # findCaller() and stacklevel parameter.
26
+ _USE_FINDCALLER = sys.version_info >= (3, 11)
27
+
28
+
29
+ def _log_schema_problems_and_exit(problems: list[SchemaProblem]) -> None:
30
+ """Log schema problems to stderr and terminate the application.
31
+
32
+ Args:
33
+ problems: List of schema problems to log.
34
+ """
35
+ # Format error message with details of all problems
36
+ # (same format as data problems)
37
+ problem_messages = [problem.message for problem in problems]
38
+ error_msg = f"Schema has problems: {'; '.join(problem_messages)}\n"
39
+ sys.stderr.write(error_msg)
40
+ sys.stderr.flush()
41
+ os._exit(1)
42
+
43
+
44
+ class SchemaLogger(logging.Logger):
45
+ """Logger subclass that enforces schema on extra fields.
46
+
47
+ This class extends :class:`logging.Logger` to add validation for
48
+ ``extra`` fields according to a compiled JSON schema. It is designed
49
+ to be used as a drop-in replacement via :func:`logging.setLoggerClass`.
50
+
51
+ The schema is loaded from ``logging_objects_with_schema.json`` in the
52
+ application root directory during initialization. If the schema cannot
53
+ be loaded or validated, the logger instance is not created, schema
54
+ problems are logged to stderr, and the application is terminated.
55
+
56
+ Example:
57
+ >>> import logging
58
+ >>> from logging_objects_with_schema import SchemaLogger
59
+ >>> logging.setLoggerClass(SchemaLogger)
60
+ >>> logger = logging.getLogger("my_service")
61
+ >>> logger.info("request processed", extra={"request_id": "abc-123"})
62
+ """
63
+
64
+ def __init__(self, name: str, level: int = logging.NOTSET) -> None:
65
+ """Initialise the schema-aware logger.
66
+
67
+ The schema is compiled once during construction. If any
68
+ problems are detected in the schema, the logger instance is not
69
+ created, schema problems are logged to stderr, and the application
70
+ is terminated.
71
+
72
+ Args:
73
+ name: Logger name (same as :class:`logging.Logger`).
74
+ level: Logger level (same as :class:`logging.Logger`).
75
+ """
76
+ # Validate schema before creating the logger instance to avoid
77
+ # registering a broken logger in the logging manager cache.
78
+ # Schema is compiled and cached first, then problems are checked.
79
+ try:
80
+ compiled, problems = _compile_schema_internal()
81
+ except (OSError, ValueError, RuntimeError) as exc:
82
+ # Convert system-level exceptions to SchemaProblem so they can be
83
+ # handled the same way as schema validation problems.
84
+ # - OSError: system-level file system issues (e.g., os.getcwd() failures
85
+ # when the current working directory is inaccessible or deleted).
86
+ # Note: OSError that occurs when reading the schema file (e.g., permission
87
+ # denied, I/O errors) is converted to SchemaProblem in _load_raw_schema()
88
+ # and does not reach this exception handler.
89
+ # - ValueError: path resolution issues (e.g., invalid path characters,
90
+ # malformed paths during schema file discovery)
91
+ # - RuntimeError: threading issues (e.g., lock acquisition problems)
92
+ # Note: JSON parsing and schema structure validation errors are
93
+ # converted to SchemaProblem instances and do not raise ValueError here.
94
+ # Note: System exceptions (KeyboardInterrupt, SystemExit) are not
95
+ # caught, which is the correct behavior.
96
+ problems = [SchemaProblem(f"Schema compilation failed: {exc}")]
97
+ compiled = CompiledSchema(leaves=[])
98
+
99
+ if problems:
100
+ # Schema is invalid; log problems and terminate without creating
101
+ # the logger instance.
102
+ _log_schema_problems_and_exit(problems)
103
+
104
+ # Schema is valid; create the logger instance.
105
+ super().__init__(name, level)
106
+ self._schema: CompiledSchema = compiled
107
+
108
+ def _log(
109
+ self,
110
+ level: int,
111
+ msg: object,
112
+ args: tuple[object, ...] | Mapping[str, object],
113
+ exc_info: Any = None,
114
+ extra: Mapping[str, object] | None = None,
115
+ stack_info: bool = False,
116
+ stacklevel: int = 1,
117
+ ) -> None:
118
+ """Log a message with the specified level and schema-validated extra.
119
+
120
+ This method validates and filters the ``extra`` parameter according
121
+ to the compiled schema before delegating to the parent class. If
122
+ validation problems are detected, they are logged as ERROR messages
123
+ after the main log record has been emitted, ensuring compatibility
124
+ with standard logger behavior (no exceptions are raised).
125
+
126
+ Args:
127
+ level: Logging level.
128
+ msg: Message format string.
129
+ args: Arguments for message formatting.
130
+ exc_info: Exception information.
131
+ extra: Extra fields to include in the log record.
132
+ stack_info: Whether to include stack information.
133
+ stacklevel: Stack level for caller information.
134
+ """
135
+ structured_extra, data_problems = _apply_schema_internal(
136
+ self._schema,
137
+ extra or {},
138
+ )
139
+
140
+ super()._log(
141
+ level,
142
+ msg,
143
+ args,
144
+ exc_info=exc_info,
145
+ extra=structured_extra,
146
+ stack_info=stack_info,
147
+ # Increment stacklevel to account for this override frame so that
148
+ # caller information points to user code instead of SchemaLogger._log.
149
+ stacklevel=stacklevel + 1,
150
+ )
151
+
152
+ if data_problems:
153
+ # Log validation errors as ERROR messages
154
+ # Get caller information for the error log record.
155
+ # Python 3.11+ has improved findCaller() with proper stacklevel support,
156
+ # so we use it as the primary method for better performance.
157
+ # For Python < 3.11, we fall back to inspect.stack() due to known issues
158
+ # with findCaller() and stacklevel parameter.
159
+ if _USE_FINDCALLER:
160
+ # Use findCaller() for Python 3.11+ (more efficient)
161
+ fn, lno, func, sinfo = self.findCaller(
162
+ stack_info=False, stacklevel=stacklevel + 1
163
+ )
164
+ else:
165
+ # Fallback to inspect.stack() for Python < 3.11
166
+ # The stack looks like:
167
+ # - Frame 0: this function (_log)
168
+ # - Frame 1: logger.info() wrapper
169
+ # - Frame 2: actual caller (test_function)
170
+ # We need to skip frame 0 (this function) and frame 1
171
+ # (logger.info wrapper), so we use stacklevel + 1 to get to
172
+ # the actual caller.
173
+ stack = inspect.stack()
174
+ frame_idx = (
175
+ stacklevel + 1
176
+ ) # Skip this function (0) + logger.info wrapper (1)
177
+ if frame_idx < len(stack):
178
+ frame = stack[frame_idx]
179
+ fn = frame.filename
180
+ lno = frame.lineno
181
+ func = frame.function
182
+ sinfo = None
183
+ else:
184
+ # Fallback to findCaller if stack is shorter than expected
185
+ fn, lno, func, sinfo = self.findCaller(
186
+ stack_info=False, stacklevel=stacklevel + 1
187
+ )
188
+ # Format error message with details of all problems
189
+ problem_messages = [problem.message for problem in data_problems]
190
+ error_msg = f"Log data does not match schema: {'; '.join(problem_messages)}"
191
+ error_record = self.makeRecord(
192
+ self.name,
193
+ logging.ERROR,
194
+ fn,
195
+ lno,
196
+ error_msg,
197
+ (),
198
+ None, # exc_info - not needed
199
+ func, # func - function name from caller
200
+ None, # extra - not needed
201
+ sinfo, # sinfo - stack info from caller
202
+ )
203
+ try:
204
+ self.callHandlers(error_record)
205
+ except Exception:
206
+ # If handler failed, log error to stderr (standard logging behavior)
207
+ sys.stderr.write(f"Error in logging handler: {error_record}\n")
@@ -1,84 +0,0 @@
1
- """Custom exception types used by logging_objects_with_schema."""
2
-
3
- from __future__ import annotations
4
-
5
- from dataclasses import dataclass
6
-
7
-
8
- @dataclass
9
- class SchemaProblem:
10
- """Describes a single problem encountered while loading the schema.
11
-
12
- Attributes:
13
- message: Human-readable description of the problem.
14
- """
15
-
16
- message: str
17
-
18
-
19
- class SchemaValidationError(Exception):
20
- """Raised when there are problems with the JSON schema definition.
21
-
22
- This exception is raised during SchemaLogger initialization when the schema
23
- file cannot be loaded, parsed, or validated. The logger instance will not
24
- be created if this exception is raised.
25
-
26
- The human-readable summary is stored in the exception message, while
27
- detailed information about each violation is available in the ``problems``
28
- attribute.
29
-
30
- Attributes:
31
- problems: List of SchemaProblem instances describing each validation issue.
32
-
33
- Example:
34
- >>> try:
35
- ... logger = SchemaLogger("my_logger")
36
- ... except SchemaValidationError as e:
37
- ... for problem in e.problems:
38
- ... print(f"Schema error: {problem.message}")
39
- """
40
-
41
- def __init__(
42
- self, message: str, problems: list[SchemaProblem] | None = None
43
- ) -> None:
44
- super().__init__(message)
45
- self.problems: list[SchemaProblem] = problems or []
46
-
47
-
48
- @dataclass
49
- class DataProblem:
50
- """Describes a single problem encountered while validating log data.
51
-
52
- Attributes:
53
- message: Human-readable description of the validation problem.
54
- """
55
-
56
- message: str
57
-
58
-
59
- class DataValidationError(Exception):
60
- """Raised when log record data does not satisfy the configured schema.
61
-
62
- This exception is raised *after* the valid part of the log record
63
- has already been formatted and sent to the underlying handler. This means
64
- that even if validation fails, the valid fields will still appear in the log.
65
-
66
- The exception message provides a summary description of the validation
67
- failure, while detailed information about individual problems is exposed
68
- via the ``problems`` attribute.
69
-
70
- Attributes:
71
- problems: List of DataProblem instances describing each validation issue.
72
-
73
- Example:
74
- >>> try:
75
- ... logger.info("processing", extra={"user_id": "not-an-int"})
76
- ... except DataValidationError as e:
77
- ... for problem in e.problems:
78
- ... print(f"Validation error: {problem.message}")
79
- ... # Note: valid fields were already logged before this exception
80
- """
81
-
82
- def __init__(self, message: str, problems: list[DataProblem] | None = None) -> None:
83
- super().__init__(message)
84
- self.problems: list[DataProblem] = problems or []
@@ -1,139 +0,0 @@
1
- """Logger subclass that applies a JSON schema to extra fields.
2
-
3
- This class extends the standard ``logging.Logger`` to validate and filter
4
- user-provided ``extra`` fields according to a compiled JSON schema. It raises
5
- a DataValidationError *after* the log record has been emitted when problems
6
- are detected.
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import logging
12
- from collections.abc import Mapping
13
- from typing import Any
14
-
15
- from .errors import DataValidationError, SchemaValidationError
16
- from .schema_applier import _apply_schema_internal
17
- from .schema_loader import CompiledSchema, _compile_schema_internal
18
-
19
-
20
- class SchemaLogger(logging.Logger):
21
- """Logger subclass that enforces schema on extra fields.
22
-
23
- This class extends :class:`logging.Logger` to add validation for
24
- ``extra`` fields according to a compiled JSON schema. It is designed
25
- to be used as a drop-in replacement via :func:`logging.setLoggerClass`.
26
-
27
- The schema is loaded from ``logging_objects_with_schema.json`` in the
28
- application root directory during initialization. If the schema cannot
29
- be loaded or validated, a :class:`SchemaValidationError` is raised.
30
-
31
- Example:
32
- >>> import logging
33
- >>> from logging_objects_with_schema import SchemaLogger
34
- >>> logging.setLoggerClass(SchemaLogger)
35
- >>> logger = logging.getLogger("my_service")
36
- >>> logger.info("request processed", extra={"request_id": "abc-123"})
37
- """
38
-
39
- def __init__(self, name: str, level: int = logging.NOTSET) -> None:
40
- """Initialise the schema-aware logger.
41
-
42
- The schema is compiled once during construction. If any
43
- problems are detected in the schema, a SchemaValidationError
44
- will be raised and this logger instance is not usable.
45
-
46
- Args:
47
- name: Logger name (same as :class:`logging.Logger`).
48
- level: Logger level (same as :class:`logging.Logger`).
49
- """
50
- super().__init__(name, level)
51
-
52
- try:
53
- compiled, problems = _compile_schema_internal()
54
- except (OSError, ValueError, RuntimeError):
55
- # Catch specific exceptions that can occur during schema compilation:
56
- # - OSError: system-level file system issues (e.g., os.getcwd() failures
57
- # when the current working directory is inaccessible or deleted).
58
- # Note: OSError that occurs when reading the schema file (e.g., permission
59
- # denied, I/O errors) is converted to SchemaValidationError in
60
- # _load_raw_schema() and does not reach this exception handler.
61
- # - ValueError: path resolution issues (e.g., invalid path characters,
62
- # malformed paths during schema file discovery)
63
- # - RuntimeError: threading issues (e.g., lock acquisition problems)
64
- # Note: JSON parsing and schema structure validation errors are
65
- # converted to SchemaProblem instances and do not raise ValueError here.
66
- # Note: System exceptions (KeyboardInterrupt, SystemExit) are not
67
- # caught, which is the correct behavior.
68
- # Ensure that a partially initialised logger instance is not left
69
- # registered in the logging manager if schema compilation fails.
70
- # Otherwise, subsequent logging.getLogger(name) calls could return
71
- # this broken instance and lead to AttributeError at runtime.
72
- self._cleanup_failed_logger()
73
- raise
74
-
75
- if problems:
76
- # Schema is invalid; remove this instance from the logging manager
77
- # cache before raising, for the same reason as above.
78
- self._cleanup_failed_logger()
79
- raise SchemaValidationError("Schema has problems", problems=problems)
80
-
81
- self._schema: CompiledSchema = compiled
82
-
83
- def _cleanup_failed_logger(self) -> None:
84
- """Remove this logger instance from the logging manager.
85
-
86
- Called when schema compilation fails to prevent broken logger
87
- instances from being cached and reused.
88
- """
89
- self.manager.loggerDict.pop(self.name, None)
90
-
91
- def _log(
92
- self,
93
- level: int,
94
- msg: object,
95
- args: tuple[object, ...] | Mapping[str, object],
96
- exc_info: Any = None,
97
- extra: Mapping[str, object] | None = None,
98
- stack_info: bool = False,
99
- stacklevel: int = 1,
100
- ) -> None:
101
- """Log a message with the specified level and schema-validated extra.
102
-
103
- This method validates and filters the ``extra`` parameter according
104
- to the compiled schema before delegating to the parent class.
105
-
106
- Args:
107
- level: Logging level.
108
- msg: Message format string.
109
- args: Arguments for message formatting.
110
- exc_info: Exception information.
111
- extra: Extra fields to include in the log record.
112
- stack_info: Whether to include stack information.
113
- stacklevel: Stack level for caller information.
114
-
115
- Raises:
116
- DataValidationError: If the ``extra`` data does not match the schema.
117
- """
118
- structured_extra, data_problems = _apply_schema_internal(
119
- self._schema,
120
- extra or {},
121
- )
122
-
123
- super()._log(
124
- level,
125
- msg,
126
- args,
127
- exc_info=exc_info,
128
- extra=structured_extra,
129
- stack_info=stack_info,
130
- # Increment stacklevel to account for this override frame so that
131
- # caller information points to user code instead of SchemaLogger._log.
132
- stacklevel=stacklevel + 1,
133
- )
134
-
135
- if data_problems:
136
- raise DataValidationError(
137
- "Log data does not match schema",
138
- problems=data_problems,
139
- )