yapl-kit 0.2.0__tar.gz → 0.2.1__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.
Files changed (57) hide show
  1. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/PKG-INFO +10 -32
  2. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/PUBLIC.md +8 -25
  3. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/pyproject.toml +5 -9
  4. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/pyproject.toml.orig +7 -11
  5. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/config.py +3 -0
  6. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/extensions/buffering/capture.py +19 -1
  7. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/extensions/buffering/context.py +15 -2
  8. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/extensions/buffering/models.py +1 -0
  9. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/extensions/coloring/models.py +1 -1
  10. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/extensions/coloring/plain.py +14 -3
  11. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/extensions/formatting/processors.py +6 -1
  12. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/extensions/queueing/__init__.py +2 -0
  13. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/extensions/queueing/handler.py +88 -9
  14. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/extensions/queueing/models.py +27 -1
  15. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/extensions/webhook/__init__.py +1 -5
  16. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/extensions/webhook/client.py +7 -1
  17. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/extensions/webhook/handler.py +3 -0
  18. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/extensions/webhook/models.py +10 -3
  19. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/extensions/webhook/payloads.py +16 -10
  20. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/factory.py +173 -106
  21. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/filters/suppression.py +1 -1
  22. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/stack/tracer.py +111 -14
  23. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/status.py +2 -2
  24. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/__init__.py +0 -0
  25. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/context.py +0 -0
  26. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/extensions/buffering/__init__.py +0 -0
  27. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/extensions/buffering/flush.py +0 -0
  28. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/extensions/coloring/__init__.py +0 -0
  29. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/extensions/coloring/colors.py +0 -0
  30. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/extensions/coloring/policy.py +0 -0
  31. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/extensions/coloring/registry.py +0 -0
  32. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/extensions/coloring/structured.py +0 -0
  33. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/extensions/coloring/text.py +0 -0
  34. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/extensions/coloring/themes.py +0 -0
  35. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/extensions/formatting/__init__.py +0 -0
  36. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/extensions/formatting/_helpers.py +0 -0
  37. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/extensions/formatting/_types.py +0 -0
  38. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/extensions/formatting/formatters.py +0 -0
  39. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/extensions/formatting/json.py +0 -0
  40. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/extensions/formatting/logfmt.py +0 -0
  41. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/extensions/formatting/plain.py +0 -0
  42. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/extensions/formatting/structured.py +0 -0
  43. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/extensions/formatting/text.py +0 -0
  44. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/extensions/queueing/policy.py +0 -0
  45. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/extensions/queueing/queue.py +0 -0
  46. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/extensions/queueing/worker.py +0 -0
  47. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/filters/__init__.py +0 -0
  48. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/filters/enrichment.py +0 -0
  49. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/levels.py +0 -0
  50. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/logger.py +0 -0
  51. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/py.typed +0 -0
  52. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/stack/__init__.py +0 -0
  53. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/stack/callsite.py +0 -0
  54. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/testing.py +0 -0
  55. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl/types.py +0 -0
  56. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl_kit/__init__.py +0 -0
  57. {yapl_kit-0.2.0 → yapl_kit-0.2.1}/src/yapl_kit/py.typed +0 -0
@@ -1,15 +1,10 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: yapl-kit
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: YetAnotherPythonLogger - a minimal, extensible logging toolkit built on top of Python's standard logging module.
5
5
  Author: SirKaiMartin
6
- Requires-Dist: httpx>=0.28.1 ; extra == 'webhook'
6
+ Requires-Dist: httpx>=0.28.1
7
7
  Requires-Python: >=3.13
8
- Provides-Extra: buffering
9
- Provides-Extra: coloring
10
- Provides-Extra: formatting
11
- Provides-Extra: queueing
12
- Provides-Extra: webhook
13
8
  Description-Content-Type: text/markdown
14
9
 
15
10
  # YAPL Kit
@@ -18,9 +13,10 @@ Description-Content-Type: text/markdown
18
13
 
19
14
  > A modern, minimal, and extensible logging toolkit built on top of Python's standard `logging` module.
20
15
 
21
- YAPL keeps the stdlib logging ecosystem intact while adding structured logging, queue-backed delivery, formatting, coloring, buffering, stack helpers, and optional webhook support.
16
+ YAPL keeps the stdlib logging ecosystem intact while adding structured logging, queue-backed delivery, formatting, coloring, buffering, stack helpers, and webhook support.
22
17
 
