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.
Files changed (37) hide show
  1. {src_py_lib-0.2.1 → src_py_lib-0.3.0}/PKG-INFO +1 -1
  2. {src_py_lib-0.2.1 → src_py_lib-0.3.0}/src/src_py_lib/__init__.py +37 -7
  3. src_py_lib-0.3.0/src/src_py_lib/utils/events.py +211 -0
  4. {src_py_lib-0.2.1 → src_py_lib-0.3.0}/src/src_py_lib/utils/logging.py +265 -203
  5. {src_py_lib-0.2.1 → src_py_lib-0.3.0}/src/src_py_lib/utils/telemetry.py +67 -5
  6. {src_py_lib-0.2.1 → src_py_lib-0.3.0}/tests/test_import.py +12 -0
  7. {src_py_lib-0.2.1 → src_py_lib-0.3.0}/tests/test_logging_http_clients.py +836 -524
  8. {src_py_lib-0.2.1 → src_py_lib-0.3.0}/.github/CODEOWNERS +0 -0
  9. {src_py_lib-0.2.1 → src_py_lib-0.3.0}/.github/workflows/ci.yml +0 -0
  10. {src_py_lib-0.2.1 → src_py_lib-0.3.0}/.github/workflows/release.yml +0 -0
  11. {src_py_lib-0.2.1 → src_py_lib-0.3.0}/.github/workflows/validate.yml +0 -0
  12. {src_py_lib-0.2.1 → src_py_lib-0.3.0}/.gitignore +0 -0
  13. {src_py_lib-0.2.1 → src_py_lib-0.3.0}/.markdownlint-cli2.yaml +0 -0
  14. {src_py_lib-0.2.1 → src_py_lib-0.3.0}/.python-version +0 -0
  15. {src_py_lib-0.2.1 → src_py_lib-0.3.0}/AGENTS.md +0 -0
  16. {src_py_lib-0.2.1 → src_py_lib-0.3.0}/LICENSE +0 -0
  17. {src_py_lib-0.2.1 → src_py_lib-0.3.0}/README.md +0 -0
  18. {src_py_lib-0.2.1 → src_py_lib-0.3.0}/SECURITY.md +0 -0
  19. {src_py_lib-0.2.1 → src_py_lib-0.3.0}/pyproject.toml +0 -0
  20. {src_py_lib-0.2.1 → src_py_lib-0.3.0}/renovate.json +0 -0
  21. {src_py_lib-0.2.1 → src_py_lib-0.3.0}/src/src_py_lib/clients/__init__.py +0 -0
  22. {src_py_lib-0.2.1 → src_py_lib-0.3.0}/src/src_py_lib/clients/github.py +0 -0
  23. {src_py_lib-0.2.1 → src_py_lib-0.3.0}/src/src_py_lib/clients/google_sheets.py +0 -0
  24. {src_py_lib-0.2.1 → src_py_lib-0.3.0}/src/src_py_lib/clients/graphql.py +0 -0
  25. {src_py_lib-0.2.1 → src_py_lib-0.3.0}/src/src_py_lib/clients/linear.py +0 -0
  26. {src_py_lib-0.2.1 → src_py_lib-0.3.0}/src/src_py_lib/clients/one_password.py +0 -0
  27. {src_py_lib-0.2.1 → src_py_lib-0.3.0}/src/src_py_lib/clients/slack.py +0 -0
  28. {src_py_lib-0.2.1 → src_py_lib-0.3.0}/src/src_py_lib/clients/sourcegraph.py +0 -0
  29. {src_py_lib-0.2.1 → src_py_lib-0.3.0}/src/src_py_lib/py.typed +0 -0
  30. {src_py_lib-0.2.1 → src_py_lib-0.3.0}/src/src_py_lib/utils/__init__.py +0 -0
  31. {src_py_lib-0.2.1 → src_py_lib-0.3.0}/src/src_py_lib/utils/config.py +0 -0
  32. {src_py_lib-0.2.1 → src_py_lib-0.3.0}/src/src_py_lib/utils/http.py +0 -0
  33. {src_py_lib-0.2.1 → src_py_lib-0.3.0}/src/src_py_lib/utils/json_cache.py +0 -0
  34. {src_py_lib-0.2.1 → src_py_lib-0.3.0}/src/src_py_lib/utils/json_types.py +0 -0
  35. {src_py_lib-0.2.1 → src_py_lib-0.3.0}/src/src_py_lib/utils/tsv.py +0 -0
  36. {src_py_lib-0.2.1 → src_py_lib-0.3.0}/tests/test_tsv.py +0 -0
  37. {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.2.1
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
- 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)