log-foundry 0.10.2.dev52__tar.gz → 0.10.2.dev53__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 (59) hide show
  1. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/PKG-INFO +1 -1
  2. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/pyproject.toml +1 -1
  3. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/__init__.py +3 -0
  4. log_foundry-0.10.2.dev53/src/log_foundry/_fork.py +422 -0
  5. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/LICENSE +0 -0
  6. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/README.md +0 -0
  7. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/_diag.py +0 -0
  8. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/_lifecycle.py +0 -0
  9. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/api.py +0 -0
  10. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/config.py +0 -0
  11. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/console.py +0 -0
  12. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/context.py +0 -0
  13. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/decorator.py +0 -0
  14. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/ids.py +0 -0
  15. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/model.py +0 -0
  16. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/py.typed +0 -0
  17. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/results.py +0 -0
  18. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/sanitize.py +0 -0
  19. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/sinks/__init__.py +0 -0
  20. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/sinks/_batch.py +0 -0
  21. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/sinks/_chunk.py +0 -0
  22. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/sinks/_retry.py +0 -0
  23. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/sinks/_socket.py +0 -0
  24. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/sinks/_time.py +0 -0
  25. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/sinks/base.py +0 -0
  26. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/sinks/callback.py +0 -0
  27. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/sinks/clickhouse.py +0 -0
  28. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/sinks/datadog.py +0 -0
  29. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/sinks/elasticsearch.py +0 -0
  30. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/sinks/eventhubs.py +0 -0
  31. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/sinks/file.py +0 -0
  32. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/sinks/filtering.py +0 -0
  33. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/sinks/firehose.py +0 -0
  34. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/sinks/honeycomb.py +0 -0
  35. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/sinks/http.py +0 -0
  36. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/sinks/kafka.py +0 -0
  37. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/sinks/kinesis.py +0 -0
  38. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/sinks/logging_sink.py +0 -0
  39. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/sinks/logstash.py +0 -0
  40. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/sinks/loki.py +0 -0
  41. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/sinks/memory.py +0 -0
  42. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/sinks/mongodb.py +0 -0
  43. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/sinks/multi.py +0 -0
  44. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/sinks/nats.py +0 -0
  45. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/sinks/newrelic.py +0 -0
  46. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/sinks/null.py +0 -0
  47. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/sinks/postgres.py +0 -0
  48. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/sinks/pubsub.py +0 -0
  49. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/sinks/rabbitmq.py +0 -0
  50. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/sinks/redis.py +0 -0
  51. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/sinks/sentry.py +0 -0
  52. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/sinks/sns.py +0 -0
  53. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/sinks/splunk.py +0 -0
  54. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/sinks/sqlite.py +0 -0
  55. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/sinks/sqs.py +0 -0
  56. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/sinks/stdout.py +0 -0
  57. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/sinks/syslog.py +0 -0
  58. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/sinks/transform.py +0 -0
  59. {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev53}/src/log_foundry/worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: log-foundry
3
- Version: 0.10.2.dev52
3
+ Version: 0.10.2.dev53
4
4
  Summary: Generate logs for your console and JSON events for downstream consumption.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -20,7 +20,7 @@ dependencies = [
20
20
  ]
21
21
 
22
22
  # Optional features. Install with: pip install log-foundry[aws]
23
- version = "0.10.2.dev52"
23
+ version = "0.10.2.dev53"
24
24
 
25
25
  [project.optional-dependencies]