23
- The core stays dependency-free. Optional integrations are installed only when you need them.
18
+ All YAPL feature families ship in the single `yapl-kit` distribution. Webhook
19
+ support uses `httpx`, which is installed automatically.
24
20
 
25
21
  ## Installation
26
22
 
@@ -38,18 +34,6 @@ With `pip`:
38
34
  pip install yapl-kit
39
35
  ```
40
36
 
41
- Install all optional features:
42
-
43
- ```bash
44
- uv add "yapl-kit[formatting,coloring,buffering,queueing,webhook]"
45
- ```
46
-
47
- or:
48
-
49
- ```bash
50
- pip install "yapl-kit[formatting,coloring,buffering,queueing,webhook]"
51
- ```
52
-
53
37
  The canonical import namespace is `yapl`:
54
38
 
55
39
  ```python
@@ -119,7 +103,7 @@ YAPL does not replace Python logging. It builds on stdlib handlers, formatters,
119
103
 
120
104
  The main design goals are:
121
105
 
122
- - keep the core small and dependency-free
106
+ - keep the distribution small and focused
123
107
  - preserve compatibility with Python's logging ecosystem
124
108
  - keep expensive formatting and I/O off the application hot path
125
109
  - make queueing, retries, and shutdown behavior bounded
@@ -225,13 +209,7 @@ Queue capacity limits the number of retained records, not their total byte size.
225
209
 
226
210
  ## Webhooks
227
211
 
228
- Webhook delivery is optional and uses `httpx`.
229
-
230
- Install it with:
231
-
232
- ```bash
233
- uv add "yapl-kit[webhook]"
234
- ```
212
+ Webhook delivery uses `httpx`, included automatically with `yapl-kit`.
235
213
 
236
214
  Configure a webhook:
237
215
 
@@ -339,9 +317,9 @@ Python cannot safely terminate arbitrary user handler code. Keep custom handlers
339
317
 
340
318
  `queue_enabled=False` runs normal stdlib handlers directly, so file, console, or custom sink work may occur on the caller thread.
341
319
 
342
- ## Optional Modules
320
+ ## Extension Modules
343
321
 
344
- YAPL keeps concrete optional features under explicit modules:
322
+ YAPL keeps feature families under explicit modules within the main distribution:
345
323
 
346
324
  ```python
347
325
  from yapl.stack import FunctionCallTracer, get_callsite
@@ -352,7 +330,7 @@ from yapl.extensions.queueing import QueueingConfig
352
330
  from yapl.extensions.webhook import WebhookConfig
353
331
  ```
354
332
 
355
- The `yapl.extensions` package is a namespace; concrete features live in its submodules.
333
+ The `yapl.extensions` package contains the concrete feature implementations.
356
334
 
357
335
  ## Package Philosophy
358
336
 
@@ -4,9 +4,10 @@
4
4
 
5
5
  > A modern, minimal, and extensible logging toolkit built on top of Python's standard `logging` module.
6
6
 
7
- YAPL keeps the stdlib logging ecosystem intact while adding structured logging, queue-backed delivery, formatting, coloring, buffering, stack helpers, and optional webhook support.
7
+ YAPL keeps the stdlib logging ecosystem intact while adding structured logging, queue-backed delivery, formatting, coloring, buffering, stack helpers, and webhook support.
8
8
 
9
- The core stays dependency-free. Optional integrations are installed only when you need them.
9
+ All YAPL feature families ship in the single `yapl-kit` distribution. Webhook
10
+ support uses `httpx`, which is installed automatically.
10
11
 
11
12
  ## Installation
12
13
 
@@ -24,18 +25,6 @@ With `pip`:
24
25
  pip install yapl-kit
25
26
  ```
26
27
 
27
- Install all optional features:
28
-
29
- ```bash
30
- uv add "yapl-kit[formatting,coloring,buffering,queueing,webhook]"
31
- ```
32
-
33
- or:
34
-
35
- ```bash
36
- pip install "yapl-kit[formatting,coloring,buffering,queueing,webhook]"
37
- ```
38
-
39
28
  The canonical import namespace is `yapl`:
40
29
 
41
30
  ```python
