src-py-lib 0.2.1__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. {src_py_lib-0.2.1 → src_py_lib-0.3.1}/.github/workflows/release.yml +2 -2
  2. {src_py_lib-0.2.1 → src_py_lib-0.3.1}/.github/workflows/validate.yml +10 -7
  3. {src_py_lib-0.2.1 → src_py_lib-0.3.1}/AGENTS.md +1 -0
  4. {src_py_lib-0.2.1 → src_py_lib-0.3.1}/PKG-INFO +4 -4
  5. {src_py_lib-0.2.1 → src_py_lib-0.3.1}/pyproject.toml +7 -7
  6. {src_py_lib-0.2.1 → src_py_lib-0.3.1}/src/src_py_lib/__init__.py +37 -7
  7. src_py_lib-0.3.1/src/src_py_lib/utils/events.py +211 -0
  8. {src_py_lib-0.2.1 → src_py_lib-0.3.1}/src/src_py_lib/utils/logging.py +265 -203
  9. {src_py_lib-0.2.1 → src_py_lib-0.3.1}/src/src_py_lib/utils/telemetry.py +67 -5
  10. {src_py_lib-0.2.1 → src_py_lib-0.3.1}/tests/test_import.py +12 -0
  11. {src_py_lib-0.2.1 → src_py_lib-0.3.1}/tests/test_logging_http_clients.py +836 -524
  12. {src_py_lib-0.2.1 → src_py_lib-0.3.1}/tests/test_tsv.py +2 -2
  13. src_py_lib-0.3.1/tests/test_unicode_scan.py +22 -0
  14. src_py_lib-0.3.1/tests/unicode_scan.py +65 -0
  15. {src_py_lib-0.2.1 → src_py_lib-0.3.1}/uv.lock +47 -47
  16. {src_py_lib-0.2.1 → src_py_lib-0.3.1}/.github/CODEOWNERS +0 -0
  17. {src_py_lib-0.2.1 → src_py_lib-0.3.1}/.github/workflows/ci.yml +0 -0
  18. {src_py_lib-0.2.1 → src_py_lib-0.3.1}/.gitignore +0 -0
  19. {src_py_lib-0.2.1 → src_py_lib-0.3.1}/.markdownlint-cli2.yaml +0 -0
  20. {src_py_lib-0.2.1 → src_py_lib-0.3.1}/.python-version +0 -0
  21. {src_py_lib-0.2.1 → src_py_lib-0.3.1}/LICENSE +0 -0
  22. {src_py_lib-0.2.1 → src_py_lib-0.3.1}/README.md +0 -0
  23. {src_py_lib-0.2.1 → src_py_lib-0.3.1}/SECURITY.md +0 -0
  24. {src_py_lib-0.2.1 → src_py_lib-0.3.1}/renovate.json +0 -0
  25. {src_py_lib-0.2.1 → src_py_lib-0.3.1}/src/src_py_lib/clients/__init__.py +0 -0
  26. {src_py_lib-0.2.1 → src_py_lib-0.3.1}/src/src_py_lib/clients/github.py +0 -0
  27. {src_py_lib-0.2.1 → src_py_lib-0.3.1}/src/src_py_lib/clients/google_sheets.py +0 -0
  28. {src_py_lib-0.2.1 → src_py_lib-0.3.1}/src/src_py_lib/clients/graphql.py +0 -0
  29. {src_py_lib-0.2.1 → src_py_lib-0.3.1}/src/src_py_lib/clients/linear.py +0 -0
  30. {src_py_lib-0.2.1 → src_py_lib-0.3.1}/src/src_py_lib/clients/one_password.py +0 -0
  31. {src_py_lib-0.2.1 → src_py_lib-0.3.1}/src/src_py_lib/clients/slack.py +0 -0
  32. {src_py_lib-0.2.1 → src_py_lib-0.3.1}/src/src_py_lib/clients/sourcegraph.py +0 -0
  33. {src_py_lib-0.2.1 → src_py_lib-0.3.1}/src/src_py_lib/py.typed +0 -0
  34. {src_py_lib-0.2.1 → src_py_lib-0.3.1}/src/src_py_lib/utils/__init__.py +0 -0
  35. {src_py_lib-0.2.1 → src_py_lib-0.3.1}/src/src_py_lib/utils/config.py +0 -0
  36. {src_py_lib-0.2.1 → src_py_lib-0.3.1}/src/src_py_lib/utils/http.py +0 -0
  37. {src_py_lib-0.2.1 → src_py_lib-0.3.1}/src/src_py_lib/utils/json_cache.py +0 -0
  38. {src_py_lib-0.2.1 → src_py_lib-0.3.1}/src/src_py_lib/utils/json_types.py +0 -0
  39. {src_py_lib-0.2.1 → src_py_lib-0.3.1}/src/src_py_lib/utils/tsv.py +0 -0
