log-foundry 0.10.2.dev125__tar.gz → 0.10.2.dev127__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 (60) hide show
  1. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/PKG-INFO +10 -3
  2. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/README.md +9 -2
  3. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/pyproject.toml +1 -1
  4. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/_diag.py +11 -1
  5. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/config.py +42 -3
  6. log_foundry-0.10.2.dev127/src/log_foundry/console.py +131 -0
  7. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/decorator.py +106 -8
  8. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/sanitize.py +147 -28
  9. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/kinesis.py +7 -5
  10. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/worker.py +2 -3
  11. log_foundry-0.10.2.dev125/src/log_foundry/console.py +0 -61
  12. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/LICENSE +0 -0
  13. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/__init__.py +0 -0
  14. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/_fork.py +0 -0
  15. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/_lifecycle.py +0 -0
  16. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/api.py +0 -0
  17. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/context.py +0 -0
  18. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/ids.py +0 -0
  19. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/model.py +0 -0
  20. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/py.typed +0 -0
  21. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/results.py +0 -0
  22. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/__init__.py +0 -0
  23. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/_batch.py +0 -0
  24. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/_chunk.py +0 -0
  25. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/_retry.py +0 -0
  26. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/_socket.py +0 -0
  27. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/_time.py +0 -0
  28. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/base.py +0 -0
  29. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/callback.py +0 -0
  30. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/clickhouse.py +0 -0
  31. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/datadog.py +0 -0
  32. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/elasticsearch.py +0 -0
  33. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/eventhubs.py +0 -0
  34. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/file.py +0 -0
  35. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/filtering.py +0 -0
  36. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/firehose.py +0 -0
  37. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/honeycomb.py +0 -0
  38. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/http.py +0 -0
  39. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/kafka.py +0 -0
  40. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/logging_sink.py +0 -0
  41. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/logstash.py +0 -0
  42. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/loki.py +0 -0
  43. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/memory.py +0 -0
  44. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/mongodb.py +0 -0
  45. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/multi.py +0 -0
  46. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/nats.py +0 -0
  47. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/newrelic.py +0 -0
  48. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/null.py +0 -0
  49. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/postgres.py +0 -0
  50. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/pubsub.py +0 -0
  51. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/rabbitmq.py +0 -0
  52. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/redis.py +0 -0
  53. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/sentry.py +0 -0
  54. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/sns.py +0 -0
  55. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/splunk.py +0 -0
  56. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/sqlite.py +0 -0
  57. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/sqs.py +0 -0
  58. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/stdout.py +0 -0
  59. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/syslog.py +0 -0
  60. {log_foundry-0.10.2.dev125 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/transform.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: log-foundry
3
- Version: 0.10.2.dev125
3
+ Version: 0.10.2.dev127
4
4
  Summary: Generate logs for your console and JSON events for downstream consumption.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -357,9 +357,14 @@ def handler(): ...
357
357
  def process(): ...
358
358
  ```
359
359
 
360
- - `name` — override the span name (defaults to the function's `__qualname__`).
360
+ - `name` — override the span name (defaults to the function's `__qualname__`; a callable with
361
+ none — a `functools.partial`, a callable instance — is named after its type).
361
362
  - `defaults` — per-decorator fields merged into every event this span emits.
362
363
 
364
+ `@trace` refuses at decoration what it cannot trace, with a `TypeError` naming the function:
365
+ a `@classmethod` or `@staticmethod` written *below* it (put them above), a bare string
366
+ (`@trace("checkout")` meant `@trace(name="checkout")`), and anything not callable.
367
+
363
368
  The **outermost** decorated call starts a new trace; every nested decorated call becomes a
364
369
  child span within it. On an exception, the decorator records `status="error"` plus an `error`
365
370
  sub-document carrying the exception's type, module, message and formatted stack, then
@@ -415,7 +420,9 @@ def process_payment(user_id: int) -> str:
415
420
  [`reset_context()`](#clearing-context-in-a-long-lived-process).
416
421
  - **`echo=True`** — *additionally* write a human-readable `LEVEL message` line to the console
417
422
  (`sys.stderr` by default), synchronously, without waiting for the async flush. The event
418
- still rides the normal pipeline to the sink — echo never redirects.
423
+ still rides the normal pipeline to the sink — echo never redirects. A console that has gone
424
+ away for good (a broken pipe, a closed stream) is announced once and echo is switched off for
425
+ the rest of the process; any other console fault is announced on a throttle.
419
426
  - **`fields={...}`** — the escape hatch. `message`, `echo` and `fields` are **reserved**: they
420
427
  are parameters, so `info("x", echo="the payload we echoed back")` would switch on the console
421
428
  line instead of recording a field. Pass them — and any key that is not a Python identifier —
@@ -306,9 +306,14 @@ def handler(): ...
306
306
  def process(): ...
307
307
  ```
308
308
 
309
- - `name` — override the span name (defaults to the function's `__qualname__`).
309
+ - `name` — override the span name (defaults to the function's `__qualname__`; a callable with
310
+ none — a `functools.partial`, a callable instance — is named after its type).
310
311
  - `defaults` — per-decorator fields merged into every event this span emits.
311
312
 
313
+ `@trace` refuses at decoration what it cannot trace, with a `TypeError` naming the function:
314
+ a `@classmethod` or `@staticmethod` written *below* it (put them above), a bare string
315
+ (`@trace("checkout")` meant `@trace(name="checkout")`), and anything not callable.
316
+
312
317
  The **outermost** decorated call starts a new trace; every nested decorated call becomes a
313
318
  child span within it. On an exception, the decorator records `status="error"` plus an `error`
314
319
  sub-document carrying the exception's type, module, message and formatted stack, then
@@ -364,7 +369,9 @@ def process_payment(user_id: int) -> str:
364
369
  [`reset_context()`](#clearing-context-in-a-long-lived-process).
365
370
  - **`echo=True`** — *additionally* write a human-readable `LEVEL message` line to the console
366
371
  (`sys.stderr` by default), synchronously, without waiting for the async flush. The event
367
- still rides the normal pipeline to the sink — echo never redirects.
372
+ still rides the normal pipeline to the sink — echo never redirects. A console that has gone
373
+ away for good (a broken pipe, a closed stream) is announced once and echo is switched off for
374
+ the rest of the process; any other console fault is announced on a throttle.
368
375
  - **`fields={...}`** — the escape hatch. `message`, `echo` and `fields` are **reserved**: they
369
376
  are parameters, so `info("x", echo="the payload we echoed back")` would switch on the console
370
377
  line instead of recording a field. Pass them — and any key that is not a Python identifier —
@@ -74,7 +74,7 @@ keywords = [
74
74
  # vulnerability-reporting channel. The repository is still named `log-forge` — the ORIGINAL name,
75
75
  # which PyPI rejected for the distribution — so these URLs deliberately do not match the package
76
76
  # name. See the note on `name` above before "correcting" them.
77
- version = "0.10.2.dev125"
77
+ version = "0.10.2.dev127"
78
78
 
79
79
  [project.urls]
80
80
  Homepage = "https://github.com/agriffi10/log-forge"
@@ -22,7 +22,17 @@ from __future__ import annotations
22
22
 
23
23
  import sys
24
24
 
25
- __all__ = ["absorbed", "errno_of", "lost", "rejected"]
25
+ __all__ = ["WARN_EVERY", "absorbed", "errno_of", "lost", "rejected"]
26
+
27
+ WARN_EVERY = 1000
28
+ """The period of every per-event throttle in the library (SPEC-017 FR-005, SPEC-055 FR-005).
29
+
30
+ A site that can fail once per event writes its line on the first failure and then on every
31
+ ``WARN_EVERY``-th, carrying the running total, because a line per failure is its own outage and
32
+ silence is how the failure survives to production. One definition, here, because the worker's
33
+ queue-full and post-shutdown sites and the console writer's stream-fault site all throttle on
34
+ the same period, and two constants stating one number disagree eventually.
35
+ """
26
36
 
27
37
  _MAX_DETAIL = 200
28
38
 
@@ -88,6 +88,40 @@ def _require_positive(name: str, value: int | None) -> None:
88
88
  raise ValueError(f"{name} must be >= 1, got {value}")
89
89
 
90
90
 
91
+ def _require_text(name: str, value: object) -> str | None:
92
+ """Rejects a stamp that is not a ``str`` or cannot encode as UTF-8 (SPEC-055 FR-001).
93
+
94
+ ``service``, ``version`` and ``env`` are copied from the config into every event without
95
+ passing through ``sanitize`` — the one route by which a string reaches an event unbounded
96
+ and uncoerced, which is deliberate on the hottest path in the library. So the check is at
97
+ the door instead (invariant 13): a lone surrogate here would otherwise cost every batch on
98
+ every sink that binds ``service`` as a column, for the life of the process, and an ``int``
99
+ would land in a ``str`` field. What is stored is ``str.__str__(value)``, an exact ``str``,
100
+ so a ``StrEnum`` member is accepted and a ``str`` subclass instance never reaches an event.
101
+
102
+ Args:
103
+ name: The setting's name, used in the error message.
104
+ value: The proposed stamp, or ``None`` to skip the check.
105
+
106
+ Returns:
107
+ The stamp as an exact ``str``, or ``None`` when it was not supplied.
108
+
109
+ Raises:
110
+ TypeError: If the value is present and not a ``str``.
111
+ ValueError: If the value is present and does not encode as UTF-8.
112
+ """
113
+ if value is None:
114
+ return None
115
+ if not isinstance(value, str):
116
+ raise TypeError(f"{name} must be a str, got {type(value).__name__}")
117
+ plain = str.__str__(value)
118
+ try:
119
+ plain.encode("utf-8")
120
+ except UnicodeEncodeError:
121
+ raise ValueError(f"{name} must be encodable as UTF-8; it carries a lone surrogate") from None
122
+ return plain
123
+
124
+
91
125
  def configure(
92
126
  *,
93
127
  service: str | None = None,
@@ -104,8 +138,9 @@ def configure(
104
138
 
105
139
  Only the arguments passed are applied, so repeated calls compose rather than reset. If
106
140
  no sink has ever been set, this defaults to :class:`~log_foundry.sinks.stdout.StdoutSink`,
107
- the zero-dependency dev default (arch §8). Every ceiling is validated before anything is
108
- assigned, so a rejected call leaves the config exactly as it found it.
141
+ the zero-dependency dev default (arch §8). Every ceiling and every stamp is validated before
142
+ anything is assigned — and before the sink is stamped into the ownership record — so a
143
+ rejected call leaves the config and the record exactly as it found them.
109
144
 
110
145
  A ``sink=`` passed after logging has already started is the one argument that needs more
111
146
  than an assignment, because whatever is delivering captured its sink before the call
@@ -161,12 +196,16 @@ def configure(
161
196
  None.
162
197
 
163
198
  Raises:
164
- ValueError: If any ceiling is less than 1.
199
+ ValueError: If any ceiling is less than 1, or a stamp does not encode as UTF-8.
200
+ TypeError: If ``service``, ``version`` or ``env`` is not a ``str`` (SPEC-055 FR-001).
165
201
  """
166
202
  _require_positive("max_value_bytes", max_value_bytes)
167
203
  _require_positive("max_stack_bytes", max_stack_bytes)
168
204
  _require_positive("max_keys", max_keys)
169
205
  _require_positive("max_depth", max_depth)
206
+ service = _require_text("service", service)
207
+ version = _require_text("version", version)
208
+ env = _require_text("env", env)
170
209
 
171
210
  changed: dict[str, object] = {
172
211
  name: value
@@ -0,0 +1,131 @@
1
+ """Console echo — a synchronous, human-readable output path (SPEC-002 FR-002)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ import threading
7
+ from typing import TextIO
8
+
9
+ from log_foundry import _diag
10
+
11
+ __all__ = ["ConsoleWriter"]
12
+
13
+
14
+ class ConsoleWriter:
15
+ """Renders events as human-readable ``LEVEL message`` lines to a stream.
16
+
17
+ This is separate from the async :class:`~log_foundry.sinks.base.Sink`: where the sink
18
+ ships structured JSON downstream, the console writer surfaces a line immediately so an
19
+ operator sees it without waiting for the async flush. It is deliberately dumb, rendering
20
+ an already-built event dict and knowing nothing about spans, and echo is additive — an
21
+ echoed event still rides the normal pipeline to the sink.
22
+
23
+ The default stream is **stderr**, not stdout (SPEC-031 FR-003, which corrected two
24
+ documents that said otherwise). It is the twelve-factor convention ``StderrSink`` already
25
+ cites — logs on stderr, the application's own output on stdout — so an echo cannot corrupt
26
+ a program whose stdout is a data stream someone pipes.
27
+
28
+ **The writer owns its stream's failures** (SPEC-055 FR-005). ``api._log`` used to absorb
29
+ them one line at a time: measured, 200,000 echoed events piped into ``head -1`` wrote
30
+ 199,970 identical stderr lines, one per event, forever. A ``BrokenPipeError``, or the
31
+ ``ValueError`` a closed file raises, is a stream that will not come back, so it is announced
32
+ once and echo is disabled for the life of the writer. Any other ``Exception`` may be
33
+ transient — ``EAGAIN`` on a non-blocking terminal, ``ENOSPC`` on a file — so the writer keeps
34
+ trying and announces the first failure and then every :data:`_diag.WARN_EVERY`-th with the
35
+ running total, the queue-full site's idiom. The state lives on the writer rather than the
36
+ module so a ``ConsoleWriter(stream=…)`` built for a test starts clean, and the counter is
37
+ taken under a lock on the failure path only, since ``_log`` reaches the process-global
38
+ writer from arbitrary application threads; the disable flag is a set-only latch read
39
+ unlocked, as ``Worker.submit`` reads ``_shutdown_done``.
40
+ """
41
+
42
+ def __init__(self, stream: TextIO | None = None) -> None:
43
+ """Binds the writer to an output stream, once, at construction.
44
+
45
+ The binding is deliberate and permanent for the life of the writer: a later
46
+ ``contextlib.redirect_stderr`` or a test's capture of ``sys.stderr`` is not honoured,
47
+ because the attribute was resolved here. ``api._console`` is built at import, so in
48
+ practice a process's echo stream is fixed before any test runs. Passing ``stream=``
49
+ explicitly is how a caller — a test above all — captures the output (SPEC-031 FR-003).
50
+
51
+ Args:
52
+ stream: The stream to write to, defaulting to ``sys.stderr`` as resolved now.
53
+
54
+ Returns:
55
+ None.
56
+
57
+ Raises:
58
+ None.
59
+ """
60
+ self._stream = stream if stream is not None else sys.stderr
61
+ self._disabled = False
62
+ self._failures = 0
63
+ self._lock = threading.Lock()
64
+
65
+ def write(self, event: dict[str, object]) -> None:
66
+ """Writes one ``{level:<7} {message}`` line and flushes immediately.
67
+
68
+ A stream fault is absorbed here rather than propagated: a permanent one disables the
69
+ writer after one announcement, a transient one is counted and announced on the throttle
70
+ (see the class docstring). The ``KeyError`` for an event missing ``level`` or
71
+ ``message`` still escapes, because that is a library defect rather than a stream fault,
72
+ and ``api._log``'s outer guard is the total guard for it.
73
+
74
+ Args:
75
+ event: An already-built event carrying ``level`` and ``message``.
76
+
77
+ Returns:
78
+ None.
79
+
80
+ Raises:
81
+ KeyError: If the event is missing ``level`` or ``message``.
82
+ """
83
+ if self._disabled:
84
+ return
85
+ level = str(event["level"])
86
+ message = str(event["message"])
87
+ try:
88
+ self._stream.write(f"{level:<7} {message}\n")
89
+ self._stream.flush()
90
+ except Exception as exc:
91
+ if _is_permanent(exc):
92
+ self._disabled = True
93
+ _diag.absorbed(
94
+ "echoing to the console",
95
+ exc,
96
+ "echo is disabled for the life of this writer; events still reach the sink",
97
+ )
98
+ return
99
+ with self._lock:
100
+ self._failures += 1
101
+ total = self._failures
102
+ if total == 1 or total % _diag.WARN_EVERY == 0:
103
+ _diag.absorbed(
104
+ "echoing to the console",
105
+ exc,
106
+ f"{total} echo(es) failed; count is cumulative",
107
+ )
108
+
109
+
110
+ def _is_permanent(exc: Exception) -> bool:
111
+ """Decides whether a stream fault means the stream will not come back.
112
+
113
+ A ``BrokenPipeError`` is a reader that has gone, and the ``ValueError`` a closed file raises
114
+ is a stream nothing will reopen. ``UnicodeEncodeError`` is *also* a ``ValueError`` and is
115
+ neither: it is one message a strict-ASCII console could not render, and the next plain
116
+ message would write fine — measured, a bare ``except ValueError`` latched echo off for the
117
+ rest of the process on the first ``"café"``. So the ``UnicodeError`` family is excluded and
118
+ takes the throttle with every other transient fault.
119
+
120
+ Args:
121
+ exc: The exception the stream raised.
122
+
123
+ Returns:
124
+ Whether to disable echo for the life of the writer.
125
+
126
+ Raises:
127
+ None.
128
+ """
129
+ if isinstance(exc, BrokenPipeError):
130
+ return True
131
+ return isinstance(exc, ValueError) and not isinstance(exc, UnicodeError)
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import asyncio
6
6
  import functools
7
+ import inspect
7
8
  import threading
8
9
  from collections.abc import Callable
9
10
  from time import monotonic
@@ -628,6 +629,94 @@ def _close_span(span: Span, status: str, exc: BaseException | None) -> None:
628
629
  _flush(span)
629
630
 
630
631
 
632
+ def _refuse_unusable(fn: object) -> None:
633
+ """Refuses, at decoration, a callable ``@trace`` could not trace (SPEC-055 FR-002).
634
+
635
+ Invariant 13: a bad argument is refused where it is written, never on the first call. A
636
+ ``classmethod`` or ``staticmethod`` object is a decorator applied in the wrong order —
637
+ ``classmethod`` is not callable, so the wrapper would fail every call, and ``staticmethod``
638
+ *is* callable but the wrapper replaces the descriptor, so an instance call would hand
639
+ ``self`` to a function declared without one. A ``str`` is the slip ``@trace("checkout")``,
640
+ which meant ``name=``. Anything else that is not callable is refused by type.
641
+
642
+ Args:
643
+ fn: Whatever was handed to the decorator.
644
+
645
+ Returns:
646
+ None.
647
+
648
+ Raises:
649
+ TypeError: If ``fn`` is a ``classmethod`` or ``staticmethod`` object, a ``str``, or not
650
+ callable at all.
651
+ Exception: Whatever the callable's own attribute access raises — a ``__class__`` property,
652
+ say — at decoration, in the caller's frame, which is invariant 13's moment.
653
+ """
654
+ if isinstance(fn, (classmethod, staticmethod)):
655
+ kind = type(fn).__name__
656
+ inner = getattr(fn.__func__, "__qualname__", "the function")
657
+ raise TypeError(
658
+ f"@trace cannot wrap the {kind} object around {inner}; put @{kind} above @trace, "
659
+ f"so that @trace decorates the plain function"
660
+ )
661
+ if isinstance(fn, str):
662
+ raise TypeError(
663
+ f"@trace needs a callable, got str; pass a span name as @trace(name={fn!r})"
664
+ )
665
+ if not callable(fn):
666
+ raise TypeError(f"@trace needs a callable, got {type(fn).__name__}")
667
+
668
+
669
+ def _span_name(fn: object) -> str:
670
+ """Resolves the span name once, at decoration, from any callable (SPEC-055 FR-002).
671
+
672
+ ``fn.__qualname__`` where the callable has one, else its type's name — so a
673
+ ``functools.partial`` traces as ``partial`` and a callable instance as its class, where
674
+ reading the attribute per call inside the wrapper raised ``AttributeError`` into the
675
+ application on every call. A ``__qualname__`` that exists but is not a ``str`` needs no
676
+ clause: ``functools.wraps`` copies it onto the wrapper and Python refuses a non-``str``
677
+ there before any wrapper exists.
678
+
679
+ Args:
680
+ fn: The callable being decorated.
681
+
682
+ Returns:
683
+ The span name.
684
+
685
+ Raises:
686
+ Exception: Whatever the callable's own ``__qualname__`` lookup raises other than
687
+ ``AttributeError``, at decoration, in the caller's frame.
688
+ """
689
+ qualname = getattr(fn, "__qualname__", None)
690
+ return qualname if isinstance(qualname, str) else type(fn).__name__
691
+
692
+
693
+ def _is_async(fn: object) -> bool:
694
+ """Decides the async wrapper, consulting a callable instance's ``__call__`` (SPEC-055 FR-002).
695
+
696
+ ``asyncio.iscoroutinefunction`` sees through a ``functools.partial`` but reads a callable
697
+ instance whose ``__call__`` is ``async def`` as synchronous, so accepting instances at all
698
+ (FR-002) would have handed one the sync wrapper and closed the span before the coroutine
699
+ ran — the generator finding's shape again. The type's ``__call__`` is asked too, for
700
+ anything that is not a plain function; a ``partial``'s or a class's is a slot wrapper and
701
+ reads as synchronous, which is correct.
702
+
703
+ Args:
704
+ fn: The callable being decorated.
705
+
706
+ Returns:
707
+ Whether the async wrapper applies.
708
+
709
+ Raises:
710
+ Exception: Whatever the callable's own ``__class__`` or ``__call__`` lookup raises, at
711
+ decoration, in the caller's frame.
712
+ """
713
+ if asyncio.iscoroutinefunction(fn):
714
+ return True
715
+ if inspect.isfunction(fn):
716
+ return False
717
+ return asyncio.iscoroutinefunction(type(fn).__call__)
718
+
719
+
631
720
  @overload
632
721
  def trace(func: F) -> F: ...
633
722
  @overload
@@ -646,7 +735,9 @@ def trace(
646
735
 
647
736
  Args:
648
737
  func: The function being decorated when used bare, otherwise ``None``.
649
- name: Overrides the span name, which defaults to ``func.__qualname__``.
738
+ name: Overrides the span name, which defaults to ``func.__qualname__`` where the callable
739
+ has one and to its type's name otherwise — ``partial`` for a ``functools.partial``, the
740
+ class name for a callable instance (SPEC-055 FR-002).
650
741
  defaults: Per-decorator default fields added to every event on the span. Any
651
742
  ``Mapping`` is accepted, and a copy is taken **once here**, at decoration — not per
652
743
  call. Before SPEC-051 FR-002 the caller's own object was bound to every span and read
@@ -657,18 +748,21 @@ def trace(
657
748
  The wrapped function, or a decorator when called with arguments.
658
749
 
659
750
  Raises:
660
- None.
751
+ TypeError: At decoration, for a ``classmethod`` or ``staticmethod`` object (the decorators
752
+ are in the wrong order), a ``str`` (``name=`` was meant), anything not callable, or a
753
+ ``name=`` that is not a ``str`` (SPEC-055 FR-002, invariant 13).
661
754
  """
662
755
  span_defaults = None if defaults is None else dict(defaults)
663
756
 
664
757
  def decorate(fn: F) -> F:
665
758
  """Wraps one function, selecting the sync or async wrapper at decoration time.
666
759
 
667
- :func:`asyncio.iscoroutinefunction` makes the choice once. The two wrappers are
760
+ :func:`_is_async` makes the choice once. The two wrappers are
668
761
  deliberate near-duplicates — the only difference is ``await fn(...)`` — because the
669
762
  sync/async split is a hard boundary, and ``contextvars`` already propagates the span
670
763
  stack and baggage across ``await`` points and concurrent tasks (arch §5), so the async
671
- span opens when the coroutine actually runs and closes when it finishes.
764
+ span opens when the coroutine actually runs and closes when it finishes. The span name
765
+ is resolved here, once, rather than read from ``fn`` on every call (SPEC-055 FR-002).
672
766
 
673
767
  Args:
674
768
  fn: The function to wrap.
@@ -677,9 +771,13 @@ def trace(
677
771
  The wrapper, typed as the original function.
678
772
 
679
773
  Raises:
680
- None.
774
+ TypeError: From :func:`_refuse_unusable`, at decoration.
681
775
  """
682
- if asyncio.iscoroutinefunction(fn):
776
+ _refuse_unusable(fn)
777
+ if name is not None and not isinstance(name, str):
778
+ raise TypeError(f"@trace name= must be a str, got {type(name).__name__}")
779
+ span_name = name or _span_name(fn)
780
+ if _is_async(fn):
683
781
 
684
782
  @functools.wraps(fn)
685
783
  async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
@@ -704,7 +802,7 @@ def trace(
704
802
  Raises:
705
803
  BaseException: Whatever the wrapped coroutine raises, unchanged.
706
804
  """
707
- span, token, scope = _begin(name or fn.__qualname__, span_defaults)
805
+ span, token, scope = _begin(span_name, span_defaults)
708
806
  status, error = "ok", None
709
807
  try:
710
808
  result = await fn(*args, **kwargs)
@@ -740,7 +838,7 @@ def trace(
740
838
  Raises:
741
839
  BaseException: Whatever the wrapped function raises, unchanged.
742
840
  """
743
- span, token, scope = _begin(name or fn.__qualname__, span_defaults)
841
+ span, token, scope = _begin(span_name, span_defaults)
744
842
  status, error = "ok", None
745
843
  try:
746
844
  result = fn(*args, **kwargs)
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import re
5
6
  import sys
6
7
  from collections.abc import Iterable, Mapping, Sequence
7
8
  from datetime import date, datetime, time
@@ -40,6 +41,27 @@ The same reasoning as :data:`_INT_LT` beside it, and as ``_placeholder``'s refus
40
41
  ``repr()``: what the library writes about a value must be computed by the library.
41
42
  """
42
43
 
44
+ _STR_STR = str.__str__
45
+ """``str.__str__``, unbound, so a ``str`` subclass cannot divert the measurement (SPEC-055 FR-001).
46
+
47
+ On an exact ``str`` it is the identity, so the hot path pays nothing; on a subclass it returns a
48
+ plain copy without consulting the subclass's own ``__str__`` or ``encode``. Measured before it:
49
+ ``info(BadEncode("x"))``, where ``encode`` raises, cost the event — absorbed and announced, but
50
+ lost — because ``truncate_str`` called the value's own ``encode`` to measure it. The same
51
+ reasoning as :data:`_INT_LT` and :data:`_FLOAT_REPR`: what the library measures about a value
52
+ must be computed by the library.
53
+ """
54
+
55
+ _SURROGATES = re.compile("[\ud800-\udfff]")
56
+ _REPLACEMENT = "\ufffd"
57
+ """One U+FFFD per lone surrogate, which neither built-in error handler gives (SPEC-055 FR-001).
58
+
59
+ ``errors="replace"`` on an *encode* writes ``?`` and loses the byte; a ``surrogatepass`` encode
60
+ followed by a ``replace`` decode writes three U+FFFD for one surrogate, because the decoder
61
+ rejects each of the three bytes separately. The substitution runs only after a strict encode has
62
+ already failed, so the common case never reaches it.
63
+ """
64
+
43
65
  _PLAIN_SCALARS: frozenset[type] = frozenset({int, bool})
44
66
 
45
67
  _TEXTLIKE: tuple[type, ...] = (str, bytes, bytearray, memoryview)
@@ -69,25 +91,38 @@ def _int_digit_ceiling(max_value_bytes: int) -> int:
69
91
  return max_value_bytes if limit <= 0 else min(max_value_bytes, limit)
70
92
 
71
93
 
72
- def _measured(value: str) -> bytes:
73
- """Returns the UTF-8 bytes of a string, tolerating lone surrogates.
94
+ def _measured(value: str) -> tuple[str, bytes, bool]:
95
+ """Returns a string as an exact ``str`` that encodes strictly, its bytes, and whether it changed.
96
+
97
+ A string carrying an unpaired surrogate — anything that went through ``surrogateescape``,
98
+ such as ``os.fsdecode`` of an undecodable filename — raises ``UnicodeEncodeError`` on a bare
99
+ ``.encode("utf-8")``. Before SPEC-055 this tolerated that with ``errors="replace"`` to
100
+ *measure* the string and then returned the caller's string unchanged, so the surrogate left
101
+ assembly intact: the JSON sinks escaped it, and every sink that hands a raw ``str`` to a
102
+ driver — ``SQLiteSink``'s ``function`` column above all — raised on it and cost the whole
103
+ batch its innocent neighbours. Invariant 8 says every string in the event encodes as UTF-8,
104
+ so the replaced string is what is returned now, and the flag reports the substitution.
74
105
 
75
- A string carrying an unpaired surrogate — anything that went through
76
- ``surrogateescape``, such as ``os.fsdecode`` of an undecodable filename — raises
77
- ``UnicodeEncodeError`` on a bare ``.encode("utf-8")``, inside a module contracted never to
78
- raise. ``errors="replace"`` is the same tolerance ``context`` applies when measuring an
79
- inbound baggage header.
106
+ The strict encode is tried first, because it is the common case and the C-level fast path;
107
+ the regex runs only after it has failed. The value is first passed through
108
+ :data:`_STR_STR`, so what is measured, and what is returned, is always an exact ``str``.
80
109
 
81
110
  Args:
82
111
  value: The string to measure.
83
112
 
84
113
  Returns:
85
- The encoded bytes.
114
+ The exact ``str`` as measured, its strict UTF-8 bytes, and ``True`` when a surrogate was
115
+ replaced.
86
116
 
87
117
  Raises:
88
118
  None.
89
119
  """
90
- return value.encode("utf-8", errors="replace")
120
+ plain = _STR_STR(value)
121
+ try:
122
+ return plain, plain.encode("utf-8"), False
123
+ except UnicodeEncodeError:
124
+ clean = _SURROGATES.sub(_REPLACEMENT, plain)
125
+ return clean, clean.encode("utf-8"), True
91
126
 
92
127
 
93
128
  def truncate_str(value: str, max_bytes: int) -> tuple[str, bool]:
@@ -99,19 +134,25 @@ def truncate_str(value: str, max_bytes: int) -> tuple[str, bool]:
99
134
  mid-sequence; where there is no room for anything but the marker, a marker alone is still
100
135
  the honest answer.
101
136
 
137
+ The result is an exact ``str`` that encodes as UTF-8 strictly, never the caller's own object
138
+ (SPEC-055 FR-001): a lone surrogate is replaced by U+FFFD before the measurement, and the
139
+ flag is therefore "the string was altered" rather than only "the ceiling fired" — a
140
+ substitution nobody can see is a silent change to the data, which is the rule
141
+ :meth:`_Coercer.real` already applies to a non-finite float.
142
+
102
143
  Args:
103
144
  value: The string to clip.
104
145
  max_bytes: The ceiling, in UTF-8 bytes.
105
146
 
106
147
  Returns:
107
- The clipped string and whether the ceiling fired.
148
+ The clipped string and whether it was altered — clipped, or a surrogate replaced.
108
149
 
109
150
  Raises:
110
151
  None.
111
152
  """
112
- raw = _measured(value)
153
+ plain, raw, replaced = _measured(value)
113
154
  if len(raw) <= max_bytes:
114
- return value, False
155
+ return plain, replaced
115
156
  budget = max_bytes - _MARKER_BYTES
116
157
  if budget <= 0:
117
158
  return TRUNCATION_MARKER, True
@@ -125,21 +166,22 @@ def truncate_tail(value: str, max_bytes: int) -> tuple[str, bool]:
125
166
  message and the innermost frames last, so the head of an over-long traceback is the least
126
167
  useful part of it. The zero-budget guard is not merely defensive — ``raw[-budget:]`` would
127
168
  return the whole string at a budget of zero, and ``max_stack_bytes`` may legally be smaller
128
- than the marker.
169
+ than the marker. The result is an exact, strictly encodable ``str`` on the same terms as
170
+ :func:`truncate_str`.
129
171
 
130
172
  Args:
131
173
  value: The string to clip.
132
174
  max_bytes: The ceiling, in UTF-8 bytes.
133
175
 
134
176
  Returns:
135
- The clipped string and whether the ceiling fired.
177
+ The clipped string and whether it was altered — clipped, or a surrogate replaced.
136
178
 
137
179
  Raises:
138
180
  None.
139
181
  """
140
- raw = _measured(value)
182
+ plain, raw, replaced = _measured(value)
141
183
  if len(raw) <= max_bytes:
142
- return value, False
184
+ return plain, replaced
143
185
  budget = max_bytes - _MARKER_BYTES
144
186
  if budget <= 0:
145
187
  return TRUNCATION_MARKER, True
@@ -269,9 +311,9 @@ class _Coercer:
269
311
  if isinstance(value, Decimal):
270
312
  return self.text(str(value))
271
313
  if isinstance(value, (bytes, bytearray, memoryview)):
272
- return self.text(bytes(value).decode("utf-8", errors="replace"))
314
+ return self.text(self._decoded(bytes(value)))
273
315
  if isinstance(value, str):
274
- return self.text(str(value))
316
+ return self.text(value)
275
317
  if isinstance(value, Mapping):
276
318
  return self.mapping(value, depth)
277
319
  if isinstance(value, (set, frozenset)):
@@ -280,6 +322,30 @@ class _Coercer:
280
322
  return self.members(value, depth)
281
323
  return self._placeholder(value)
282
324
 
325
+ def _decoded(self, value: bytes) -> str:
326
+ """Decodes bytes as UTF-8, replacing what does not decode and recording that it did.
327
+
328
+ Strict first, and only on ``UnicodeDecodeError`` with ``errors="replace"`` (SPEC-055
329
+ FR-001): before this the replacement was unconditional and silent, so the same
330
+ undecodable byte was marked when it arrived as a ``str`` and unmarked when it arrived as
331
+ ``bytes``. The rule is the one every other substitution here follows — a change nobody
332
+ can see is a change that sets ``truncated``.
333
+
334
+ Args:
335
+ value: The bytes to decode.
336
+
337
+ Returns:
338
+ The decoded text, with U+FFFD where a byte sequence was not UTF-8.
339
+
340
+ Raises:
341
+ None.
342
+ """
343
+ try:
344
+ return value.decode("utf-8")
345
+ except UnicodeDecodeError:
346
+ self.truncated = True
347
+ return value.decode("utf-8", errors="replace")
348
+
283
349
  def mapping(self, value: Mapping[Any, object], depth: int) -> object:
284
350
  """Coerces a mapping, capping it at ``max_keys`` and guarding against a cycle.
285
351
 
@@ -341,12 +407,46 @@ class _Coercer:
341
407
  self._parents.pop()
342
408
 
343
409
  def key(self, key: object) -> str:
344
- """Coerces a mapping key to a bounded string, since JSON object keys are always strings.
410
+ """Coerces a mapping key to a bounded string, and is total (SPEC-055 FR-004).
411
+
412
+ Whatever the key's own ``__str__``, ``bit_length`` or ``__lt__`` raises is caught here
413
+ rather than in :meth:`value`, whose guard sits around the *enclosing* mapping: measured,
414
+ ``{"sib": 1, KeyBoom(): 2}`` reached the sink as ``<unserializable: dict>`` with
415
+ ``truncated`` unset, so one hostile key took every sibling with it — the exact outcome
416
+ the integer branch below was written to prevent for one kind of key. The key becomes a
417
+ ``<unserializable key: T>`` placeholder naming its type, on :meth:`_placeholder`'s
418
+ no-``repr`` rule, and ``truncated`` is set: a value placeholder is visible on its own,
419
+ but a key that could not be rendered is a substitution the reader must be told about.
420
+
421
+ Two hostile keys of one type collide on one placeholder and the later wins, which is
422
+ accepted: the alternative is a placeholder carrying something of the key's identity,
423
+ which is what arch §6 forbids, and the collision is still marked because each replaced
424
+ key sets ``truncated``. The guard is here rather than in :meth:`mapping`'s loop because
425
+ the loop's other two failures — the ``max_keys`` cap and a value that cannot be coerced
426
+ — already have owners, and a third ``try`` around the loop would hide which one fired.
427
+
428
+ Args:
429
+ key: The mapping key, of any type.
430
+
431
+ Returns:
432
+ The bounded string form of the key, or the key placeholder.
433
+
434
+ Raises:
435
+ None.
436
+ """
437
+ try:
438
+ return self._key(key)
439
+ except Exception:
440
+ self.truncated = True
441
+ return self.text(f"<unserializable key: {_type_name(key)}>")
442
+
443
+ def _key(self, key: object) -> str:
444
+ """Renders one mapping key, since JSON object keys are always strings.
345
445
 
346
446
  An integer key goes through :meth:`integer` first. A bare ``str()`` here would raise on
347
447
  an over-long one — the very ``ValueError`` this module exists to keep away from a sink
348
- — and the failure would be caught up in :meth:`value`, replacing the whole mapping with
349
- a placeholder, so one hostile key would take every sibling with it, unmarked. ``bool``
448
+ — and the failure would be caught up in :meth:`key`, replacing the key with a
449
+ placeholder where its digits would have done. ``bool``
350
450
  is excluded because ``True`` must render as the key ``"True"``, not ``"1"``.
351
451
 
352
452
  A float key goes through :meth:`real` for the same reason, and its ``text()`` wrap is
@@ -362,7 +462,7 @@ class _Coercer:
362
462
  The bounded string form of the key.
363
463
 
364
464
  Raises:
365
- Exception: Whatever the key's own ``__str__`` raises; :meth:`value` is the guard.
465
+ Exception: Whatever the key's own ``__str__`` raises; :meth:`key` is the guard.
366
466
  """
367
467
  if isinstance(key, str):
368
468
  return self.text(key)
@@ -458,7 +558,9 @@ class _Coercer:
458
558
  A type name rather than ``repr(value)`` on purpose: arch §6 refuses to auto-capture
459
559
  argument and return values so the library cannot leak secrets or PII, and ``repr()`` of
460
560
  an arbitrary object routinely prints attribute values — a credential held on a client
461
- object would land in the log.
561
+ object would land in the log. The placeholder goes through :meth:`text` like any other
562
+ string (SPEC-055 FR-004): ``type.__name__`` is writable, so a type named with more than
563
+ ``max_value_bytes`` would otherwise be the one string in the event no ceiling reached.
462
564
 
463
565
  Args:
464
566
  value: The value that could not be coerced.
@@ -469,11 +571,28 @@ class _Coercer:
469
571
  Raises:
470
572
  None. A pathological metaclass is still not this module's problem.
471
573
  """
472
- try:
473
- name = type(value).__name__
474
- except Exception:
475
- name = "?"
476
- return f"<unserializable: {name}>"
574
+ return self.text(f"<unserializable: {_type_name(value)}>")
575
+
576
+
577
+ def _type_name(value: object) -> str:
578
+ """Returns a value's type name, or ``"?"`` when even that cannot be read.
579
+
580
+ Shared by both placeholders so the no-``repr`` rule has one implementation. A metaclass
581
+ whose ``__name__`` is a raising property is the case the fallback exists for.
582
+
583
+ Args:
584
+ value: The value whose type is named.
585
+
586
+ Returns:
587
+ The type's ``__name__``, or ``"?"``.
588
+
589
+ Raises:
590
+ None.
591
+ """
592
+ try:
593
+ return str(type(value).__name__)
594
+ except Exception:
595
+ return "?"
477
596
 
478
597
 
479
598
  def coerce(value: object, *, cfg: Config) -> object:
@@ -288,11 +288,13 @@ MAX_PARTITION_KEY_BYTES = 256
288
288
  def _partition_key(raw: str) -> str:
289
289
  """Bounds a partition key to the service's 256 **bytes**, never 256 characters.
290
290
 
291
- Both encodes carry an ``errors=`` and both are load-bearing. ``sanitize.coerce`` passes a lone
292
- surrogate through unchanged, so a bare ``encode("utf-8")`` on a caller's ``trace_id`` raises
293
- ``UnicodeEncodeError`` — a raw exception out of ``emit``, which is the failure SPEC-048 exists
294
- to remove rather than introduce. ``errors="ignore"`` on the decode drops a character the byte
295
- cut landed inside, so the result always decodes cleanly.
291
+ Both encodes carry an ``errors=`` and both are load-bearing. Assembly replaces a lone
292
+ surrogate since SPEC-055 FR-001, so an event field cannot carry one — but this key is derived
293
+ from whatever field the caller named, on a batch a ``TransformSink`` or a caller's own
294
+ ``Sink`` may have rewritten after assembly, and a bare ``encode("utf-8")`` on a surrogate
295
+ raises ``UnicodeEncodeError`` — a raw exception out of ``emit``, which is the failure SPEC-048
296
+ exists to remove rather than introduce. ``errors="ignore"`` on the decode drops a character
297
+ the byte cut landed inside, so the result always decodes cleanly.
296
298
 
297
299
  ``sanitize.truncate_str`` is the inventoried byte-bounded clipper and is deliberately **not**
298
300
  reused: it appends a truncation marker, and a marker inside a partition key changes the shard
@@ -27,7 +27,6 @@ module at module scope. Re-exported rather than relocated outright so that
27
27
  SPEC-034 froze — keeps naming the same object.
28
28
  """
29
29
 
30
- _DROP_WARN_EVERY = 1000
31
30
 
32
31
  _PUT_POLL_SECONDS = 0.05
33
32
  """How long a full-queue ``flush()`` waits before re-asking whether the drain was abandoned.
@@ -567,7 +566,7 @@ class Worker:
567
566
  with self._lock:
568
567
  self.dropped += 1
569
568
  total = self.dropped
570
- if total == 1 or total % _DROP_WARN_EVERY == 0:
569
+ if total == 1 or total % _diag.WARN_EVERY == 0:
571
570
  _diag.lost("submission", total, "log queue full; count is cumulative")
572
571
  return
573
572
  if not retired and self._shutdown_done:
@@ -594,7 +593,7 @@ class Worker:
594
593
  with self._lock:
595
594
  self.submitted_after_shutdown += 1
596
595
  total = self.submitted_after_shutdown
597
- if total == 1 or total % _DROP_WARN_EVERY == 0:
596
+ if total == 1 or total % _diag.WARN_EVERY == 0:
598
597
  _diag.lost(
599
598
  "submission",
600
599
  total,
@@ -1,61 +0,0 @@
1
- """Console echo — a synchronous, human-readable output path (SPEC-002 FR-002)."""
2
-
3
- from __future__ import annotations
4
-
5
- import sys
6
- from typing import TextIO
7
-
8
- __all__ = ["ConsoleWriter"]
9
-
10
-
11
- class ConsoleWriter:
12
- """Renders events as human-readable ``LEVEL message`` lines to a stream.
13
-
14
- This is separate from the async :class:`~log_foundry.sinks.base.Sink`: where the sink
15
- ships structured JSON downstream, the console writer surfaces a line immediately so an
16
- operator sees it without waiting for the async flush. It is deliberately dumb, rendering
17
- an already-built event dict and knowing nothing about spans, and echo is additive — an
18
- echoed event still rides the normal pipeline to the sink.
19
-
20
- The default stream is **stderr**, not stdout (SPEC-031 FR-003, which corrected two
21
- documents that said otherwise). It is the twelve-factor convention ``StderrSink`` already
22
- cites — logs on stderr, the application's own output on stdout — so an echo cannot corrupt
23
- a program whose stdout is a data stream someone pipes.
24
- """
25
-
26
- def __init__(self, stream: TextIO | None = None) -> None:
27
- """Binds the writer to an output stream, once, at construction.
28
-
29
- The binding is deliberate and permanent for the life of the writer: a later
30
- ``contextlib.redirect_stderr`` or a test's capture of ``sys.stderr`` is not honoured,
31
- because the attribute was resolved here. ``api._console`` is built at import, so in
32
- practice a process's echo stream is fixed before any test runs. Passing ``stream=``
33
- explicitly is how a caller — a test above all — captures the output (SPEC-031 FR-003).
34
-
35
- Args:
36
- stream: The stream to write to, defaulting to ``sys.stderr`` as resolved now.
37
-
38
- Returns:
39
- None.
40
-
41
- Raises:
42
- None.
43
- """
44
- self._stream = stream if stream is not None else sys.stderr
45
-
46
- def write(self, event: dict[str, object]) -> None:
47
- """Writes one ``{level:<7} {message}`` line and flushes immediately.
48
-
49
- Args:
50
- event: An already-built event carrying ``level`` and ``message``.
51
-
52
- Returns:
53
- None.
54
-
55
- Raises:
56
- KeyError: If the event is missing ``level`` or ``message``.
57
- """
58
- level = str(event["level"])
59
- message = str(event["message"])
60
- self._stream.write(f"{level:<7} {message}\n")
61
- self._stream.flush()