@@ -105,7 +94,7 @@ YAPL does not replace Python logging. It builds on stdlib handlers, formatters,
105
94
 
106
95
  The main design goals are:
107
96
 
108
- - keep the core small and dependency-free
97
+ - keep the distribution small and focused
109
98
  - preserve compatibility with Python's logging ecosystem
110
99
  - keep expensive formatting and I/O off the application hot path
111
100
  - make queueing, retries, and shutdown behavior bounded
@@ -211,13 +200,7 @@ Queue capacity limits the number of retained records, not their total byte size.
211
200
 
212
201
  ## Webhooks
213
202
 
214
- Webhook delivery is optional and uses `httpx`.
215
-
216
- Install it with:
217
-
218
- ```bash
219
- uv add "yapl-kit[webhook]"
220
- ```
203
+ Webhook delivery uses `httpx`, included automatically with `yapl-kit`.
221
204
 
222
205
  Configure a webhook:
223
206
 
@@ -325,9 +308,9 @@ Python cannot safely terminate arbitrary user handler code. Keep custom handlers
325
308
 
326
309
  `queue_enabled=False` runs normal stdlib handlers directly, so file, console, or custom sink work may occur on the caller thread.
327
310
 
328
- ## Optional Modules
311
+ ## Extension Modules
329
312
 
330
- YAPL keeps concrete optional features under explicit modules:
313
+ YAPL keeps feature families under explicit modules within the main distribution:
331
314
 
332
315
  ```python
333
316
  from yapl.stack import FunctionCallTracer, get_callsite
@@ -338,7 +321,7 @@ from yapl.extensions.queueing import QueueingConfig
338
321
  from yapl.extensions.webhook import WebhookConfig
339
322
  ```
340
323
 
341
- The `yapl.extensions` package is a namespace; concrete features live in its submodules.
324
+ The `yapl.extensions` package contains the concrete feature implementations.
342
325
 
343
326
  ## Package Philosophy
344
327
 
@@ -1,21 +1,14 @@
1
1
  [project]
2
2
  name = "yapl-kit"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "YetAnotherPythonLogger - a minimal, extensible logging toolkit built on top of Python's standard logging module."
5
5
  readme = "PUBLIC.md"
6
6
  requires-python = ">=3.13"
7
- dependencies = []
7
+ dependencies = ["httpx>=0.28.1"]
8
8
 
9
9
  [[project.authors]]
10
10
  name = "SirKaiMartin"
11
11
 
12
- [project.optional-dependencies]
13
- formatting = []
14
- coloring = []
15
- buffering = []
16
- queueing = []
17
- webhook = ["httpx>=0.28.1"]
18
-
19
12
  [build-system]
20
13
  requires = ["uv_build>=0.12.16,<0.13.0"]
21
14
  build-backend = "uv_build"
@@ -71,6 +64,9 @@ section-order = [
71
64
  "local-folder",
72
65
  ]
73
66
 
67
+ [tool.ty.src]
68
+ include = ["src"]
69
+
74
70
  [dependency-groups]