@@ -67,7 +67,7 @@ jobs:
67
67
 
68
68
  steps:
69
69
  - name: Check out release ref
70
- uses: actions/checkout@v6
70
+ uses: actions/checkout@v7
71
71
  with:
72
72
  fetch-depth: 0
73
73
  persist-credentials: false
@@ -79,7 +79,7 @@ jobs:
79
79
  python-version: ${{ env.PYTHON_VERSION }}
80
80
 
81
81
  - name: Cache uv
82
- uses: actions/cache@v5
82
+ uses: actions/cache@v6
83
83
  with:
84
84
  path: ~/.cache/uv
85
85
  key: uv-${{ runner.os }}-py${{ env.PYTHON_VERSION }}-${{ hashFiles('uv.lock') }}
@@ -98,7 +98,7 @@ jobs:
98
98
 
99
99
  steps:
100
100
  - name: Check out code
101
- uses: actions/checkout@v6
101
+ uses: actions/checkout@v7
102
102
  with:
103
103
  persist-credentials: false
104
104
  ref: ${{ inputs.ref || github.ref }}
@@ -130,13 +130,13 @@ jobs:
130
130
 
131
131
  steps:
132
132
  - name: Check out code
133
- uses: actions/checkout@v6
133
+ uses: actions/checkout@v7
134
134
  with:
135
135
  persist-credentials: false
136
136
  ref: ${{ inputs.ref || github.ref }}
137
137
 
138
138
  - name: Cache npm
139
- uses: actions/cache@v5
139
+ uses: actions/cache@v6
140
140
  with:
141
141
  path: ~/.npm
142
142
  key: npm-${{ runner.os }}-markdownlint-cli2-${{ env.MARKDOWNLINT_CLI2_VERSION }}
@@ -156,7 +156,7 @@ jobs:
156
156
 
157
157
  steps:
158
158
  - name: Check out code
159
- uses: actions/checkout@v6
159
+ uses: actions/checkout@v7
160
160
  with:
161
161
  fetch-depth: 0
162
162
  persist-credentials: false
@@ -168,7 +168,7 @@ jobs:
168
168
  python-version: ${{ env.PYTHON_VERSION }}
169
169
 
170
170
  - name: Cache uv
171
- uses: actions/cache@v5
171
+ uses: actions/cache@v6
172
172
  with:
173
173
  path: ~/.cache/uv
174
174
  key: uv-${{ runner.os }}-py${{ env.PYTHON_VERSION }}-${{ hashFiles('uv.lock') }}
@@ -181,6 +181,9 @@ jobs:
181
181
  - name: Validate lockfile
182
182
  run: uv lock --check
183
183
 
184
+ - name: Scan for Unicode characters
185
+ run: uv run --frozen python tests/unicode_scan.py
186
+
184
187
  - name: Lint Python
185
188
  run: uv run --frozen ruff check .
186
189
 
@@ -216,7 +219,7 @@ jobs:
216
219
 
217
220
  steps:
218
221
  - name: Check out code
219
- uses: actions/checkout@v6
222
+ uses: actions/checkout@v7
220
223
  with:
221
224
  fetch-depth: 0
222
225
  persist-credentials: false
@@ -228,7 +231,7 @@ jobs:
228
231
  python-version: ${{ env.PYTHON_VERSION }}
229
232
 
230
233
  - name: Cache uv
231
- uses: actions/cache@v5
234
+ uses: actions/cache@v6
232
235
  with:
233
236
  path: ~/.cache/uv
234
237
  key: uv-${{ runner.os }}-py${{ env.PYTHON_VERSION }}-${{ hashFiles('uv.lock') }}
@@ -17,6 +17,7 @@
17
17
  actionlint
18
18
  npx --yes markdownlint-cli2@0.22.1
19
19
  uv sync
20
+ uv run python tests/unicode_scan.py
20
21
  uv run ruff format .
21
22
  uv run ruff check .
22
23
  uv run pyright
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: src-py-lib
3
- Version: 0.2.1
3
+ Version: 0.3.1
4
4
  Summary: Reusable libraries for Sourcegraph projects
