src-py-lib 0.2.1__tar.gz → 0.3.0__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.
- {src_py_lib-0.2.1 → src_py_lib-0.3.0}/PKG-INFO +1 -1
- {src_py_lib-0.2.1 → src_py_lib-0.3.0}/src/src_py_lib/__init__.py +37 -7
- src_py_lib-0.3.0/src/src_py_lib/utils/events.py +211 -0
- {src_py_lib-0.2.1 → src_py_lib-0.3.0}/src/src_py_lib/utils/logging.py +265 -203
- {src_py_lib-0.2.1 → src_py_lib-0.3.0}/src/src_py_lib/utils/telemetry.py +67 -5
- {src_py_lib-0.2.1 → src_py_lib-0.3.0}/tests/test_import.py +12 -0
- {src_py_lib-0.2.1 → src_py_lib-0.3.0}/tests/test_logging_http_clients.py +836 -524
- {src_py_lib-0.2.1 → src_py_lib-0.3.0}/.github/CODEOWNERS +0 -0
- {src_py_lib-0.2.1 → src_py_lib-0.3.0}/.github/workflows/ci.yml +0 -0
- {src_py_lib-0.2.1 → src_py_lib-0.3.0}/.github/workflows/release.yml +0 -0
- {src_py_lib-0.2.1 → src_py_lib-0.3.0}/.github/workflows/validate.yml +0 -0
- {src_py_lib-0.2.1 → src_py_lib-0.3.0}/.gitignore +0 -0
- {src_py_lib-0.2.1 → src_py_lib-0.3.0}/.markdownlint-cli2.yaml +0 -0
- {src_py_lib-0.2.1 → src_py_lib-0.3.0}/.python-version +0 -0
- {src_py_lib-0.2.1 → src_py_lib-0.3.0}/AGENTS.md +0 -0
- {src_py_lib-0.2.1 → src_py_lib-0.3.0}/LICENSE +0 -0
- {src_py_lib-0.2.1 → src_py_lib-0.3.0}/README.md +0 -0
- {src_py_lib-0.2.1 → src_py_lib-0.3.0}/SECURITY.md +0 -0
- {src_py_lib-0.2.1 → src_py_lib-0.3.0}/pyproject.toml +0 -0
- {src_py_lib-0.2.1 → src_py_lib-0.3.0}/renovate.json +0 -0
- {src_py_lib-0.2.1 → src_py_lib-0.3.0}/src/src_py_lib/clients/__init__.py +0 -0
- {src_py_lib-0.2.1 → src_py_lib-0.3.0}/src/src_py_lib/clients/github.py +0 -0
- {src_py_lib-0.2.1 → src_py_lib-0.3.0}/src/src_py_lib/clients/google_sheets.py +0 -0
- {src_py_lib-0.2.1 → src_py_lib-0.3.0}/src/src_py_lib/clients/graphql.py +0 -0
- {src_py_lib-0.2.1 → src_py_lib-0.3.0}/src/src_py_lib/clients/linear.py +0 -0
- {src_py_lib-0.2.1 → src_py_lib-0.3.0}/src/src_py_lib/clients/one_password.py +0 -0
- {src_py_lib-0.2.1 → src_py_lib-0.3.0}/src/src_py_lib/clients/slack.py +0 -0
- {src_py_lib-0.2.1 → src_py_lib-0.3.0}/src/src_py_lib/clients/sourcegraph.py +0 -0
- {src_py_lib-0.2.1 → src_py_lib-0.3.0}/src/src_py_lib/py.typed +0 -0
- {src_py_lib-0.2.1 → src_py_lib-0.3.0}/src/src_py_lib/utils/__init__.py +0 -0
- {src_py_lib-0.2.1 → src_py_lib-0.3.0}/src/src_py_lib/utils/config.py +0 -0
- {src_py_lib-0.2.1 → src_py_lib-0.3.0}/src/src_py_lib/utils/http.py +0 -0
- {src_py_lib-0.2.1 → src_py_lib-0.3.0}/src/src_py_lib/utils/json_cache.py +0 -0
- {src_py_lib-0.2.1 → src_py_lib-0.3.0}/src/src_py_lib/utils/json_types.py +0 -0
- {src_py_lib-0.2.1 → src_py_lib-0.3.0}/src/src_py_lib/utils/tsv.py +0 -0
- {src_py_lib-0.2.1 → src_py_lib-0.3.0}/tests/test_tsv.py +0 -0
- {src_py_lib-0.2.1 → src_py_lib-0.3.0}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: src-py-lib
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|