26
26
  aws = ["boto3>=1.43.61"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
@@ -3,6 +3,7 @@
3
3
  from importlib.metadata import PackageNotFoundError
4
4
  from importlib.metadata import version as _dist_version
5
5
 
6
+ from log_foundry import _fork
6
7
  from log_foundry.api import critical, debug, error, info, set_baggage, warning
7
8
  from log_foundry.config import Config, configure, get_config
8
9
  from log_foundry.context import (
@@ -22,6 +23,8 @@ try:
22
23
  except PackageNotFoundError:
23
24
  __version__ = "0.0.0"
24
25
 
26
+ _fork.install()
27
+
25
28
 
26
29
  def flush(timeout: float | None = 5.0) -> FlushResult:
27
30
  """Drains buffered events through the sink without closing it.
@@ -0,0 +1,422 @@
1
+ """Repairing the library's own synchronization state in a forked child (SPEC-039)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import collections
6
+ import os
7
+ import sys
8
+ import threading
9
+ import types
10
+ from typing import Any
11
+
12
+ from log_foundry import _diag
13
+
14
+ _PACKAGE = __name__.rpartition(".")[0]
15
+ """This package's import name, derived rather than written.
16
+
17
+ A literal is wrong under a vendored install — ``myapp._vendor.log_foundry`` — where both the
18
+ root selection and the ownership test would miss every module and the handler would silently
19
+ repair nothing.
20
+ """
21
+
22
+ _RLOCK_TYPE = type(threading.RLock())
23
+
24
+ _LOCK_TYPES: tuple[type, ...] = (type(threading.Lock()), _RLOCK_TYPE)
25
+
26
+ _CONTAINER_TYPES: tuple[type, ...] = (list, tuple, set, frozenset, dict, collections.deque)
27
+
28
+ _installed = False
29
+ """Whether :func:`install` has already registered the child handler in this process.
30
+
31
+ A flag and a function rather than a bare module-scope call, so that registration is one
32
+ statement a reader can find and repeated calls cannot stack handlers (FR-006 AC-2). What it
33
+ does **not** cover is stated rather than implied: ``importlib.reload`` re-runs this body and
34
+ resets the flag, so a deliberate reload registers a second handler. That is harmless — the
35
+ repair is idempotent and a child would simply run it twice — and closing it would mean
36
+ recording the registration somewhere outside this module, which is a worse trade than saying so.
37
+ """
38
+
39
+
40
+ def _defined_here(cls: type) -> bool:
41
+ """Whether one class was defined in this package.
42
+
43
+ Args:
44
+ cls: The class to place.
45
+
46
+ Returns:
47
+ Whether its defining module is this package or a module inside it.
48
+
49
+ Raises:
50
+ None.
51
+ """
52
+ module = getattr(cls, "__module__", "") or ""
53
+ return module == _PACKAGE or module.startswith(f"{_PACKAGE}.")
54
+
55
+
56
+ def _is_owned(value: object) -> bool:
57
+ """Whether this object runs code this package defines, anywhere in its ancestry.
58
+
59
+ The ownership test is what keeps the traversal off third-party state (FR-003 AC-2): a
60
+ ``boto3`` session's locks, a ``librdkafka`` handle and a ``psycopg`` connection are not the
61
+ library's to swap, and reaching into them would be a fork fix that breaks a driver.
62
+
63
+ **The whole MRO is asked, not just the defining module**, because subclassing a shipped sink
64
+ is a documented extension point — ``README`` offers ``Sink`` to subclass and SPEC-038 rebuilt
65
+ ``HTTPSink.emit`` as a template method for exactly that. An instance of a user's
66
+ ``class MySink(FileSink)`` reports ``__main__``, so a defining-module test walked straight
67
+ past a ``_lock`` that ``FileSink.__init__`` built: measured, the child hung in ``info()``
68
+ while a plain ``FileSink`` in the same probe returned. That is the hang this spec exists to
69
+ remove, reached through the one door users are told to use.
70
+
71
+ What that widening costs is recorded rather than left to be discovered: a class **mixing in**
72
+ a third-party base alongside a library one has its foreign attributes replaced too, measured
73
+ on ``class MySink(FileSink, ThirdPartyBase)``. A separately *held* client is still untouched,
74
+ which is the boundary FR-005 states — the two cannot be told apart from the instance, and
75
+ refusing the mixed case would mean refusing the sink's own lock with it.
76
+
77
+ Args:
78
+ value: Any object, including a class.
79
+
80
+ Returns:
81
+ Whether it or any of its ancestors was defined in this package.
82
+
83
+ Raises:
84
+ None.
85
+ """
86
+ owner = value if isinstance(value, type) else type(value)
87
+ return any(_defined_here(base) for base in getattr(owner, "__mro__", (owner,)))
88
+
89
+
90
+ def _is_container(value: object) -> bool:
91
+ """Whether the walk reads this value's members as well as its attributes.
92
+
93
+ ``isinstance`` rather than an exact-type test, so a ``deque``, a ``defaultdict`` or any
94
+ other ordinary subclass is entered: an exact tuple made membership depend on which concrete
95
+ class a future sink happened to hold its children in, which is the guess FR-003 AC-3 exists
96
+ to replace.
97
+
98
+ Args:
99
+ value: Any object.
100
+
101
+ Returns:
102
+ Whether it holds members the walk should read.
103
+
104
+ Raises:
105
+ None.
106
+ """
107
+ return isinstance(value, _CONTAINER_TYPES)
108
+
109
+
110
+ def _is_traversable(value: object) -> bool:
111
+ """Whether the walk descends into this value.
112
+
113
+ Three shapes and no others: a module of this package, an object running code this package
114
+ defines, and a plain container, which is traversed because ``MultiSink._sinks`` is one and
115
+ the sinks inside it hold the locks the child's first log call takes.
116
+
117
+ Args:
118
+ value: Any object.
119
+
120
+ Returns:
121
+ Whether it is a namespace or container the walk should enter.
122
+
123
+ Raises:
124
+ None.
125
+ """
126
+ if isinstance(value, types.ModuleType):
127
+ name = getattr(value, "__name__", "") or ""
128
+ return name == _PACKAGE or name.startswith(f"{_PACKAGE}.")
129
+ return _is_container(value) or _is_owned(value)
130
+
131
+
132
+ def _container_children(container: Any) -> list[Any]:
133
+ """Returns what a plain container holds, keys included for a mapping.
134
+
135
+ Nothing is *replaced* inside a container: a primitive there would be unreachable in a
136
+ tuple or a set, so a partial answer would read as coverage it does not have. The AST lint
137
+ for FR-003 AC-3 forbids that shape outright instead, which is what makes descent-only
138
+ correct here rather than merely convenient.
139
+
140
+ Args:
141
+ container: Any value :func:`_is_container` accepted.
142
+
143
+ Returns:
144
+ Its members, or both its keys and its values for a mapping.
145
+
146
+ Raises:
147
+ None. A container mutating under the walk would raise, and a child that cannot finish
148
+ repairing itself must still repair what it reached. A foreign container subclass
149
+ reachable from an owned attribute runs its own code here — ``keys``/``values`` for a
150
+ mapping, ``__iter__`` for anything else — which is absorbed if it raises, but a
151
+ *blocking* one is a child that never returns from ``fork`` and there is nothing to catch
152
+ that with.
153
+ """
154
+ try:
155
+ if isinstance(container, dict):
156
+ return [*container.keys(), *container.values()]
157
+ return list(container)
158
+ except Exception as exc:
159
+ _diag.absorbed("reading a container after a fork", exc, "what it holds is not repaired")
160
+ return []
161
+
162
+
163
+ def _slot_names(holder: object) -> list[str]:
164
+ """Returns every ``__slots__`` name declared across a holder's class hierarchy.
165
+
166
+ A slotted instance keeps its attributes off ``__dict__``, so a walk reading only ``vars()``
167
+ would miss them. No shipped class needs this today and that is stated rather than dressed
168
+ up: ``worker._FlushMarker`` is slotted and holds an ``Event``, but a marker lives inside a
169
+ ``queue.Queue`` the walk never enters. It is here because the shape lint accepts a slotted
170
+ ``self.<attr>``, so the walk has to be able to reach one.
171
+
172
+ Args:
173
+ holder: Any object.
174
+
175
+ Returns:
176
+ The declared slot names, which may be empty.
177
+
178
+ Raises:
179
+ None.
180
+ """
181
+ names: list[str] = []
182
+ for cls in type(holder).__mro__:
183
+ declared = cls.__dict__.get("__slots__", ())
184
+ if isinstance(declared, str):
185
+ names.append(declared)
186
+ else:
187
+ names.extend(str(name) for name in declared)
188
+ return names
189
+
190
+
191
+ def _namespace_items(holder: Any) -> list[tuple[str, Any]]:
192
+ """Returns the ``(name, value)`` pairs a holder owns, without triggering its properties.
193
+
194
+ Values come from the instance ``__dict__`` and the slot descriptors rather than from a
195
+ blanket ``getattr`` over ``dir()``, which would evaluate every property — including ones
196
+ that open a connection or take the very lock this is about to replace.
197
+
198
+ Args:
199
+ holder: A module, a class, or an instance.
200
+
201
+ Returns:
202
+ One pair per attribute the holder itself carries.
203
+
204
+ Raises:
205
+ None. An attribute that cannot be read is skipped, since a repair that stops at the first
206
+ awkward object leaves the rest of the process holding dead locks.
207
+ """
208
+ items: list[tuple[str, Any]] = []
209
+ try:
210
+ own = dict(vars(holder))
211
+ except TypeError:
212
+ own = {}
213
+ except Exception as exc:
214
+ _diag.absorbed("reading an object's attributes after a fork", exc, "it is not repaired")
215
+ return []
216
+ items.extend(own.items())
217
+ if isinstance(holder, types.ModuleType | type):
218
+ return items
219
+ for name in _slot_names(holder):
220
+ if name in own:
221
+ continue
222
+ value = _slot_value(holder, name)
223
+ if value is not None:
224
+ items.append((name, value))
225
+ return items
226
+
227
+
228
+ def _slot_value(holder: object, name: str) -> Any | None:
229
+ """Reads one slot, answering ``None`` for a slot that is unset or refuses to be read.
230
+
231
+ Both answers are deliberately the same, because both mean "there is nothing here to
232
+ replace": a lock is never ``None``, so nothing is lost by conflating them, and an object
233
+ that raises on attribute access must not end the repair for the rest of the process.
234
+
235
+ Args:
236
+ holder: The instance to read from.
237
+ name: The slot name.
238
+
239
+ Returns:
240
+ The value, or ``None``.
241
+
242
+ Raises:
243
+ None.
244
+ """
245
+ try:
246
+ return getattr(holder, name, None)
247
+ except Exception:
248
+ return None
249
+
250
+
251
+ def _assign(holder: Any, name: str, value: Any) -> None:
252
+ """Puts a fresh primitive where the dead one was.
253
+
254
+ Instances are written through ``object.__setattr__`` so a frozen dataclass or a custom
255
+ ``__setattr__`` cannot refuse the repair; modules and classes take the ordinary path,
256
+ which is the only one they have.
257
+
258
+ Args:
259
+ holder: The module, class or instance carrying the attribute.
260
+ name: The attribute to rebind.
261
+ value: The replacement primitive.
262
+
263
+ Returns:
264
+ None.
265
+
266
+ Raises:
267
+ None. A holder that refuses the write keeps a primitive no thread can ever release, which
268
+ is announced rather than raised: this runs in a child that has not yet returned from
269
+ ``fork``.
270
+ """
271
+ try:
272
+ if isinstance(holder, types.ModuleType | type):
273
+ setattr(holder, name, value)
274
+ else:
275
+ object.__setattr__(holder, name, value)
276
+ except Exception as exc:
277
+ _diag.absorbed(
278
+ "re-initialising a lock after a fork",
279
+ exc,
280
+ f"{type(holder).__name__}.{name} may block the next caller forever",
281
+ )
282
+
283
+
284
+ def _fresh_primitive(value: Any, memo: dict[int, Any], keepalive: list[Any]) -> Any | None:
285
+ """Returns the replacement for one lock or event, minting it at most once.
286
+
287
+ **The memo is load-bearing, not tidiness.** A sink's ``log_foundry_stop_signal`` *is* the
288
+ worker's ``_stop`` (SPEC-027), so two fresh events would leave the worker setting one and
289
+ the sink waiting on the other — a shutdown that never cuts a backoff short. An ``Event``
290
+ carries its set state across, which is also what makes replacing one safe at all.
291
+
292
+ Args:
293
+ value: The attribute value under inspection.
294
+ memo: Replacements already minted, keyed by the id of what they replace.
295
+ keepalive: Holds every replaced primitive, so no id in ``memo`` can be reused by a later
296
+ object and hand back the wrong replacement.
297
+
298
+ Returns:
299
+ The replacement, or ``None`` when this value is not a lock or an event.
300
+
301
+ Raises:
302
+ None.
303
+ """
304
+ existing = memo.get(id(value))
305
+ if existing is not None:
306
+ return existing
307
+ fresh: Any
308
+ if isinstance(value, threading.Event):
309
+ fresh = threading.Event()
310
+ if value.is_set():
311
+ fresh.set()
312
+ elif isinstance(value, _LOCK_TYPES):
313
+ fresh = threading.RLock() if isinstance(value, _RLOCK_TYPE) else threading.Lock()
314
+ else:
315
+ return None
316
+ memo[id(value)] = fresh
317
+ keepalive.append(value)
318
+ return fresh
319
+
320
+
321
+ def _reinit_primitives() -> None:
322
+ """Replaces every lock and event this package owns, wherever the walk reaches one.
323
+
324
+ An inherited ``Lock`` stays locked with no owner — measured, ``acquire(timeout=1)`` returns
325
+ ``False`` — so a child's first log call blocks forever on the application's own thread. A
326
+ lock that was *not* held is replaced too: asking whether one is held has no answer that is
327
+ not itself a race (FR-003 AC-6).
328
+
329
+ Being a container and being a namespace are **not** exclusive: an owned class that
330
+ subclasses one holds both members and attributes, and treating the two as alternatives
331
+ silently drops whichever branch lost. The cost is proportional to what the library's own
332
+ containers hold, which for a buffering sink is caller data — a ``MemorySink`` holding 100k
333
+ events measured 202 ms, against 0.45 ms idle. That is accepted rather than bounded: a cap
334
+ would be a lock this cannot promise to find, and the alternative to finding it is a hang.
335
+
336
+ Args:
337
+ None.
338
+
339
+ Returns:
340
+ None.
341
+
342
+ Raises:
343
+ None.
344
+ """
345
+ memo: dict[int, Any] = {}
346
+ keepalive: list[Any] = []
347
+ seen: set[int] = set()
348
+ stack: list[Any] = [
349
+ module
350
+ for name, module in list(sys.modules.items())
351
+ if module is not None and (name == _PACKAGE or name.startswith(f"{_PACKAGE}."))
352
+ ]
353
+ while stack:
354
+ holder = stack.pop()
355
+ if id(holder) in seen:
356
+ continue
357
+ seen.add(id(holder))
358
+ if _is_container(holder):
359
+ stack.extend(child for child in _container_children(holder) if _is_traversable(child))
360
+ if not isinstance(holder, types.ModuleType | type) and not _is_owned(holder):
361
+ continue
362
+ for name, value in _namespace_items(holder):
363
+ fresh = _fresh_primitive(value, memo, keepalive)
364
+ if fresh is not None:
365
+ _assign(holder, name, fresh)
366
+ elif _is_traversable(value):
367
+ stack.append(value)
368
+
369
+
370
+ def _reinit_after_fork() -> None:
371
+ """Repairs the library in a child that has just returned from ``fork``.
372
+
373
+ **The order of work here is the contract** (FR-001 AC-2): locks and events first, because
374
+ anything running afterwards may take one, and a lock re-initialised after a handler that
375
+ takes it is a handler that hangs.
376
+
377
+ Args:
378
+ None.
379
+
380
+ Returns:
381
+ None.
382
+
383
+ Raises:
384
+ None. A fork handler that raises has its exception printed by CPython with a full
385
+ traceback, carrying the message arch §6 keeps out of anything the library says about
386
+ itself — and it would leave the rest of the repair undone.
387
+ """
388
+ try:
389
+ _reinit_primitives()
390
+ except Exception as exc:
391
+ _diag.absorbed("repairing the library after a fork", exc, "this child may block or lose")
392
+
393
+
394
+ def install() -> None:
395
+ """Registers the child handler with ``os.register_at_fork``, once per process.
396
+
397
+ Called from the package's ``__init__`` so registration happens at import of the package,
398
+ and idempotent across repeated calls, which together are what make a double import register
399
+ once (FR-006 AC-2 — see :data:`_installed` for the one case that is not covered).
400
+ **Only** ``after_in_child`` is registered (FR-001 AC-1):
401
+ ``before`` does not run for a C-level fork at all — uWSGI calls ``PyOS_AfterFork_Child``
402
+ only — so the child handler has to be sufficient regardless, and a parent-side handler
403
+ would buy a partial fix for a measured 1.20 s hold on the forking thread.
404
+
405
+ A platform without ``os.register_at_fork`` — Windows — imports the package cleanly and
406
+ registers nothing, which is what the guard is for. Nothing else in the library changes
407
+ behaviour there, since only ``fork`` inherits the hazards this closes.
408
+
409
+ Args:
410
+ None.
411
+
412
+ Returns:
413
+ None.
414
+
415
+ Raises:
416
+ None.
417
+ """
418
+ global _installed
419
+ if _installed or not hasattr(os, "register_at_fork"):
420
+ return
421
+ _installed = True
422
+ os.register_at_fork(after_in_child=_reinit_after_fork)