5
5
  Project-URL: Homepage, https://github.com/sourcegraph/src-py-lib
6
6
  Project-URL: Issues, https://github.com/sourcegraph/src-py-lib/issues
@@ -17,12 +17,12 @@ Classifier: Programming Language :: Python :: 3
17
17
  Classifier: Typing :: Typed
18
18
  Requires-Python: >=3.11
19
19
  Requires-Dist: httpx<1,>=0.28.1
20
- Requires-Dist: opentelemetry-api<2,>=1.42.1
20
+ Requires-Dist: opentelemetry-api<2,>=1.43.0
21
21
  Requires-Dist: pydantic<3,>=2.13.4
22
22
  Requires-Dist: python-dotenv<2,>=1.2.2
23
23
  Provides-Extra: otel
24
- Requires-Dist: opentelemetry-exporter-otlp-proto-http<2,>=1.42.1; extra == 'otel'
25
- Requires-Dist: opentelemetry-sdk<2,>=1.42.1; extra == 'otel'
24
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http<2,>=1.43.0; extra == 'otel'
25
+ Requires-Dist: opentelemetry-sdk<2,>=1.43.0; extra == 'otel'
26
26
  Description-Content-Type: text/markdown
27
27
 
28
28
  # src-py-lib
@@ -4,10 +4,10 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [dependency-groups]
6
6
  dev = [
7
- "opentelemetry-exporter-otlp-proto-http>=1.42.1,<2",
8
- "opentelemetry-sdk>=1.42.1,<2",
9
- "pyright>=1.1.410",
10
- "ruff>=0.15.16",
7
+ "opentelemetry-exporter-otlp-proto-http>=1.43.0,<2",
8
+ "opentelemetry-sdk>=1.43.0,<2",
9
+ "pyright>=1.1.411",
10
+ "ruff>=0.15.20",
11
11
  ]
12
12
 
13
13
  [project]
@@ -32,7 +32,7 @@ classifiers = [
32
32
  ]
33
33
  dependencies = [
34
34
  "httpx>=0.28.1,<1",
35
- "opentelemetry-api>=1.42.1,<2",
35
+ "opentelemetry-api>=1.43.0,<2",
36
36
  "pydantic>=2.13.4,<3",
37
37
  "python-dotenv>=1.2.2,<2",
38
38
  ]
@@ -46,8 +46,8 @@ Issues = "https://github.com/sourcegraph/src-py-lib/issues"
46
46
 
47
47
  [project.optional-dependencies]
48
48
  otel = [
49
- "opentelemetry-exporter-otlp-proto-http>=1.42.1,<2",
50
- "opentelemetry-sdk>=1.42.1,<2",
49
+ "opentelemetry-exporter-otlp-proto-http>=1.43.0,<2",
50
+ "opentelemetry-sdk>=1.43.0,<2",
51
51
  ]
52
52
 
53
53
  [tool.hatch.build.targets.wheel]