75
71
  dev = [
76
72
  "pytest>=9.1.1",
@@ -1,13 +1,15 @@
1
1
  [project]
2
2
  name = "yapl-kit"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "YetAnotherPythonLogger - a minimal, extensible logging toolkit built on top of Python's standard logging module."
5
5
  readme = "PUBLIC.md"
6
6
  authors = [
7
7
  { name = "SirKaiMartin" }
8
8
  ]
9
9
  requires-python = ">=3.13"
10
- dependencies = []
10
+ dependencies = [
11
+ "httpx>=0.28.1",
12
+ ]
11
13
 
12
14
  [build-system]
13
15
  requires = ["uv_build>=0.12.16,<0.13.0"]
@@ -30,15 +32,6 @@ markers = [
30
32
  "performance: caller-latency, throughput, and backpressure regression coverage",
31
33
  ]
32
34
 
33
- [project.optional-dependencies]
34
- formatting = []
35
- coloring = []
36
- buffering = []
37
- queueing = []
38
- webhook = [
39
- "httpx>=0.28.1",
40
- ]
41
-
42
35
  [tool.ruff]
43
36
  target-version = "py313"
44
37
  line-length = 100
@@ -78,3 +71,6 @@ section-order = [
78
71
  "first-party",
79
72
  "local-folder",
80
73
  ]
74
+
75
+ [tool.ty.src]
76
+ include = ["src"]
@@ -18,6 +18,7 @@ from .types import (
18
18
 
19
19
  if TYPE_CHECKING:
20
20
  from .extensions.formatting.processors import LogEventProcessor
21
+ from .extensions.queueing.models import QueueErrorCallback
21
22
  from .extensions.webhook.models import WebhookConfig
22
23
 
23
24
 
@@ -216,6 +217,7 @@ class YaplConfig:
216
217
  file_path: File used by shorthand ``"file"`` output.
217
218
  overrides: Per-logger level overrides.
218
219
  suppression: Rules applied to configured handlers.
220
+ queue_error_callback: Optional callback for isolated target-handler failures.
219
221
  """
220
222
 
221
223
  level: int = 20
@@ -236,6 +238,7 @@ class YaplConfig:
236
238
  overrides: LoggerOverrides = field(default_factory=LoggerOverrides)
237
239
  suppression: SuppressionRules = field(default_factory=SuppressionRules)
238
240
  shutdown_timeout_seconds: float = 5.0
241
+ queue_error_callback: QueueErrorCallback | None = None
239
242
 
240
243
  def with_level(self, level: LevelLike) -> YaplConfig:
241
244
  """Set the root level and return this configuration.
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
3
4
  from contextvars import ContextVar, Token
4
5
  from copy import copy
5
6
  import logging
@@ -18,6 +19,23 @@ _capture_enabled: ContextVar[bool] = ContextVar(
18
19
  )
19
20
 
20
21
 
22
+ def current_buffer_owner() -> object | None:
23
+ """Return the current async task, or ``None`` outside an event loop."""
24
+ try:
25
+ return asyncio.current_task()
26
+ except RuntimeError:
27
+ return None
28
+
29
+
30
+ def owns_buffer_state(state: BufferingState) -> bool:
31
+ """Return whether ``state`` belongs to this synchronous context or task.
32
+
33
+ Context variables already isolate ordinary threads. Only task-owned states
34
+ need an asyncio lookup because child tasks inherit their parent's context.
35
+ """
36
+ return state.owner_task is None or state.owner_task is current_buffer_owner()
37
+
38
+
21
39
  def get_active_buffer_state() -> BufferingState | None:
22
40
  return _active_buffer_state.get()
23
41
 
@@ -54,7 +72,7 @@ class BufferCaptureFilter(logging.Filter):
54
72
  return True
55
73
 
56
74
  state = get_active_buffer_state()
57
- if state is None:
75
+ if state is None or not owns_buffer_state(state):
58
76
  return True
59
77
 
60
78
  buffered_record = copy(record)
@@ -7,8 +7,10 @@ from typing import Self
7
7
 
8
8
  from .capture import (
9
9
  BufferCaptureFilter,
10
+ current_buffer_owner,
10
11
  get_active_buffer_state,
11
12
  is_capture_enabled,
13
+ owns_buffer_state,
12
14
  reset_active_buffer_state,
13
15
  reset_capture_enabled,
14
16
  set_active_buffer_state,
@@ -87,8 +89,13 @@ class BufferedLogs:
87
89
 
88
90
  def _enter(self) -> Self:
89
91
  self._previous_state = get_active_buffer_state()
92
+ owner_task = current_buffer_owner()
90
93
 
91
- if self._previous_state is not None and self._cfg.include_nested_contexts:
94
+ if (
95
+ self._previous_state is not None
96
+ and self._cfg.include_nested_contexts
97
+ and self._previous_state.owner_task is owner_task
98
+ ):
92
99
  self._state = self._previous_state
93
100
  self._store = self._state.store
94
101
  self._cfg = self._state.config
@@ -96,6 +103,11 @@ class BufferedLogs:
96
103
  self._owns_state = False
97
104
  return self
98
105
 
106
+ self._state = BufferingState(
107
+ store=self._store,
108
+ config=self._cfg,
109
+ owner_task=owner_task,
110
+ )
99
111
  self._token = set_active_buffer_state(self._state)
100
112
  self._owns_state = True
101
113
  return self
@@ -134,7 +146,8 @@ class BufferingFilter(logging.Filter):
134
146
 
135
147
  def filter(self, record: logging.LogRecord) -> bool:
136
148
  """Allow records only when capture is inactive for this context."""
137
- return (not is_capture_enabled()) or get_active_buffer_state() is None
149
+ state = get_active_buffer_state()
150
+ return (not is_capture_enabled()) or state is None or not owns_buffer_state(state)
138
151
 
139
152
 
140
153
  def install_buffering(*, stream_handlers_only: bool = False) -> int:
@@ -63,3 +63,4 @@ class BufferingState:
63
63
 
64
64
  store: BufferedLogStore
65
65
  config: BufferingConfig
66
+ owner_task: object | None = field(default=None, repr=False, compare=False)
@@ -10,7 +10,7 @@ LEVEL_COLOR_TOKEN = "level_color"
10
10
 
11
11
 
12
12
  def _resolve_dict_color(value: Mapping[str, str]) -> dict[str, str]:
13
- return {key: resolve_color(color) for key, color in value.items()}
13
+ return {key.upper(): resolve_color(color) for key, color in value.items()}
14
14
 
15
15
 
16
16
  def _resolve_style_color(value: str) -> str:
@@ -29,6 +29,7 @@ class ColorPlainFormatter(logging.Formatter):
29
29
 
30
30
  message = record.getMessage()
31
31
  level_name = record.levelname
32
+ level_color = theme.color_for_level(level_name)
32
33
 
33
34
  parts: list[str] = []
34
35
 
@@ -43,7 +44,7 @@ class ColorPlainFormatter(logging.Formatter):
43
44
  parts.append(self._color_field("logger", record.name, theme))
44
45
 
45
46
  if self._config.include_message:
46
- parts.append(self._color_field("message", message, theme))
47
+ parts.append(self._color_field("message", message, theme, level_color=level_color))
47
48
 
48
49
  rendered = self._config.separator.join(parts)
49
50
 
@@ -83,9 +84,19 @@ class ColorPlainFormatter(logging.Formatter):
83
84
  return text
84
85
  return f"{color}{text}{reset}"
85
86
 
86
- def _color_field(self, field_name: str, value: Any, theme: Any) -> str:
87
+ def _color_field(
88
+ self,
89
+ field_name: str,
90
+ value: Any,
91
+ theme: Any,
92
+ *,
93
+ level_color: str | None = None,
94
+ ) -> str:
87
95
  style = theme.style_for_field(field_name)
88
- return self._wrap(str(value), style.value_color, theme.reset)
96
+ color = style.value_color
97
+ if color == "level_color":
98
+ color = level_color or ""
99
+ return self._wrap(str(value), color, theme.reset)
89
100
 
90
101
  def _color_level(self, level_name: str, theme: Any) -> str:
91
102
  style = theme.style_for_field("level")
@@ -302,8 +302,13 @@ class ParseMessageFields:
302
302
  if not isinstance(message, str) or "=" not in message:
303
303
  return event
304
304
 
305
+ try:
306
+ tokens = shlex.split(message)
307
+ except ValueError:
308
+ return event
309
+
305
310
  parsed: dict[str, Any] = {}
306
- for token in shlex.split(message):
311
+ for token in tokens:
307
312
  if "=" not in token:
308
313
  continue
309
314
  key, value = token.split("=", 1)
@@ -3,6 +3,7 @@ from .models import (
3
3
  QueueBatch,
4
4
  QueueDiagnostics,
5
5
  QueuedLogItem,
6
+ QueueErrorCallback,
6
7
  QueueingConfig,
7
8
  QueueOverflowPolicy,
8
9
  )
@@ -14,6 +15,7 @@ __all__ = [
14
15
  "LogQueue",
15
16
  "QueueBatch",
16
17
  "QueueDiagnostics",
18
+ "QueueErrorCallback",
17
19
  "QueueOverflowPolicy",
18
20
  "QueueWorker",
19
21
  "QueuedLogItem",
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import contextlib
4
+ from dataclasses import dataclass
4
5
  import logging
5
6
  from logging.handlers import QueueHandler
6
7
  from queue import Empty, Full, Queue
@@ -11,6 +12,12 @@ from .models import QueueDiagnostics, QueueingConfig
11
12
  from .policy import should_fallback_to_file
12
13
 
13
14
 
15
+ @dataclass(slots=True)
16
+ class _TargetFlushState:
17
+ completed: threading.Event
18
+ failed: bool = False
19
+
20
+
14
21
  class QueueingHandler(QueueHandler):
15
22
  """Move handler work off the logging thread with bounded shutdown waits.
16
23
 
@@ -35,6 +42,7 @@ class QueueingHandler(QueueHandler):
35
42
  self._cfg = config or QueueingConfig()
36
43
  self._diagnostics = QueueDiagnostics()
37
44
  self._target_handlers = tuple(handler for handler in target_handlers if handler is not self)
45
+ self._target_labels = self._build_target_labels()
38
46
  self._queue: Queue[logging.LogRecord] = Queue(maxsize=self._cfg.max_queue_size)
39
47
  super().__init__(self._queue)
40
48
  self._fallback_handler = self._find_fallback_handler()
@@ -48,12 +56,14 @@ class QueueingHandler(QueueHandler):
48
56
  self._accepting = True
49
57
  self._stop_event = threading.Event()
50
58
  self._state_lock = threading.Lock()
59
+ self._target_flush_lock = threading.Lock()
60
+ self._target_flush_states: dict[int, _TargetFlushState] = {}
51
61
  self._active_blocking_enqueues = 0
52
62
  self._targets_closed = False
53
63
  self._workers_remaining = 1 + (self._fallback_queue is not None)
54
64
  self._worker = threading.Thread(
55
65
  target=self._run,
56
- args=(self._queue, self._target_handlers),
66
+ args=(self._queue, self._target_handlers, self._target_labels),
57
67
  name=self._cfg.worker_name,
58
68
  daemon=True,
59
69
  )
@@ -62,7 +72,11 @@ class QueueingHandler(QueueHandler):
62
72
  if self._fallback_queue is not None and self._fallback_handler is not None:
63
73
  self._fallback_worker = threading.Thread(
64
74
  target=self._run,
65
- args=(self._fallback_queue, (self._fallback_handler,)),
75
+ args=(
76
+ self._fallback_queue,
77
+ (self._fallback_handler,),
78
+ (self._target_labels[self._target_handlers.index(self._fallback_handler)],),
79
+ ),
66
80
  name=f"{self._cfg.worker_name}-fallback",
67
81
  daemon=True,
68
82
  )
@@ -90,7 +104,7 @@ class QueueingHandler(QueueHandler):
90
104
  self._fallback_worker is not None and self._fallback_worker.is_alive()
91
105
  )
92
106
 
93
- def diagnostics_snapshot(self) -> dict[str, int]:
107
+ def diagnostics_snapshot(self) -> dict[str, object]:
94
108
  """Return queue counters and the current combined queued-record count."""
95
109
  queue_size = self._queue.qsize()
96
110
  if self._fallback_queue is not None:
@@ -125,6 +139,8 @@ class QueueingHandler(QueueHandler):
125
139
  try:
126
140
  with self._state_lock:
127
141
  if not self._accepting:
142
+ self._diagnostics.dropped += 1
143
+ self._diagnostics.dropped_shutdown += 1
128
144
  return
129
145
  self._queue.put_nowait(record)
130
146
  except Full:
@@ -136,6 +152,8 @@ class QueueingHandler(QueueHandler):
136
152
  def _enqueue_blocking(self, record: logging.LogRecord) -> None:
137
153
  with self._state_lock:
138
154
  if not self._accepting:
155
+ self._diagnostics.dropped += 1
156
+ self._diagnostics.dropped_shutdown += 1
139
157
  return
140
158
  self._active_blocking_enqueues += 1
141
159
 
@@ -180,7 +198,9 @@ class QueueingHandler(QueueHandler):
180
198
  if timed_flush(remaining) is False:
181
199
  drained = False
182
200
  else:
183
- handler.flush()
201
+ remaining = max(0.0, deadline - time.monotonic())
202
+ if not self._flush_target_with_timeout(handler, remaining):
203
+ drained = False
184
204
  except Exception:
185
205
  drained = False
186
206
  if drained:
@@ -227,6 +247,7 @@ class QueueingHandler(QueueHandler):
227
247
  self,
228
248
  queue: Queue[logging.LogRecord],
229
249
  handlers: tuple[logging.Handler, ...],
250
+ labels: tuple[str, ...],
230
251
  ) -> None:
231
252
  try:
232
253
  while True:
@@ -242,7 +263,7 @@ class QueueingHandler(QueueHandler):
242
263
  self._diagnostics.dropped += 1
243
264
  self._diagnostics.dropped_shutdown += 1
244
265
  else:
245
- self._dispatch(record, handlers)
266
+ self._dispatch(record, handlers, labels)
246
267
  finally:
247
268
  queue.task_done()
248
269
  finally:
@@ -258,16 +279,34 @@ class QueueingHandler(QueueHandler):
258
279
  # until it resolves so a late record is discarded instead of stranded.
259
280
  return self._active_blocking_enqueues == 0
260
281
 
261
- def _dispatch(self, record: logging.LogRecord, handlers: tuple[logging.Handler, ...]) -> None:
262
- for handler in handlers:
282
+ def _dispatch(
283
+ self,
284
+ record: logging.LogRecord,
285
+ handlers: tuple[logging.Handler, ...],
286
+ labels: tuple[str, ...],
287
+ ) -> None:
288
+ for handler, label in zip(handlers, labels, strict=True):
263
289
  if self._cfg.respect_handler_level and record.levelno < handler.level:
264
290
  continue
265
291
  try:
266
292
  handler.handle(record)
267
- except Exception:
293
+ except Exception as error:
294
+ self._diagnostics.record_handler_failure(label)
295
+ if self._cfg.error_callback is not None:
296
+ with contextlib.suppress(Exception):
297
+ self._cfg.error_callback(handler, record, error)
268
298
  # Isolate one target failure so it cannot stop shared delivery.
269
299
  continue
270
300
 
301
+ def _build_target_labels(self) -> tuple[str, ...]:
302
+ counts: dict[str, int] = {}
303
+ labels: list[str] = []
304
+ for handler in self._target_handlers:
305
+ name = handler.get_name() or type(handler).__name__
306
+ counts[name] = counts.get(name, 0) + 1
307
+ labels.append(name if counts[name] == 1 else f"{name}#{counts[name]}")
308
+ return tuple(labels)
309
+
271
310
  def _close_targets_if_finished(self) -> None:
272
311
  with self._state_lock:
273
312
  if self._workers_remaining or self._targets_closed:
@@ -275,7 +314,7 @@ class QueueingHandler(QueueHandler):
275
314
  self._targets_closed = True
276
315
  for handler in self._target_handlers:
277
316
  with contextlib.suppress(Exception):
278
- handler.flush()
317
+ self._flush_target_with_timeout(handler, None)
279
318
  with contextlib.suppress(Exception):
280
319
  handler.close()
281
320
 
@@ -302,6 +341,46 @@ class QueueingHandler(QueueHandler):
302
341
  None,
303
342
  )
304
343
 
344
+ def _flush_target_with_timeout(
345
+ self,
346
+ handler: logging.Handler,
347
+ timeout_seconds: float | None,
348
+ ) -> bool:
349
+ """Flush one target with one reusable in-flight timeout operation."""
350
+ handler_key = id(handler)
351
+ with self._target_flush_lock:
352
+ state = self._target_flush_states.get(handler_key)
353
+ if state is None:
354
+ state = _TargetFlushState(completed=threading.Event())
355
+ self._target_flush_states[handler_key] = state
356
+ threading.Thread(
357
+ target=self._run_target_flush,
358
+ args=(handler, state),
359
+ name="yapl-queue-target-flush",
360
+ daemon=True,
361
+ ).start()
362
+
363
+ if not state.completed.wait(timeout_seconds):
364
+ return False
365
+
366
+ return not state.failed
367
+
368
+ def _run_target_flush(
369
+ self,
370
+ handler: logging.Handler,
371
+ state: _TargetFlushState,
372
+ ) -> None:
373
+ try:
374
+ handler.flush()
375
+ except Exception:
376
+ state.failed = True
377
+ finally:
378
+ state.completed.set()
379
+ with self._target_flush_lock:
380
+ handler_key = id(handler)
381
+ if self._target_flush_states.get(handler_key) is state:
382
+ del self._target_flush_states[handler_key]
383
+
305
384
 
306
385
  def enable_queueing(
307
386
  config: QueueingConfig | None = None,