log-foundry 0.5.1.dev11__tar.gz → 0.6.1.dev1__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 (53) hide show
  1. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/PKG-INFO +1 -1
  2. log_foundry-0.6.1.dev1/pyproject.toml +162 -0
  3. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/context.py +9 -6
  4. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/decorator.py +5 -5
  5. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/ids.py +6 -6
  6. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/model.py +5 -5
  7. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sanitize.py +3 -3
  8. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/_chunk.py +4 -2
  9. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/_time.py +1 -1
  10. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/callback.py +4 -1
  11. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/file.py +1 -3
  12. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/filtering.py +5 -2
  13. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/firehose.py +3 -1
  14. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/http.py +8 -2
  15. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/kinesis.py +6 -1
  16. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/logging_sink.py +1 -1
  17. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/logstash.py +3 -1
  18. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/multi.py +3 -1
  19. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/redis.py +1 -1
  20. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/sentry.py +3 -1
  21. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/transform.py +5 -2
  22. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/util.py +1 -1
  23. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/worker.py +5 -4
  24. log_foundry-0.5.1.dev11/pyproject.toml +0 -99
  25. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/LICENSE +0 -0
  26. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/README.md +0 -0
  27. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/__init__.py +13 -13
  28. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/api.py +3 -3
  29. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/config.py +0 -0
  30. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/console.py +0 -0
  31. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/py.typed +0 -0
  32. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/__init__.py +0 -0
  33. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/_socket.py +0 -0
  34. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/base.py +0 -0
  35. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/clickhouse.py +0 -0
  36. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/datadog.py +0 -0
  37. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/elasticsearch.py +0 -0
  38. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/eventhubs.py +0 -0
  39. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/honeycomb.py +0 -0
  40. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/kafka.py +0 -0
  41. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/loki.py +0 -0
  42. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/mongodb.py +0 -0
  43. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/nats.py +0 -0
  44. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/newrelic.py +0 -0
  45. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/postgres.py +0 -0
  46. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/pubsub.py +0 -0
  47. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/rabbitmq.py +0 -0
  48. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/sns.py +0 -0
  49. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/splunk.py +0 -0
  50. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/sqlite.py +0 -0
  51. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/sqs.py +0 -0
  52. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/stdout.py +0 -0
  53. {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/syslog.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: log-foundry
3
- Version: 0.5.1.dev11
3
+ Version: 0.6.1.dev1
4
4
  Summary: Generate logs for your console and JSON events for downstream consumption.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -0,0 +1,162 @@
1
+ [project]
2
+ # Distribution name on PyPI; the import name matches it (`log_foundry`, see src/).
3
+ # The project was originally "log-forge", which PyPI rejects as too similar to the unrelated,
4
+ # pre-existing "logforge" project — its similarity check collapses separators.
5
+ name = "log-foundry"
6
+ # The version is derived from Git tags at build time (poetry-dynamic-versioning).
7
+ # Do not add a literal `version` key back here.
8
+ dynamic = []
9
+ description = "Generate logs for your console and JSON events for downstream consumption."
10
+ authors = [
11
+ { name = "Andrew Griffith" }
12
+ ]
13
+ license = "MIT"
14
+ readme = "README.md"
15
+ # 3.12 is the floor because that is the newest runtime a large class of deployed AWS Lambda
16
+ # fleets is on, and nothing in the library needs 3.13 (SPEC-013 FR-001). CI runs the full gate
17
+ # on 3.12 *and* 3.13 — the declaration follows the evidence.
18
+ requires-python = ">=3.12"
19
+ dependencies = [
20
+ ]
21
+
22
+ # Optional features. Install with: pip install log-foundry[aws]
23
+ version = "0.6.1.dev1"
24
+
25
+ [project.optional-dependencies]
26
+ aws = ["boto3>=1.34"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
27
+ sentry = ["sentry-sdk>=2.0"]
28
+ kafka = ["confluent-kafka>=2.0"]
29
+ redis = ["redis>=5.0"]
30
+ amqp = ["pika>=1.3"]
31
+ nats = ["nats-py>=2.6"]
32
+ gcp-pubsub = ["google-cloud-pubsub>=2.18"]
33
+ azure-eventhubs = ["azure-eventhub>=5.11"]
34
+ # clickhouse-connect caps at Python <3.15; marker scopes it so the unbounded project range resolves.
35
+ clickhouse = ["clickhouse-connect>=0.7; python_version < '3.15'"]
36
+ mongo = ["pymongo>=4.6"]
37
+ postgres = ["psycopg[binary]>=3.1"]
38
+
39
+ [tool.poetry]
40
+ # Poetry still requires this field to exist; it is only a placeholder.
41
+ # The real value is injected from Git tags during the build.
42
+
43
+ # src layout: map the package from the src/ directory
44
+ packages = [
45
+ { include = "log_foundry", from = "src" },
46
+ ]
47
+
48
+ # Non-Python files to include in the package
49
+ include = [
50
+ "src/log_foundry/py.typed", # PEP 561 marker for typed packages
51
+ ]
52
+
53
+ # Files to exclude from the package
54
+ exclude = [
55
+ "tests/**/*",
56
+ "docs/**/*",
57
+ ]
58
+
59
+ # Development-only dependencies (not shipped to consumers).
60
+ # Install with: poetry install --with dev
61
+ [tool.poetry.group.dev.dependencies]
62
+ pytest = "^9.0"
63
+ pytest-asyncio = "^1.4.0" # for testing async @trace (guide Phase 8)
64
+ pytest-cov = "^7.1.0"
65
+ # Bounded to a minor, not left open. Ruff's DEFAULT rule set is not stable across minors —
66
+ # 0.16.0 widened it from 59 rules to 413 — so an open constraint lets a release change what
67
+ # CI enforces. `[tool.ruff.lint] select` below is the other half: the bound makes the change
68
+ # deliberate, the select makes the set ours.
69
+ ruff = ">=0.16,<0.17"
70
+ mypy = "^1.10"
71
+
72
+
73
+ [tool.poetry-dynamic-versioning]
74
+ enable = false
75
+ vcs = "git"
76
+ style = "pep440"
77
+ bump = true # development builds sort after the last release (e.g. 0.0.2.devN)
78
+ metadata = false # drop the +<hash> local segment so PyPI accepts the upload
79
+
80
+
81
+ [build-system]
82
+ requires = ["poetry-core>=2.0.0,<3.0.0", "poetry-dynamic-versioning>=1.4.0,<2.0.0"]
83
+ build-backend = "poetry_dynamic_versioning.backend"
84
+
85
+
86
+ [tool.pytest.ini_options]
87
+ testpaths = ["tests"]
88
+ pythonpath = ["src"] # src layout: make `import log_foundry` work without an install
89
+ asyncio_mode = "auto" # async test functions run without an explicit marker
90
+ addopts = "--strict-markers"
91
+
92
+ [tool.ruff]
93
+ src = ["src", "tests"]
94
+ line-length = 100
95
+ # Deliberately no `target-version`: ruff infers it from `requires-python`, so the two can never
96
+ # drift. If one is ever added it must match the floor above.
97
+
98
+ [tool.ruff.lint]
99
+ # THE RULE SET IS CHOSEN HERE, NOT INHERITED. Ruff's default set is not a stable contract:
100
+ # 0.16.0 widened it from 59 rules to 413. Selecting explicitly means a ruff release can add
101
+ # rules *inside* a family we asked for, but cannot decide which families CI enforces. The
102
+ # `>=0.16,<0.17` bound on the dev dependency is the other half of that.
103
+ select = [
104
+ "F", # pyflakes
105
+ "E4", "E7", "E9", # pycodestyle errors. NOT E5: `line-length` above is advisory
106
+ # today (it feeds `ruff format`, which this repo does not run) and
107
+ # 52 lines in src+tests exceed it. Enforcing it is 52 reflows and
108
+ # its own decision.
109
+ "W",
110
+ "I", # import sorting
111
+ "B", # bugbear
112
+ "UP", # pyupgrade — the floor is 3.12, keep the syntax current
113
+ "SIM", "C4", "PIE", "RET", "FURB", "PERF",
114
+ "RUF",
115
+ "S", # bandit
116
+ "BLE", "TRY", # exception hygiene — the decorator's contract is about exceptions
117
+ "LOG", "G",
118
+ "DTZ", # naive datetimes: this library stamps every event's timestamp
119
+ "ASYNC", # @trace is async-aware (SPEC-003)
120
+ "PYI", "YTT", "FLY", "INT", "TC", "FA", "PGH", "EXE", "ISC",
121
+ ]
122
+
123
+ # Each entry is a decision with its reason, not a backlog.
124
+ ignore = [
125
+ "BLE001", # THE central one. `except Exception` in a sink is the architecture, not a
126
+ # slip: a broken destination must degrade logging and nothing more, and the
127
+ # decorator must never raise into the caller (arch §4). Eleven of these, all
128
+ # in sinks/ and worker.py, all load-bearing.
129
+ "TRY003", # messages are written at the raise site; a class per message would add names
130
+ # nothing reads.
131
+ "TRY300", # "return in else" reads worse than the early return this code uses.
132
+ "TRY400", # this library IS the logger; it writes to stderr deliberately, and
133
+ # logger.exception is not available to it.
134
+ "S110", # try/except/pass is the shape of "logging must never break the caller"
135
+ # (arch §4). Every occurrence carries a written reason.
136
+ "SIM105", # same sites as S110: contextlib.suppress would hide the written reason
137
+ # behind an import.
138
+ "PERF203", # try/except inside a loop is deliberate in the worker's drain: one bad batch
139
+ # must not abort the rest.
140
+ "S608", # postgres.py and sqlite.py build INSERTs around a table name they validate
141
+ # themselves; the values are always parameterised.
142
+ "UP047", # PEP 695 `def f[T](...)`. Valid on the 3.12 floor, but rewriting the public
143
+ # `@trace` decorator's generics is a change to the signature users read, and
144
+ # the TypeVar form is not deprecated. Its own PR if ever.
145
+ "RUF002", "RUF003", # ambiguous-unicode in docstrings/comments: this codebase writes
146
+ # –, ×, → and — on purpose.
147
+ ]
148
+
149
+ [tool.ruff.lint.per-file-ignores]
150
+ # Asserts are the test framework; the rest are fixtures that only look like secrets, naive
151
+ # datetimes under test on purpose, and raises inside a `try` that IS the thing under test.
152
+ "tests/**/*.py" = ["S101", "S106", "DTZ001", "TRY301"]
153
+ # FileSink and RotatingFileSink hold a handle open ACROSS calls by design — that is what makes
154
+ # them a sink rather than a per-event open(). A context manager would close it after one emit.
155
+ "src/log_foundry/sinks/file.py" = ["SIM115"]
156
+
157
+ [tool.mypy]
158
+ files = ["src"]
159
+ # Must be the *lowest* supported version, not the newest: mypy checks against this, so a 3.13-only
160
+ # API would type-check clean on a 3.12 floor and fail as an AttributeError in a consumer's runtime.
161
+ python_version = "3.12"
162
+ strict = true
@@ -24,21 +24,24 @@ if TYPE_CHECKING:
24
24
  from log_foundry.model import Span
25
25
 
26
26
  __all__ = [
27
+ "current_baggage_header",
27
28
  "current_span",
28
- "push_span",
29
- "pop_span",
29
+ "current_trace_context",
30
+ "current_traceparent",
30
31
  "get_baggage",
32
+ "pop_span",
33
+ "push_span",
31
34
  "set_baggage",
32
- "current_traceparent",
33
- "current_trace_context",
34
- "current_baggage_header",
35
35
  ]
36
36
 
37
37
  _span_stack: contextvars.ContextVar[tuple[Span, ...]] = contextvars.ContextVar(
38
38
  "log_foundry_span_stack", default=()
39
39
  )
40
40
  _baggage: contextvars.ContextVar[dict[str, object]] = contextvars.ContextVar(
41
- "log_foundry_baggage", default={}
41
+ "log_foundry_baggage",
42
+ default={}, # noqa: B039 - the never-mutate rule in this module's docstring is what
43
+ # makes the shared default safe: `set_baggage` replaces the dict, `get_baggage` is not
44
+ # exported, and both internal readers treat it read-only.
42
45
  )
43
46
  # An inbound trace context adopted via ``continue_trace`` (SPEC-014), applied by ``_open_span``
44
47
  # to the next *root* span. A ContextVar for the same reason as the two above: it is then correct
@@ -42,7 +42,7 @@ from log_foundry.ids import (
42
42
  from log_foundry.model import Span, backfill_baggage, end_event, start_event
43
43
  from log_foundry.worker import Health, Worker
44
44
 
45
- __all__ = ["trace", "continue_trace"]
45
+ __all__ = ["continue_trace", "trace"]
46
46
 
47
47
  # Bound on how much of a rejected inbound value is echoed into a stderr warning.
48
48
  _MAX_REJECTED_ECHO = 64
@@ -70,7 +70,7 @@ def _open_span(name: str, defaults: dict[str, object] | None) -> Span:
70
70
  trace_id, parent_span_id = parent.trace_id, parent.span_id
71
71
  else:
72
72
  adopted = context.get_adopted_context()
73
- trace_id, parent_span_id = adopted if adopted else (new_trace_id(), None)
73
+ trace_id, parent_span_id = adopted or (new_trace_id(), None)
74
74
  span = Span(
75
75
  trace_id=trace_id,
76
76
  span_id=new_span_id(),
@@ -229,7 +229,7 @@ def _flush_worker(timeout: float | None = 5.0) -> bool:
229
229
  return True
230
230
  try:
231
231
  return worker.flush(timeout)
232
- except Exception: # noqa: BLE001 — a flush is the call most likely to be made in a
232
+ except Exception: # a flush is the call most likely to be made in a
233
233
  # `finally`, so the library must never be the reason a caller's function fails. Any
234
234
  # failure is reported by the return value instead (FR-003).
235
235
  return False
@@ -311,7 +311,7 @@ def trace(
311
311
  finally:
312
312
  context.pop_span(token)
313
313
 
314
- return cast(F, async_wrapper)
314
+ return cast("F", async_wrapper)
315
315
 
316
316
  @functools.wraps(fn)
317
317
  def wrapper(*args: Any, **kwargs: Any) -> Any:
@@ -328,6 +328,6 @@ def trace(
328
328
  finally:
329
329
  context.pop_span(token)
330
330
 
331
- return cast(F, wrapper)
331
+ return cast("F", wrapper)
332
332
 
333
333
  return decorate(func) if func is not None else decorate
@@ -17,13 +17,13 @@ import os
17
17
  import uuid
18
18
 
19
19
  __all__ = [
20
- "new_trace_id",
21
- "new_span_id",
22
- "new_log_id",
23
- "parse_traceparent",
24
20
  "format_traceparent",
25
- "is_valid_trace_id",
26
21
  "is_valid_span_id",
22
+ "is_valid_trace_id",
23
+ "new_log_id",
24
+ "new_span_id",
25
+ "new_trace_id",
26
+ "parse_traceparent",
27
27
  ]
28
28
 
29
29
  _HEX = frozenset("0123456789abcdef")
@@ -91,7 +91,7 @@ def parse_traceparent(value: object) -> tuple[str, str] | None:
91
91
  version, trace_id, span_id, flags = parts[0], parts[1], parts[2], parts[3]
92
92
  if not _is_hex(version, 2) or version == _INVALID_VERSION:
93
93
  return None
94
- if version == _VERSION:
94
+ if version == _VERSION: # noqa: SIM102 - kept nested so the comment below stays attached
95
95
  # Version 00 is defined as exactly four fields; a trailing field is malformed, not a
96
96
  # forward-compatible extension.
97
97
  if len(parts) != 4 or not _is_hex(flags, 2):
@@ -11,7 +11,7 @@ from __future__ import annotations
11
11
  import time
12
12
  import traceback
13
13
  from dataclasses import dataclass, field
14
- from datetime import datetime, timezone
14
+ from datetime import UTC, datetime
15
15
  from typing import TYPE_CHECKING
16
16
 
17
17
  from log_foundry.sanitize import sanitize_fields, truncate_str, truncate_tail
@@ -19,7 +19,7 @@ from log_foundry.sanitize import sanitize_fields, truncate_str, truncate_tail
19
19
  if TYPE_CHECKING:
20
20
  from log_foundry.config import Config
21
21
 
22
- __all__ = ["Span", "build_event", "start_event", "end_event", "backfill_baggage"]
22
+ __all__ = ["Span", "backfill_baggage", "build_event", "end_event", "start_event"]
23
23
 
24
24
  # Auto-generated span-boundary event messages. Tests assert on contract fields
25
25
  # (``status``, ``trace_id``), never on this text — rename freely.
@@ -52,7 +52,7 @@ class Span:
52
52
 
53
53
  def _iso_now() -> str:
54
54
  """Return the current UTC time as ISO-8601 with millisecond precision and a 'Z'."""
55
- now = datetime.now(timezone.utc)
55
+ now = datetime.now(UTC)
56
56
  return f"{now:%Y-%m-%dT%H:%M:%S}.{now.microsecond // 1000:03d}Z"
57
57
 
58
58
 
@@ -121,7 +121,7 @@ def _exception_message(exc: BaseException) -> str:
121
121
  """
122
122
  try:
123
123
  return str(exc)
124
- except Exception: # noqa: BLE001 — see above; a hostile __str__ must not escape.
124
+ except Exception: # see above; a hostile __str__ must not escape.
125
125
  return "<unprintable message>"
126
126
 
127
127
 
@@ -134,7 +134,7 @@ def _exception_stack(exc: BaseException) -> str:
134
134
  """
135
135
  try:
136
136
  return "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
137
- except Exception: # noqa: BLE001 — same reasoning as _exception_message.
137
+ except Exception: # same reasoning as _exception_message.
138
138
  return f"<unformattable traceback: {type(exc).__name__}>"
139
139
 
140
140
 
@@ -137,7 +137,7 @@ class _Coercer:
137
137
  """Coerce one node. Total — never raises, whatever the value does."""
138
138
  try:
139
139
  return self._dispatch(value, depth)
140
- except Exception: # noqa: BLE001 — a hostile __iter__/__str__/__eq__ must not reach the
140
+ except Exception: # a hostile __iter__/__str__/__eq__ must not reach the
141
141
  return self._placeholder(value) # caller's stack; this is the whole point (FR-001).
142
142
 
143
143
  def _dispatch(self, value: object, depth: int) -> object:
@@ -246,7 +246,7 @@ class _Coercer:
246
246
  """Name the type that could not be coerced, without disclosing the value."""
247
247
  try:
248
248
  name = type(value).__name__
249
- except Exception: # noqa: BLE001 — a pathological metaclass is still not our problem.
249
+ except Exception: # a pathological metaclass is still not our problem.
250
250
  name = "?"
251
251
  return f"<unserializable: {name}>"
252
252
 
@@ -277,7 +277,7 @@ def sanitize_fields(
277
277
  # would replace every field value with ``<depth limit>`` and emit a uniformly empty event,
278
278
  # which is exactly what FR-006's validation exists to prevent.
279
279
  result = coercer.mapping(fields, -1)
280
- except Exception: # noqa: BLE001 — belt and braces: `_Coercer.value` is already total, but
280
+ except Exception: # belt and braces: `_Coercer.value` is already total, but
281
281
  return {}, True # a hostile top-level mapping must not reach the caller either.
282
282
  if not isinstance(result, dict):
283
283
  # ``fields`` was itself circular — impossible from ``build_event``, which always passes a
@@ -9,8 +9,10 @@ single request (callers drop oversized items first, counting ``dropped_oversized
9
9
  from __future__ import annotations
10
10
 
11
11
  import re
12
- from collections.abc import Callable, Iterator
13
- from typing import TypeVar
12
+ from typing import TYPE_CHECKING, TypeVar
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Callable, Iterator
14
16
 
15
17
  __all__ = ["chunk_items", "chunk_list", "valid_identifier"]
16
18
 
@@ -10,7 +10,7 @@ from __future__ import annotations
10
10
  import time
11
11
  from datetime import datetime
12
12
 
13
- __all__ = ["epoch_seconds", "epoch_nanos"]
13
+ __all__ = ["epoch_nanos", "epoch_seconds"]
14
14
 
15
15
 
16
16
  def epoch_seconds(timestamp: object) -> float:
@@ -7,7 +7,10 @@ and knows nothing about spans or context — that dumbness is what makes sinks s
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
- from collections.abc import Callable
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Callable
11
14
 
12
15
  __all__ = ["CallbackSink"]
13
16
 
@@ -147,9 +147,7 @@ class RotatingFileSink:
147
147
  and self._size + incoming > self._max_bytes
148
148
  ):
149
149
  return True
150
- if self._next_rollover is not None and time.time() >= self._next_rollover:
151
- return True
152
- return False
150
+ return self._next_rollover is not None and time.time() >= self._next_rollover
153
151
 
154
152
  def _rotate(self) -> None:
155
153
  """Close the active file, shift/prune numbered backups, and open a fresh active file."""
@@ -8,9 +8,12 @@ time; this only reshapes an already-built batch on its way to a sink.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
- from collections.abc import Callable
11
+ from typing import TYPE_CHECKING
12
12
 
13
- from log_foundry.sinks.base import Sink
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Callable
15
+
16
+ from log_foundry.sinks.base import Sink
14
17
 
15
18
  __all__ = ["FilteringSink"]
16
19
 
@@ -77,7 +77,9 @@ class FirehoseSink:
77
77
  results = response.get("RequestResponses", [])
78
78
  records = [
79
79
  record
80
- for record, result in zip(records, results)
80
+ # strict=False states today's behaviour. A short `results` would silently
81
+ # truncate this retry list — see the note in kinesis.py.
82
+ for record, result in zip(records, results, strict=False)
81
83
  if result.get("ErrorCode")
82
84
  ]
83
85
  if not records:
@@ -20,7 +20,10 @@ import time
20
20
  import urllib.error
21
21
  import urllib.request
22
22
  from base64 import b64encode
23
- from typing import Any, Callable
23
+ from typing import TYPE_CHECKING, Any
24
+
25
+ if TYPE_CHECKING:
26
+ from collections.abc import Callable
24
27
 
25
28
  __all__ = ["HTTPSink", "merge_headers"]
26
29
 
@@ -109,7 +112,10 @@ class HTTPSink:
109
112
  """
110
113
  headers, data = self._prepare(body, content_type, extra_headers)
111
114
  for attempt in range(self.max_retries + 1):
112
- request = urllib.request.Request(
115
+ # Opening `self.url` is this class's entire purpose. The URL is the application's
116
+ # own configured endpoint, never inbound data — an app that can set it can already
117
+ # read its own files.
118
+ request = urllib.request.Request( # noqa: S310
113
119
  self.url, data=data, method=self.method, headers=headers
114
120
  )
115
121
  try:
@@ -85,7 +85,12 @@ class KinesisSink:
85
85
  results = response.get("Records", [])
86
86
  records = [
87
87
  record
88
- for record, result in zip(records, results)
88
+ # strict=False states today's behaviour rather than changing it. Note the
89
+ # latent case it preserves: if `results` came back shorter than `records`
90
+ # (or empty, via the `.get` default above), zip truncates, `records` empties
91
+ # and the batch reports success — the silent-loss shape SPEC-017 went after.
92
+ # Making that raise is a behaviour change, so it belongs in its own spec.
93
+ for record, result in zip(records, results, strict=False)
89
94
  if result.get("ErrorCode")
90
95
  ]
91
96
  if not records:
@@ -96,7 +96,7 @@ class LoggingSink:
96
96
  fields = event.get("fields")
97
97
  if isinstance(fields, dict):
98
98
  # Nested payload is lossless even when a flat key collides and is skipped below.
99
- setattr(record, "fields", fields)
99
+ record.fields = fields
100
100
  for key, value in fields.items():
101
101
  # Skip reserved LogRecord attrs, identity keys, and "fields": the sink owns
102
102
  # record.fields (the nested payload); a field named "fields" must not overwrite it.
@@ -54,7 +54,9 @@ class LogstashSink:
54
54
  if self._http is not None:
55
55
  self._http.emit(batch)
56
56
  else:
57
- assert self._socket is not None
57
+ # Narrowing for mypy, not a runtime check: __init__ guarantees exactly one of
58
+ # _http/_socket is set, and the branch above covers the other.
59
+ assert self._socket is not None # noqa: S101
58
60
  frames = [(json.dumps(event) + "\n").encode("utf-8") for event in batch]
59
61
  self._socket.send_all(frames)
60
62
 
@@ -17,8 +17,10 @@ has nothing to retry.
17
17
  from __future__ import annotations
18
18
 
19
19
  import sys
20
+ from typing import TYPE_CHECKING
20
21
 
21
- from log_foundry.sinks.base import Sink
22
+ if TYPE_CHECKING:
23
+ from log_foundry.sinks.base import Sink
22
24
 
23
25
  __all__ = ["MultiSink"]
24
26
 
@@ -15,7 +15,7 @@ import sys
15
15
  import time
16
16
  from typing import Any
17
17
 
18
- __all__ = ["RedisStreamsSink", "RedisListSink"]
18
+ __all__ = ["RedisListSink", "RedisStreamsSink"]
19
19
 
20
20
  _BACKOFF_BASE = 0.1
21
21
 
@@ -102,7 +102,9 @@ class SentrySink:
102
102
  }
103
103
 
104
104
  def _post_envelope(self, event: dict[str, object]) -> None:
105
- assert self._http is not None
105
+ # Narrowing for mypy, not a runtime check: _http is set in __init__ whenever the
106
+ # transport path that reaches here is in use.
107
+ assert self._http is not None # noqa: S101
106
108
  header = {"event_id": uuid.uuid4().hex, "dsn": self._dsn}
107
109
  item_header = {"type": "event"}
108
110
  body = (
@@ -9,9 +9,12 @@ example).
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
- from collections.abc import Callable
12
+ from typing import TYPE_CHECKING
13
13
 
14
- from log_foundry.sinks.base import Sink
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Callable
16
+
17
+ from log_foundry.sinks.base import Sink
15
18
 
16
19
  __all__ = ["TransformSink"]
17
20
 
@@ -13,7 +13,7 @@ from typing import TextIO
13
13
 
14
14
  from log_foundry.sinks.stdout import StdoutSink
15
15
 
16
- __all__ = ["StderrSink", "NullSink", "MemorySink"]
16
+ __all__ = ["MemorySink", "NullSink", "StderrSink"]
17
17
 
18
18
 
19
19
  class StderrSink(StdoutSink):
@@ -23,9 +23,10 @@ import queue
23
23
  import sys
24
24
  import threading
25
25
  import time
26
- from typing import NamedTuple, cast
26
+ from typing import TYPE_CHECKING, NamedTuple, cast
27
27
 
28
- from log_foundry.sinks.base import Sink
28
+ if TYPE_CHECKING:
29
+ from log_foundry.sinks.base import Sink
29
30
 
30
31
  __all__ = ["Health", "Worker"]
31
32
 
@@ -125,7 +126,7 @@ class Worker:
125
126
  sys.stderr.write(
126
127
  f"log-foundry: log queue full, dropped {total} submission(s) so far\n"
127
128
  )
128
- except Exception: # noqa: BLE001 — submit() runs on the *caller's* thread, so an
129
+ except Exception: # submit() runs on the *caller's* thread, so an
129
130
  # unwritable stderr (closed fd, broken pipe, daemonized process) would raise
130
131
  # straight into the app. A diagnostic about dropped logs must never itself be
131
132
  # the reason a decorated function fails. The counter is already recorded.
@@ -267,7 +268,7 @@ class Worker:
267
268
  try:
268
269
  self.sink.emit(batch)
269
270
  return
270
- except Exception: # noqa: BLE001 — any sink failure must not kill the worker thread
271
+ except Exception: # any sink failure must not kill the worker thread
271
272
  if attempt >= self.max_retries:
272
273
  # Under the lock so a concurrent health() sees a coherent snapshot rather
273
274
  # than a half-updated pair. No deadlock: shutdown() releases before join().
@@ -1,99 +0,0 @@
1
- [project]
2
- # Distribution name on PyPI; the import name matches it (`log_foundry`, see src/).
3
- # The project was originally "log-forge", which PyPI rejects as too similar to the unrelated,
4
- # pre-existing "logforge" project — its similarity check collapses separators.
5
- name = "log-foundry"
6
- # The version is derived from Git tags at build time (poetry-dynamic-versioning).
7
- # Do not add a literal `version` key back here.
8
- dynamic = []
9
- description = "Generate logs for your console and JSON events for downstream consumption."
10
- authors = [
11
- { name = "Andrew Griffith" }
12
- ]
13
- license = "MIT"
14
- readme = "README.md"
15
- # 3.12 is the floor because that is the newest runtime a large class of deployed AWS Lambda
16
- # fleets is on, and nothing in the library needs 3.13 (SPEC-013 FR-001). CI runs the full gate
17
- # on 3.12 *and* 3.13 — the declaration follows the evidence.
18
- requires-python = ">=3.12"
19
- dependencies = [
20
- ]
21
-
22
- # Optional features. Install with: pip install log-foundry[aws]
23
- version = "0.5.1.dev11"
24
-
25
- [project.optional-dependencies]
26
- aws = ["boto3>=1.34"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
27
- sentry = ["sentry-sdk>=2.0"]
28
- kafka = ["confluent-kafka>=2.0"]
29
- redis = ["redis>=5.0"]
30
- amqp = ["pika>=1.3"]
31
- nats = ["nats-py>=2.6"]
32
- gcp-pubsub = ["google-cloud-pubsub>=2.18"]
33
- azure-eventhubs = ["azure-eventhub>=5.11"]
34
- # clickhouse-connect caps at Python <3.15; marker scopes it so the unbounded project range resolves.
35
- clickhouse = ["clickhouse-connect>=0.7; python_version < '3.15'"]
36
- mongo = ["pymongo>=4.6"]
37
- postgres = ["psycopg[binary]>=3.1"]
38
-
39
- [tool.poetry]
40
- # Poetry still requires this field to exist; it is only a placeholder.
41
- # The real value is injected from Git tags during the build.
42
-
43
- # src layout: map the package from the src/ directory
44
- packages = [
45
- { include = "log_foundry", from = "src" },
46
- ]
47
-
48
- # Non-Python files to include in the package
49
- include = [
50
- "src/log_foundry/py.typed", # PEP 561 marker for typed packages
51
- ]
52
-
53
- # Files to exclude from the package
54
- exclude = [
55
- "tests/**/*",
56
- "docs/**/*",
57
- ]
58
-
59
- # Development-only dependencies (not shipped to consumers).
60
- # Install with: poetry install --with dev
61
- [tool.poetry.group.dev.dependencies]
62
- pytest = "^9.0"
63
- pytest-asyncio = "^1.4.0" # for testing async @trace (guide Phase 8)
64
- pytest-cov = "^7.1.0"
65
- ruff = "^0.5"
66
- mypy = "^1.10"
67
-
68
-
69
- [tool.poetry-dynamic-versioning]
70
- enable = false
71
- vcs = "git"
72
- style = "pep440"
73
- bump = true # development builds sort after the last release (e.g. 0.0.2.devN)
74
- metadata = false # drop the +<hash> local segment so PyPI accepts the upload
75
-
76
-
77
- [build-system]
78
- requires = ["poetry-core>=2.0.0,<3.0.0", "poetry-dynamic-versioning>=1.4.0,<2.0.0"]
79
- build-backend = "poetry_dynamic_versioning.backend"
80
-
81
-
82
- [tool.pytest.ini_options]
83
- testpaths = ["tests"]
84
- pythonpath = ["src"] # src layout: make `import log_foundry` work without an install
85
- asyncio_mode = "auto" # async test functions run without an explicit marker
86
- addopts = "--strict-markers"
87
-
88
- [tool.ruff]
89
- src = ["src", "tests"]
90
- line-length = 100
91
- # Deliberately no `target-version`: ruff infers it from `requires-python`, so the two can never
92
- # drift. If one is ever added it must match the floor above.
93
-
94
- [tool.mypy]
95
- files = ["src"]
96
- # Must be the *lowest* supported version, not the newest: mypy checks against this, so a 3.13-only
97
- # API would type-check clean on a 3.12 floor and fail as an AttributeError in a consumer's runtime.
98
- python_version = "3.12"
99
- strict = true
@@ -87,22 +87,22 @@ def shutdown() -> None:
87
87
 
88
88
 
89
89
  __all__ = [
90
+ "Health",
91
+ "__version__",
90
92
  "configure",
91
- "get_config",
92
- "trace",
93
- "debug",
94
- "info",
95
- "warning",
96
- "error",
97
- "critical",
98
- "set_baggage",
99
93
  "continue_trace",
100
- "current_traceparent",
101
- "current_trace_context",
94
+ "critical",
102
95
  "current_baggage_header",
96
+ "current_trace_context",
97
+ "current_traceparent",
98
+ "debug",
99
+ "error",
103
100
  "flush",
104
- "shutdown",
101
+ "get_config",
105
102
  "health",
106
- "Health",
107
- "__version__",
103
+ "info",
104
+ "set_baggage",
105
+ "shutdown",
106
+ "trace",
107
+ "warning",
108
108
  ]
@@ -20,12 +20,12 @@ from log_foundry.ids import new_span_id, new_trace_id
20
20
  from log_foundry.model import Span, build_event
21
21
 
22
22
  __all__ = [
23
+ "critical",
23
24
  "debug",
24
- "info",
25
- "warning",
26
25
  "error",
27
- "critical",
26
+ "info",
28
27
  "set_baggage",
28
+ "warning",
29
29
  ]
30
30
 
31
31
  # One console writer per process (default stream sys.stderr). Configurable overrides are