@@ -59,6 +59,15 @@ from src_py_lib.utils.config import (
59
59
  from src_py_lib.utils.config import (
60
60
  config_parse_args as parse_args,
61
61
  )
62
+ from src_py_lib.utils.events import (
63
+ CallbackEventSink,
64
+ CompositeEventSink,
65
+ EventRuntime,
66
+ EventSink,
67
+ InMemoryEventSink,
68
+ JSONLEventSink,
69
+ NullEventSink,
70
+ )
62
71
  from src_py_lib.utils.http import HTTPClient, HTTPClientError, HTTPResponse
63
72
  from src_py_lib.utils.json_cache import load_json_cache, load_json_subset, save_json_cache
64
73
  from src_py_lib.utils.json_types import (
@@ -71,17 +80,19 @@ from src_py_lib.utils.json_types import (
71
80
  json_strs,
72
81
  )
73
82
  from src_py_lib.utils.logging import (
83
+ EventBridgeHandler,
74
84
  LoggingConfig,
75
85
  LoggingSettings,
76
- configure_logging,
86
+ cli_logging_handlers,
87
+ cli_run_context,
77
88
  critical,
78
89
  debug,
79
90
  error,
80
91
  info,
81
92
  log_context,
82
93
  log_event,
83
- logging_context,
84
94
  logging_settings_from_config,
95
+ observability_context,
85
96
  resolve_log_level_name,
86
97
  span,
87
98
  stage,
@@ -94,6 +105,7 @@ from src_py_lib.utils.telemetry import (
94
105
  OpenTelemetryRuntime,
95
106
  OpenTelemetrySettings,
96
107
  OpenTelemetrySetupError,
108
+ OtelLogsSink,
97
109
  configure_open_telemetry,
98
110
  current_traceparent_header,
99
111
  open_telemetry_settings_from_config,
@@ -111,13 +123,20 @@ def logging(
111
123
  open_telemetry: OpenTelemetrySettings | None = None,
112
124
  run_fields: Mapping[str, Any] | None = None,
113
125
  run_summary: Callable[[], Mapping[str, Any]] | None = None,
126
+ resource: Mapping[str, Any] | None = None,
114
127
  ) -> AbstractContextManager[Path | None]:
115
- """Configure standard CLI logging and emit startup metadata."""
128
+ """Configure standard CLI-mode logging for one run and emit startup metadata.
129
+
130
+ CLI mode: installs terminal and event-bridge handlers on the configured
131
+ package loggers (never the root logger) and writes the JSONL event log.
132
+ Importable-module callers should use `observability_context()` instead,
133
+ which never touches stdlib logging handlers.
134
+ """
116
135
  resolved_logging_config = logging_config
117
136
  if open_telemetry is not None:
118
137
  resolved_logging_config = logging_config or logging_settings_from_config(config)
119
138
  resolved_logging_config = LoggingSettings(
120
- logger_name=resolved_logging_config.logger_name,
139
+ logger_names=resolved_logging_config.logger_names,
121
140
  terminal_level=resolved_logging_config.terminal_level,
122
141
  log_file_level=resolved_logging_config.log_file_level,
123
142
  log_file=resolved_logging_config.log_file,
@@ -130,13 +149,14 @@ def logging(
130
149
  ),
131
150
  open_telemetry=open_telemetry,
132
151
  )
133
- return logging_context(
152
+ return cli_run_context(
134
153
  command or _script_name(),
135
154
  config,
136
155
  git_cwd=git_cwd,
137
156
  logging_config=resolved_logging_config,
138
157
  run_fields=run_fields,
139
158
  run_summary=run_summary,
159
+ resource=resource,
140
160
  )
141
161
 
142
162
 
@@ -145,8 +165,13 @@ def _script_name() -> str:
145
165
 
146
166
 
147
167
  __all__ = [
168
+ "CallbackEventSink",
169
+ "CompositeEventSink",
148
170
  "Config",
149
171
  "ConfigError",
172
+ "EventBridgeHandler",
173
+ "EventRuntime",
174
+ "EventSink",
150
175
  "GraphQLError",
151
176
  "GraphQLClient",
152
177
  "GitHubClient",
@@ -155,15 +180,19 @@ __all__ = [
155
180
  "HTTPClient",
156
181
  "HTTPClientError",
157
182
  "HTTPResponse",
183
+ "InMemoryEventSink",
158
184
  "JSONDict",
185
+ "JSONLEventSink",
159
186
  "LinearClient",
160
187
  "LinearClientConfig",
161
188
  "LoggingConfig",
162
189
  "LoggingSettings",
190
+ "NullEventSink",
163
191
  "OpenTelemetryConfig",
164
192
  "OpenTelemetryRuntime",
165
193
  "OpenTelemetrySettings",
166
194
  "OpenTelemetrySetupError",
195
+ "OtelLogsSink",
167
196
  "PullRequest",
168
197
  "SlackClient",
169
198
  "SlackClientConfig",
@@ -178,9 +207,10 @@ __all__ = [
178
207
  "config_field",
179
208
  "config_field_names",
180
209
  "config_help_formatter",
210
+ "cli_logging_handlers",
211
+ "cli_run_context",
181
212
  "config_snapshot",
182
213
  "configure_open_telemetry",
183
- "configure_logging",
184
214
  "critical",
185
215
  "current_traceparent_header",
186
216
  "debug",
@@ -205,11 +235,11 @@ __all__ = [
205
235
  "load_json_cache",
206
236
  "load_json_subset",
207
237
  "logging",
208
- "logging_context",
209
238
  "logging_settings_from_config",
210
239
  "log_event",
211
240
  "log_context",
212
241
  "normalize_sourcegraph_endpoint",
242
+ "observability_context",
213
243
  "open_telemetry_settings_from_config",
214
244
  "parse_args",
215
245
  "pr_ref_from_url",
@@ -0,0 +1,211 @@
1
+ """Structured wide events with explicit sinks, decoupled from stdlib logging.
2
+
3
+ Events are plain dicts shaped after the OpenTelemetry Logs Data Model:
4
+ `time_unix_nano`, `severity_text`, `severity_number`, `event_name`, `body`,
5
+ `trace_id` / `span_id` / `parent_span_id`, and `attributes`. Run-level
6
+ metadata (`resource`) is stamped once per run on the run-start event, not
7
+ repeated on every line.
8
+
9
+ Sinks receive every event as a Mapping. The active sink is ambient run
10
+ context, set only by `observability_context()` in `logging.py`; the default
11
+ is `NullEventSink`, so importing this library never writes anywhere.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import contextlib
17
+ import json
18
+ import logging
19
+ import threading
20
+ from collections.abc import Callable, Mapping
21
+ from contextvars import ContextVar
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+ from types import TracebackType
25
+ from typing import Any, Final, Protocol, Self, cast
26
+
27
+ # OpenTelemetry Logs Data Model field names, pinned here so an upstream
28
+ # rename is a one-file change.
29
+ TIME_UNIX_NANO: Final[str] = "time_unix_nano"
30
+ SEVERITY_TEXT: Final[str] = "severity_text"
31
+ SEVERITY_NUMBER: Final[str] = "severity_number"
32
+ EVENT_NAME: Final[str] = "event_name"
33
+ BODY: Final[str] = "body"
34
+ TRACE_ID: Final[str] = "trace_id"
35
+ SPAN_ID: Final[str] = "span_id"
36
+ PARENT_SPAN_ID: Final[str] = "parent_span_id"
37
+ ATTRIBUTES: Final[str] = "attributes"
38
+ RESOURCE: Final[str] = "resource"
39
+
40
+ # OpenTelemetry semantic-convention attribute names used by this library.
41
+ ERROR_TYPE: Final[str] = "error.type"
42
+ SERVICE_NAME: Final[str] = "service.name"
43
+ SERVICE_VERSION: Final[str] = "service.version"
44
+ PROCESS_PID: Final[str] = "process.pid"
45
+ PROCESS_RUNTIME_NAME: Final[str] = "process.runtime.name"
46
+ PROCESS_RUNTIME_VERSION: Final[str] = "process.runtime.version"
47
+
48
+ EVENT_FIELD_ORDER: Final[tuple[str, ...]] = (
49
+ TIME_UNIX_NANO,
50
+ SEVERITY_TEXT,
51
+ SEVERITY_NUMBER,
52
+ EVENT_NAME,
53
+ TRACE_ID,
54
+ SPAN_ID,
55
+ PARENT_SPAN_ID,
56
+ BODY,
57
+ RESOURCE,
58
+ ATTRIBUTES,
59
+ )
60
+
61
+ # Python logging level -> OTel severity number for the level's lower bound.
62
+ _SEVERITY_NUMBER_BY_LEVEL: Final[tuple[tuple[int, str, int], ...]] = (
63
+ (logging.CRITICAL, "FATAL", 21),
64
+ (logging.ERROR, "ERROR", 17),
65
+ (logging.WARNING, "WARN", 13),
66
+ (logging.INFO, "INFO", 9),
67
+ (logging.DEBUG, "DEBUG", 5),
68
+ (0, "TRACE", 1),
69
+ )
70
+
71
+
72
+ def severity_fields(level: int) -> tuple[str, int]:
73
+ """Return OTel `(severity_text, severity_number)` for a Python log level."""
74
+ for threshold, severity_text, severity_number in _SEVERITY_NUMBER_BY_LEVEL:
75
+ if level >= threshold:
76
+ return severity_text, severity_number
77
+ return "TRACE", 1
78
+
79
+
80
+ def ordered_event_payload(payload: Mapping[str, Any]) -> dict[str, Any]:
81
+ """Return the payload with model fields first and attributes sorted."""
82
+ ordered: dict[str, Any] = {}
83
+ for key in EVENT_FIELD_ORDER:
84
+ if key in payload:
85
+ ordered[key] = payload[key]
86
+ for key in sorted(key for key in payload if key not in ordered):
87
+ ordered[key] = payload[key]
88
+ attributes = ordered.get(ATTRIBUTES)
89
+ if isinstance(attributes, Mapping):
90
+ typed_attributes = cast(Mapping[str, Any], attributes)
91
+ ordered[ATTRIBUTES] = {key: typed_attributes[key] for key in sorted(typed_attributes)}
92
+ return ordered
93
+
94
+
95
+ class EventSink(Protocol):
96
+ """Receives structured wide events as plain mappings."""
97
+
98
+ def emit(self, event: Mapping[str, Any]) -> None: ...
99
+
100
+
101
+ class NullEventSink:
102
+ """Discard every event; the default sink outside any run context."""
103
+
104
+ def emit(self, event: Mapping[str, Any]) -> None:
105
+ return
106
+
107
+
108
+ class InMemoryEventSink:
109
+ """Collect events in memory for tests and module callers."""
110
+
111
+ def __init__(self) -> None:
112
+ self._lock = threading.Lock()
113
+ self.events: list[dict[str, Any]] = []
114
+
115
+ def emit(self, event: Mapping[str, Any]) -> None:
116
+ with self._lock:
117
+ self.events.append(dict(event))
118
+
119
+
120
+ class CallbackEventSink:
121
+ """Pass a defensive copy of every event to a caller-supplied function."""
122
+
123
+ def __init__(self, callback: Callable[[dict[str, Any]], None]) -> None:
124
+ self._callback = callback
125
+
126
+ def emit(self, event: Mapping[str, Any]) -> None:
127
+ self._callback(dict(event))
128
+
129
+
130
+ class CompositeEventSink:
131
+ """Fan one event out to several sinks."""
132
+
133
+ def __init__(self, sinks: tuple[EventSink, ...]) -> None:
134
+ self.sinks = sinks
135
+
136
+ def emit(self, event: Mapping[str, Any]) -> None:
137
+ for sink in self.sinks:
138
+ sink.emit(event)
139
+
140
+
141
+ class JSONLEventSink:
142
+ """Write each event as one JSON line; thread-safe; usable as a context manager."""
143
+
144
+ def __init__(self, path: Path) -> None:
145
+ path.parent.mkdir(parents=True, exist_ok=True)
146
+ self.path = path
147
+ self._lock = threading.Lock()
148
+ self._file = path.open("w", encoding="utf-8", buffering=1)
149
+
150
+ def emit(self, event: Mapping[str, Any]) -> None:
151
+ line = json.dumps(ordered_event_payload(event), default=str)
152
+ with self._lock, contextlib.suppress(ValueError):
153
+ self._file.write(line + "\n")
154
+
155
+ def flush(self) -> None:
156
+ with self._lock, contextlib.suppress(ValueError):
157
+ self._file.flush()
158
+
159
+ def close(self) -> None:
160
+ with self._lock, contextlib.suppress(Exception):
161
+ self._file.flush()
162
+ self._file.close()
163
+
164
+ def __enter__(self) -> Self:
165
+ return self
166
+
167
+ def __exit__(
168
+ self,
169
+ exception_type: type[BaseException] | None,
170
+ exception: BaseException | None,
171
+ traceback: TracebackType | None,
172
+ ) -> None:
173
+ self.close()
174
+
175
+
176
+ def flush_sink(sink: EventSink) -> None:
177
+ """Flush a sink if it supports flushing."""
178
+ flush = getattr(sink, "flush", None)
179
+ if callable(flush):
180
+ flush()
181
+
182
+
183
+ @dataclass(frozen=True)
184
+ class EventRuntime:
185
+ """Ambient per-run observability scope: where events go and what rides along."""
186
+
187
+ run: str = ""
188
+ sink: EventSink = field(default_factory=NullEventSink)
189
+ min_level: int = logging.DEBUG
190
+
191
+
192
+ _EVENT_RUNTIME: ContextVar[EventRuntime | None] = ContextVar(
193
+ "src_py_lib_event_runtime",
194
+ default=None,
195
+ )
196
+ _DEFAULT_RUNTIME: Final[EventRuntime] = EventRuntime()
197
+
198
+
199
+ def current_event_runtime() -> EventRuntime:
200
+ """Return the active event runtime; outside a run this is a null runtime."""
201
+ return _EVENT_RUNTIME.get() or _DEFAULT_RUNTIME
202
+
203
+
204
+ def set_event_runtime(runtime: EventRuntime) -> Any:
205
+ """Install an event runtime; returns the reset token."""
206
+ return _EVENT_RUNTIME.set(runtime)
207
+
208
+
209
+ def reset_event_runtime(token: Any) -> None:
210
+ """Restore the previous event runtime."""
211
+ _EVENT_RUNTIME.reset(token)