ctrlrelay 0.10.0__py3-none-any.whl
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.
- ctrlrelay/__init__.py +33 -0
- ctrlrelay/bridge/__init__.py +21 -0
- ctrlrelay/bridge/__main__.py +89 -0
- ctrlrelay/bridge/protocol.py +75 -0
- ctrlrelay/bridge/server.py +740 -0
- ctrlrelay/bridge/telegram_handler.py +136 -0
- ctrlrelay/cli.py +3138 -0
- ctrlrelay/core/__init__.py +54 -0
- ctrlrelay/core/audit.py +257 -0
- ctrlrelay/core/checkpoint.py +155 -0
- ctrlrelay/core/config.py +778 -0
- ctrlrelay/core/dispatcher.py +372 -0
- ctrlrelay/core/github.py +432 -0
- ctrlrelay/core/network.py +277 -0
- ctrlrelay/core/obs.py +118 -0
- ctrlrelay/core/poller.py +920 -0
- ctrlrelay/core/pr_verifier.py +230 -0
- ctrlrelay/core/pr_watcher.py +121 -0
- ctrlrelay/core/question_expiry.py +193 -0
- ctrlrelay/core/scheduler.py +337 -0
- ctrlrelay/core/state.py +642 -0
- ctrlrelay/core/worktree.py +893 -0
- ctrlrelay/dashboard/__init__.py +5 -0
- ctrlrelay/dashboard/client.py +159 -0
- ctrlrelay/gh_protocol.py +47 -0
- ctrlrelay/install.py +203 -0
- ctrlrelay/personalization/__init__.py +26 -0
- ctrlrelay/personalization/manager.py +1183 -0
- ctrlrelay/personalization/paths.py +143 -0
- ctrlrelay/pipelines/__init__.py +15 -0
- ctrlrelay/pipelines/base.py +50 -0
- ctrlrelay/pipelines/dev.py +1367 -0
- ctrlrelay/pipelines/post_merge.py +411 -0
- ctrlrelay/pipelines/secops.py +1018 -0
- ctrlrelay/pipelines/task.py +713 -0
- ctrlrelay/setup.py +617 -0
- ctrlrelay/templates/__init__.py +0 -0
- ctrlrelay/templates/global-CLAUDE.md.snippet +61 -0
- ctrlrelay/templates/launchd/__init__.py +0 -0
- ctrlrelay/templates/launchd/bridge.plist.template +37 -0
- ctrlrelay/templates/launchd/poller.plist.template +44 -0
- ctrlrelay/templates/systemd/__init__.py +0 -0
- ctrlrelay/templates/systemd/bridge.service.template +20 -0
- ctrlrelay/templates/systemd/poller.service.template +25 -0
- ctrlrelay/transports/__init__.py +34 -0
- ctrlrelay/transports/base.py +74 -0
- ctrlrelay/transports/file_mock.py +103 -0
- ctrlrelay/transports/socket_client.py +298 -0
- ctrlrelay-0.10.0.dist-info/METADATA +254 -0
- ctrlrelay-0.10.0.dist-info/RECORD +53 -0
- ctrlrelay-0.10.0.dist-info/WHEEL +4 -0
- ctrlrelay-0.10.0.dist-info/entry_points.txt +2 -0
- ctrlrelay-0.10.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Classify `gh` subprocess failures so operators get an actionable message.
|
|
2
|
+
|
|
3
|
+
`gh` exits 1 for everything: an unplugged network cable, an expired token
|
|
4
|
+
and a 500 from the API all surface as
|
|
5
|
+
``returned non-zero exit status 1``. That sends operators debugging auth
|
|
6
|
+
when the real problem is that the laptop is offline (#32).
|
|
7
|
+
|
|
8
|
+
`gh` does write clear wording to stderr, so classification keys off the
|
|
9
|
+
stderr text rather than the exit code.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import errno
|
|
15
|
+
import re
|
|
16
|
+
import subprocess
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from enum import Enum
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class GhFailureKind(str, Enum):
|
|
22
|
+
"""What actually went wrong behind a non-zero `gh` exit."""
|
|
23
|
+
|
|
24
|
+
NETWORK_UNAVAILABLE = "network_unavailable"
|
|
25
|
+
AUTH = "auth"
|
|
26
|
+
RATE_LIMITED = "rate_limited"
|
|
27
|
+
API_ERROR = "api_error"
|
|
28
|
+
GH_MISSING = "gh_missing"
|
|
29
|
+
TLS_TRUST = "tls_trust"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Ordered most-specific-first within each group; matched case-insensitively
|
|
33
|
+
# against gh's stderr. Network patterns cover gh's own wording plus the Go
|
|
34
|
+
# net/http and OpenSSL strings it passes through verbatim.
|
|
35
|
+
_NETWORK_PATTERNS = (
|
|
36
|
+
"error connecting to",
|
|
37
|
+
"dial tcp",
|
|
38
|
+
"no such host",
|
|
39
|
+
"network is unreachable",
|
|
40
|
+
"no route to host",
|
|
41
|
+
"connection refused",
|
|
42
|
+
"connection timed out",
|
|
43
|
+
"connection reset by peer",
|
|
44
|
+
# A peer that vanished mid-write. Deliberately not matching bare
|
|
45
|
+
# "EOF" alongside it: gh passes API response bodies through verbatim
|
|
46
|
+
# and "EOF" is common enough in them to misclassify real API errors
|
|
47
|
+
# as outages, which is the mistake in the other direction.
|
|
48
|
+
"broken pipe",
|
|
49
|
+
"i/o timeout",
|
|
50
|
+
"tls handshake",
|
|
51
|
+
"x509:",
|
|
52
|
+
"certificate signed by",
|
|
53
|
+
"context deadline exceeded",
|
|
54
|
+
"temporary failure in name resolution",
|
|
55
|
+
"name resolution",
|
|
56
|
+
"proxyconnect tcp",
|
|
57
|
+
"server misbehaving",
|
|
58
|
+
"check your internet connection",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# errnos `subprocess.run` raises when it cannot EXEC the child — the file
|
|
62
|
+
# is absent, unreadable, a directory, or the wrong architecture. These
|
|
63
|
+
# say "your gh install is broken". Resource-exhaustion errnos (EMFILE,
|
|
64
|
+
# ENOMEM, EAGAIN) also surface as OSError here but mean nothing of the
|
|
65
|
+
# sort, and telling that operator to install GitHub CLI sends them the
|
|
66
|
+
# wrong way.
|
|
67
|
+
_EXEC_FAILURE_ERRNOS = frozenset(
|
|
68
|
+
e for e in (
|
|
69
|
+
errno.ENOENT, errno.EACCES, errno.EPERM, errno.ENOEXEC,
|
|
70
|
+
errno.EISDIR, errno.ENOTDIR, errno.ELOOP,
|
|
71
|
+
errno.ENAMETOOLONG, errno.ETXTBSY,
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Persistent local trust problems, not outages: a corporate MITM proxy
|
|
76
|
+
# without its CA installed, or a clock skewed far enough to invalidate a
|
|
77
|
+
# valid certificate. "Retry later" is wrong advice for both. A cert valid
|
|
78
|
+
# for the wrong host IS usually a captive portal, so that stays network.
|
|
79
|
+
_TLS_TRUST_PATTERNS = (
|
|
80
|
+
"certificate signed by unknown authority",
|
|
81
|
+
"certificate has expired or is not yet valid",
|
|
82
|
+
"unable to get local issuer certificate",
|
|
83
|
+
"self-signed certificate",
|
|
84
|
+
"self signed certificate",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
_RATE_LIMIT_PATTERNS = (
|
|
88
|
+
"rate limit exceeded",
|
|
89
|
+
"secondary rate limit",
|
|
90
|
+
"too many requests",
|
|
91
|
+
"http 429",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
_AUTH_PATTERNS = (
|
|
95
|
+
"bad credentials",
|
|
96
|
+
"requires authentication",
|
|
97
|
+
"gh auth login",
|
|
98
|
+
"not logged in",
|
|
99
|
+
"authentication failed",
|
|
100
|
+
"http 401",
|
|
101
|
+
"required scopes",
|
|
102
|
+
"needs the '",
|
|
103
|
+
"token has not been granted",
|
|
104
|
+
"gh_token",
|
|
105
|
+
"github_token",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
_HTTP_STATUS_RE = re.compile(r"\(?\bHTTP (\d{3})\b\)?", re.IGNORECASE)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True)
|
|
112
|
+
class GhFailure:
|
|
113
|
+
"""A classified `gh` failure plus the raw evidence behind it."""
|
|
114
|
+
|
|
115
|
+
kind: GhFailureKind
|
|
116
|
+
message: str
|
|
117
|
+
stderr: str = ""
|
|
118
|
+
returncode: int | None = None
|
|
119
|
+
status: int | None = None
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def is_transient(self) -> bool:
|
|
123
|
+
"""True when retrying later could plausibly succeed without the
|
|
124
|
+
operator changing anything."""
|
|
125
|
+
return self.kind in (
|
|
126
|
+
GhFailureKind.NETWORK_UNAVAILABLE,
|
|
127
|
+
GhFailureKind.RATE_LIMITED,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _decode(stderr: str | bytes | None) -> str:
|
|
132
|
+
if stderr is None:
|
|
133
|
+
return ""
|
|
134
|
+
if isinstance(stderr, bytes):
|
|
135
|
+
stderr = stderr.decode("utf-8", errors="replace")
|
|
136
|
+
return stderr.strip()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# Go renders a failed HTTP round-trip as `*url.Error`:
|
|
140
|
+
# Get "https://api.github.com/user": <cause>
|
|
141
|
+
# By construction that means NO HTTP response was received, so it can
|
|
142
|
+
# never be an API error — whatever the cause reads like. `gh api` prints
|
|
143
|
+
# a real API error in the other shape entirely, `gh: <msg> (HTTP nnn)`,
|
|
144
|
+
# so the two do not overlap. Keying on the shape catches the whole family
|
|
145
|
+
# (EOF, http2 connection lost, request canceled, remote error: tls: …)
|
|
146
|
+
# instead of chasing each new wording as a string.
|
|
147
|
+
_URL_ERROR_RE = re.compile(r'^(?:get|post|put|patch|delete|head)\s+"https?://', re.I)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _is_transport_failure(text: str) -> bool:
|
|
151
|
+
"""True when the text is Go's url.Error shape — request issued, no
|
|
152
|
+
response received."""
|
|
153
|
+
return any(
|
|
154
|
+
_URL_ERROR_RE.match(line.strip())
|
|
155
|
+
for line in text.splitlines()
|
|
156
|
+
if line.strip()
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _kind_for(text: str) -> GhFailureKind:
|
|
161
|
+
lowered = text.lower()
|
|
162
|
+
# Checked before the network patterns: these strings contain
|
|
163
|
+
# "x509:", but retrying never fixes a CA bundle or a skewed clock.
|
|
164
|
+
if any(pattern in lowered for pattern in _TLS_TRUST_PATTERNS):
|
|
165
|
+
return GhFailureKind.TLS_TRUST
|
|
166
|
+
if any(pattern in lowered for pattern in _NETWORK_PATTERNS):
|
|
167
|
+
return GhFailureKind.NETWORK_UNAVAILABLE
|
|
168
|
+
if _is_transport_failure(text):
|
|
169
|
+
return GhFailureKind.NETWORK_UNAVAILABLE
|
|
170
|
+
# Rate-limit bodies mention authentication ("Authenticated requests get
|
|
171
|
+
# a higher rate limit"), so they have to be matched before auth.
|
|
172
|
+
if any(pattern in lowered for pattern in _RATE_LIMIT_PATTERNS):
|
|
173
|
+
return GhFailureKind.RATE_LIMITED
|
|
174
|
+
if any(pattern in lowered for pattern in _AUTH_PATTERNS):
|
|
175
|
+
return GhFailureKind.AUTH
|
|
176
|
+
return GhFailureKind.API_ERROR
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _message_for(kind: GhFailureKind, status: int | None) -> str:
|
|
180
|
+
if kind is GhFailureKind.NETWORK_UNAVAILABLE:
|
|
181
|
+
return (
|
|
182
|
+
"Network unavailable — could not reach api.github.com. "
|
|
183
|
+
"Check connectivity or https://githubstatus.com, then retry."
|
|
184
|
+
)
|
|
185
|
+
if kind is GhFailureKind.RATE_LIMITED:
|
|
186
|
+
return (
|
|
187
|
+
"GitHub rate limit exceeded — wait for the limit to reset, "
|
|
188
|
+
"then retry."
|
|
189
|
+
)
|
|
190
|
+
if kind is GhFailureKind.AUTH:
|
|
191
|
+
return (
|
|
192
|
+
"GitHub credentials rejected — run `gh auth status` "
|
|
193
|
+
"(then `gh auth login`) and retry."
|
|
194
|
+
)
|
|
195
|
+
if kind is GhFailureKind.TLS_TRUST:
|
|
196
|
+
return (
|
|
197
|
+
"TLS certificate not trusted — the connection to api.github.com "
|
|
198
|
+
"was intercepted or the certificate could not be verified. "
|
|
199
|
+
"Install your proxy's CA certificate, or check the system clock. "
|
|
200
|
+
"Retrying will not help."
|
|
201
|
+
)
|
|
202
|
+
if kind is GhFailureKind.GH_MISSING:
|
|
203
|
+
return (
|
|
204
|
+
"The `gh` CLI could not be run — install GitHub CLI, make sure "
|
|
205
|
+
"it is on PATH, and check the file is executable."
|
|
206
|
+
)
|
|
207
|
+
if status is not None:
|
|
208
|
+
return f"GitHub API returned an error (HTTP {status})."
|
|
209
|
+
return "GitHub API call failed."
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def classify_gh_failure(
|
|
213
|
+
stderr: str | bytes | None, returncode: int | None = None
|
|
214
|
+
) -> GhFailure:
|
|
215
|
+
"""Classify one `gh` failure from the stderr it wrote."""
|
|
216
|
+
text = _decode(stderr)
|
|
217
|
+
kind = _kind_for(text)
|
|
218
|
+
match = _HTTP_STATUS_RE.search(text)
|
|
219
|
+
status = int(match.group(1)) if match else None
|
|
220
|
+
return GhFailure(
|
|
221
|
+
kind=kind,
|
|
222
|
+
message=_message_for(kind, status),
|
|
223
|
+
stderr=text,
|
|
224
|
+
returncode=returncode,
|
|
225
|
+
status=status,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def gh_failure_from_exception(exc: BaseException) -> GhFailure:
|
|
230
|
+
"""Classify whatever a `gh` subprocess call raised.
|
|
231
|
+
|
|
232
|
+
Handles the three things `subprocess.run(..., check=True)` can throw at
|
|
233
|
+
a caller: a non-zero exit, a missing binary, and a timeout — plus bare
|
|
234
|
+
`OSError` for socket-level failures raised by Python rather than gh.
|
|
235
|
+
"""
|
|
236
|
+
if isinstance(exc, FileNotFoundError):
|
|
237
|
+
# Checked before OSError: a missing gh is a setup problem, and
|
|
238
|
+
# calling it "offline" would send operators the wrong way.
|
|
239
|
+
return GhFailure(
|
|
240
|
+
kind=GhFailureKind.GH_MISSING,
|
|
241
|
+
message=_message_for(GhFailureKind.GH_MISSING, None),
|
|
242
|
+
stderr=str(exc),
|
|
243
|
+
)
|
|
244
|
+
if isinstance(exc, subprocess.CalledProcessError):
|
|
245
|
+
return classify_gh_failure(exc.stderr, returncode=exc.returncode)
|
|
246
|
+
if isinstance(exc, (subprocess.TimeoutExpired, TimeoutError)):
|
|
247
|
+
# A hung gh is the one exception shape that really does suggest
|
|
248
|
+
# connectivity: the child launched and then stopped responding.
|
|
249
|
+
return GhFailure(
|
|
250
|
+
kind=GhFailureKind.NETWORK_UNAVAILABLE,
|
|
251
|
+
message=_message_for(GhFailureKind.NETWORK_UNAVAILABLE, None),
|
|
252
|
+
stderr=str(exc),
|
|
253
|
+
)
|
|
254
|
+
if isinstance(exc, OSError):
|
|
255
|
+
# An OSError here came from `subprocess.run` failing to launch the
|
|
256
|
+
# child. Python is not doing the networking — gh is — so it can
|
|
257
|
+
# never mean "offline"; reporting it that way is the exact
|
|
258
|
+
# misdiagnosis this module exists to prevent.
|
|
259
|
+
#
|
|
260
|
+
# But only the EXEC-shaped errnos mean the install is broken. A
|
|
261
|
+
# process-table or memory limit (EMFILE, ENOMEM, EAGAIN) also
|
|
262
|
+
# lands here, and answering that with "install GitHub CLI" is
|
|
263
|
+
# just the same misdirection wearing a different hat. Fall
|
|
264
|
+
# through for those: the caller still prints the raw errno text,
|
|
265
|
+
# which is the actionable part.
|
|
266
|
+
if exc.errno in _EXEC_FAILURE_ERRNOS:
|
|
267
|
+
return GhFailure(
|
|
268
|
+
kind=GhFailureKind.GH_MISSING,
|
|
269
|
+
message=_message_for(GhFailureKind.GH_MISSING, None),
|
|
270
|
+
stderr=str(exc),
|
|
271
|
+
)
|
|
272
|
+
return GhFailure(
|
|
273
|
+
kind=GhFailureKind.API_ERROR,
|
|
274
|
+
message=f"Could not run `gh`: {exc}",
|
|
275
|
+
stderr=str(exc),
|
|
276
|
+
)
|
|
277
|
+
return classify_gh_failure(str(exc))
|
ctrlrelay/core/obs.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Structured logging for ctrlrelay.
|
|
2
|
+
|
|
3
|
+
Emits newline-delimited JSON records on the ``ctrlrelay`` logger tree so the
|
|
4
|
+
poller and bridge stdout streams (captured by launchd into
|
|
5
|
+
``~/.ctrlrelay/logs/*.log``) carry machine-readable events.
|
|
6
|
+
|
|
7
|
+
Typical usage:
|
|
8
|
+
|
|
9
|
+
from ctrlrelay.core.obs import get_logger, log_event
|
|
10
|
+
|
|
11
|
+
logger = get_logger("transport.socket")
|
|
12
|
+
log_event(
|
|
13
|
+
logger,
|
|
14
|
+
"dev.question.posted",
|
|
15
|
+
session_id=session_id,
|
|
16
|
+
repo=repo,
|
|
17
|
+
issue_number=issue_number,
|
|
18
|
+
transport="socket",
|
|
19
|
+
)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
import sys
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
_CONFIGURED = False
|
|
31
|
+
|
|
32
|
+
# LogRecord attributes that should not be serialized as "extra" fields.
|
|
33
|
+
_RESERVED_ATTRS = frozenset(
|
|
34
|
+
{
|
|
35
|
+
"args",
|
|
36
|
+
"asctime",
|
|
37
|
+
"created",
|
|
38
|
+
"exc_info",
|
|
39
|
+
"exc_text",
|
|
40
|
+
"filename",
|
|
41
|
+
"funcName",
|
|
42
|
+
"levelname",
|
|
43
|
+
"levelno",
|
|
44
|
+
"lineno",
|
|
45
|
+
"message",
|
|
46
|
+
"module",
|
|
47
|
+
"msecs",
|
|
48
|
+
"msg",
|
|
49
|
+
"name",
|
|
50
|
+
"pathname",
|
|
51
|
+
"process",
|
|
52
|
+
"processName",
|
|
53
|
+
"relativeCreated",
|
|
54
|
+
"stack_info",
|
|
55
|
+
"taskName",
|
|
56
|
+
"thread",
|
|
57
|
+
"threadName",
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class JSONFormatter(logging.Formatter):
|
|
63
|
+
"""Format records as single-line JSON with ``event`` + extra fields."""
|
|
64
|
+
|
|
65
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
66
|
+
payload: dict[str, Any] = {
|
|
67
|
+
"ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
|
|
68
|
+
"level": record.levelname,
|
|
69
|
+
"logger": record.name,
|
|
70
|
+
"event": record.getMessage(),
|
|
71
|
+
}
|
|
72
|
+
for key, value in record.__dict__.items():
|
|
73
|
+
if key in _RESERVED_ATTRS or key.startswith("_"):
|
|
74
|
+
continue
|
|
75
|
+
payload[key] = value
|
|
76
|
+
if record.exc_info:
|
|
77
|
+
payload["exc"] = self.formatException(record.exc_info)
|
|
78
|
+
return json.dumps(payload, default=str)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def configure_logging(level: int = logging.INFO) -> None:
|
|
82
|
+
"""Install a JSON handler on the ``ctrlrelay`` logger tree.
|
|
83
|
+
|
|
84
|
+
Safe to call repeatedly — subsequent calls are no-ops. Records propagate
|
|
85
|
+
to the root logger so pytest's ``caplog`` fixture (and any other ancestor
|
|
86
|
+
handlers) can still observe them.
|
|
87
|
+
"""
|
|
88
|
+
global _CONFIGURED
|
|
89
|
+
if _CONFIGURED:
|
|
90
|
+
return
|
|
91
|
+
logger = logging.getLogger("ctrlrelay")
|
|
92
|
+
logger.setLevel(level)
|
|
93
|
+
handler = logging.StreamHandler(stream=sys.stdout)
|
|
94
|
+
handler.setFormatter(JSONFormatter())
|
|
95
|
+
logger.addHandler(handler)
|
|
96
|
+
_CONFIGURED = True
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def get_logger(name: str) -> logging.Logger:
|
|
100
|
+
"""Return a namespaced logger under ``ctrlrelay.<name>``."""
|
|
101
|
+
configure_logging()
|
|
102
|
+
return logging.getLogger(f"ctrlrelay.{name}")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def log_event(logger: logging.Logger, event: str, **fields: Any) -> None:
|
|
106
|
+
"""Emit an INFO-level structured event.
|
|
107
|
+
|
|
108
|
+
``fields`` are attached as LogRecord attributes and surfaced by
|
|
109
|
+
:class:`JSONFormatter`. Avoid using keys that collide with stdlib
|
|
110
|
+
LogRecord attributes (``message``, ``name``, ``args`` …) — those are
|
|
111
|
+
reserved by the logging module and will raise ``KeyError``.
|
|
112
|
+
"""
|
|
113
|
+
logger.info(event, extra=fields)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def hash_text(text: str) -> str:
|
|
117
|
+
"""Short, stable SHA-256 prefix for PII-sensitive payloads."""
|
|
118
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
|