agentrail-python 0.1.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.
@@ -0,0 +1,64 @@
1
+ # Copyright 2026 Agentrail. Licensed under the Apache License, Version 2.0.
2
+ """Agentrail for Python: backend journey evidence over OpenTelemetry.
3
+
4
+ Standalone: ``agentrail = init(Options(...))``. With an existing OpenTelemetry
5
+ setup: ``create_processors(Options(...))``, then ``bind`` your provider.
6
+ """
7
+
8
+ from ._client import Client, Diagnostics, MilestoneScope
9
+ from ._init import init
10
+ from ._options import (
11
+ AuthorizedOperation,
12
+ CaptureCode,
13
+ CaptureError,
14
+ CollectorOptions,
15
+ ConfigurationError,
16
+ IncomingRequest,
17
+ IPLocation,
18
+ LocationSource,
19
+ LogsOptions,
20
+ McpContext,
21
+ MilestoneContext,
22
+ Options,
23
+ PerformanceContext,
24
+ PluginInvocation,
25
+ PrincipalKind,
26
+ QueueOptions,
27
+ Surface,
28
+ VerifiedIdentity,
29
+ )
30
+ from ._privacy import StructuredLog
31
+ from ._processors import Processors, create_processors
32
+ from ._queue import QueueStats
33
+ from ._version import VERSION
34
+
35
+ __version__ = VERSION
36
+ __all__ = [
37
+ "VERSION",
38
+ "AuthorizedOperation",
39
+ "CaptureCode",
40
+ "CaptureError",
41
+ "Client",
42
+ "CollectorOptions",
43
+ "ConfigurationError",
44
+ "Diagnostics",
45
+ "IPLocation",
46
+ "IncomingRequest",
47
+ "LocationSource",
48
+ "LogsOptions",
49
+ "McpContext",
50
+ "MilestoneContext",
51
+ "MilestoneScope",
52
+ "Options",
53
+ "PerformanceContext",
54
+ "PluginInvocation",
55
+ "PrincipalKind",
56
+ "Processors",
57
+ "QueueOptions",
58
+ "QueueStats",
59
+ "StructuredLog",
60
+ "Surface",
61
+ "VerifiedIdentity",
62
+ "create_processors",
63
+ "init",
64
+ ]
@@ -0,0 +1,362 @@
1
+ # Copyright 2026 Agentrail. Licensed under the Apache License, Version 2.0.
2
+ """The client an application records with."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import functools
7
+ import inspect
8
+ import re
9
+ import threading
10
+ import time
11
+ from collections.abc import Callable
12
+ from dataclasses import dataclass
13
+ from types import TracebackType
14
+ from typing import TYPE_CHECKING, Any, TypeVar, cast
15
+
16
+ from opentelemetry import context as otel_context
17
+ from opentelemetry import trace
18
+ from opentelemetry.trace import Link, NonRecordingSpan, Span, SpanContext, SpanKind, StatusCode, TraceFlags
19
+
20
+ from ._handoff import HANDOFF_HEADER, Refused, parse_traceparent, verify
21
+ from ._options import (
22
+ LOCATION_SOURCES,
23
+ PRINCIPAL_KINDS,
24
+ SURFACES,
25
+ ConfigurationError,
26
+ IncomingRequest,
27
+ MilestoneContext,
28
+ VerifiedIdentity,
29
+ )
30
+ from ._performance import performance_attributes
31
+ from ._privacy import StructuredLog, clean_context, copy_log
32
+ from ._queue import ExportQueue, QueueStats
33
+ from ._runtime import Attributes, Mark, RequestState, Runtime
34
+ from ._suppression import suppressed
35
+ from ._text import bounded, valid_text
36
+ from ._version import VERSION
37
+
38
+ if TYPE_CHECKING:
39
+ from opentelemetry.sdk._logs import ReadableLogRecord
40
+ from opentelemetry.sdk.trace import ReadableSpan
41
+
42
+ from .asgi import AgentrailASGIMiddleware, ASGIApp
43
+ from .wsgi import AgentrailWSGIMiddleware, WSGIApp
44
+
45
+ F = TypeVar("F", bound=Callable[..., Any])
46
+ _COUNTRY = re.compile(r"[A-Z]{2}")
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class Diagnostics:
51
+ spans: QueueStats
52
+ logs: QueueStats
53
+
54
+
55
+ def _error_type(error: BaseException) -> str:
56
+ return bounded(type(error).__qualname__, 4096)
57
+
58
+
59
+ class MilestoneScope:
60
+ """A milestone around a block: ``with`` or ``async with``.
61
+
62
+ The block's exception is recorded and re-raised unchanged.
63
+ """
64
+
65
+ def __init__(self, client: Client, name: str, details: MilestoneContext) -> None:
66
+ client._validate_milestone(name, details) # pyright: ignore[reportPrivateUsage]
67
+ self._client = client
68
+ self._name = name
69
+ self._details = details
70
+ self._span: Span | None = None
71
+ self._token: object | None = None
72
+
73
+ def __enter__(self) -> MilestoneScope:
74
+ span, ctx = self._client._start(self._name, self._details) # pyright: ignore[reportPrivateUsage]
75
+ self._span = span
76
+ self._token = otel_context.attach(ctx)
77
+ return self
78
+
79
+ def __exit__(
80
+ self,
81
+ exc_type: type[BaseException] | None,
82
+ exc: BaseException | None,
83
+ traceback: TracebackType | None,
84
+ ) -> None:
85
+ span, token = self._span, self._token
86
+ self._span = self._token = None
87
+ if span is None or token is None:
88
+ return
89
+ try:
90
+ if exc is not None:
91
+ span.set_status(StatusCode.ERROR)
92
+ span.set_attribute("error.type", _error_type(exc))
93
+ span.end()
94
+ finally:
95
+ otel_context.detach(cast(Any, token))
96
+
97
+ async def __aenter__(self) -> MilestoneScope:
98
+ return self.__enter__()
99
+
100
+ async def __aexit__(
101
+ self,
102
+ exc_type: type[BaseException] | None,
103
+ exc: BaseException | None,
104
+ traceback: TracebackType | None,
105
+ ) -> None:
106
+ self.__exit__(exc_type, exc, traceback)
107
+
108
+
109
+ class Client:
110
+ """Records backend evidence with a bound tracer provider.
111
+
112
+ Get one from ``init`` or from ``create_processors(...).bind(provider)``.
113
+ """
114
+
115
+ def __init__(
116
+ self,
117
+ runtime: Runtime,
118
+ provider: trace.TracerProvider,
119
+ spans: ExportQueue[ReadableSpan],
120
+ logs: ExportQueue[ReadableLogRecord],
121
+ ) -> None:
122
+ self._runtime = runtime
123
+ self._tracer = provider.get_tracer(runtime.scope.name, VERSION)
124
+ self._spans = spans
125
+ self._logs = logs
126
+ self._closed = False
127
+ self._shutdown_lock = threading.Lock()
128
+ self._on_shutdown: list[Callable[[], object]] = []
129
+
130
+ # Identity and milestones
131
+
132
+ def identify(self, identity: VerifiedIdentity) -> None:
133
+ """Assert an identity the application has already authenticated.
134
+
135
+ Only inside a managed request or milestone. IDs are hashed before export.
136
+ """
137
+ if (
138
+ identity.kind not in PRINCIPAL_KINDS
139
+ or not identity.id
140
+ or not valid_text(identity.id, 1 << 20)
141
+ or (identity.acting_user_id is not None and not identity.acting_user_id)
142
+ or (identity.acting_user_id is not None and not valid_text(identity.acting_user_id, 1 << 20))
143
+ ):
144
+ raise ConfigurationError("Use a supported principal kind and nonempty, well-formed identity IDs.")
145
+ if suppressed():
146
+ return
147
+ state = self._runtime.state()
148
+ if state is None:
149
+ self._runtime.report("identity_outside_request", "context")
150
+ return
151
+ hashed = self._runtime.identity(identity)
152
+ if state.assert_identity(hashed):
153
+ # The first assertion stands; a different one is recorded as its own auth step.
154
+ span, _ = self._start("identity.asserted", MilestoneContext(surface="auth", kind="auth.identity"), hashed)
155
+ span.end()
156
+
157
+ def milestone(self, name: str, details: MilestoneContext) -> None:
158
+ """Record an instant step."""
159
+ self._validate_milestone(name, details)
160
+ if suppressed():
161
+ return
162
+ span, _ = self._start(name, details)
163
+ span.end()
164
+
165
+ def start_milestone(self, name: str, details: MilestoneContext) -> MilestoneScope:
166
+ """A step around a block: ``with client.start_milestone(...):``."""
167
+ return MilestoneScope(self, name, details)
168
+
169
+ def with_milestone(self, name: str, details: MilestoneContext) -> Callable[[F], F]:
170
+ """Decorate a function or coroutine function: each call is one step."""
171
+ self._validate_milestone(name, details)
172
+
173
+ def decorate(function: F) -> F:
174
+ if inspect.iscoroutinefunction(function):
175
+
176
+ @functools.wraps(function)
177
+ async def run_async(*args: Any, **kwargs: Any) -> Any:
178
+ async with MilestoneScope(self, name, details):
179
+ return await function(*args, **kwargs)
180
+
181
+ return cast(F, run_async)
182
+
183
+ @functools.wraps(function)
184
+ def run(*args: Any, **kwargs: Any) -> Any:
185
+ with MilestoneScope(self, name, details):
186
+ return function(*args, **kwargs)
187
+
188
+ return cast(F, run)
189
+
190
+ return decorate
191
+
192
+ def _validate_milestone(self, name: str, details: MilestoneContext) -> None:
193
+ if (
194
+ not name
195
+ or not details.kind
196
+ or not valid_text(name, 256)
197
+ or not valid_text(details.kind, 256)
198
+ or details.surface not in SURFACES
199
+ ):
200
+ raise ConfigurationError("Milestone name and kind must contain 1-256 characters, with a known surface.")
201
+
202
+ def _start(
203
+ self, name: str, details: MilestoneContext, identity: Attributes | None = None
204
+ ) -> tuple[Span, otel_context.Context]:
205
+ performance = None
206
+ if details.performance is not None:
207
+ try:
208
+ performance = performance_attributes(details.performance, self._runtime.options.input.hash_key)
209
+ except ConfigurationError:
210
+ self._runtime.report("invalid_performance", "context")
211
+ base = otel_context.get_current()
212
+ state = self._runtime.state(base)
213
+ if state is None:
214
+ external = trace.get_current_span(base).get_span_context()
215
+ links = [Link(clean_context(external))] if external.is_valid else []
216
+ state = RequestState(links=links)
217
+ # Its own managed scope: an unrelated caller's trace is a link, never the parent.
218
+ base = otel_context.set_value(self._runtime.state_key, state, otel_context.Context())
219
+ mark = Mark(name=name, details=details, performance=performance, identity=identity)
220
+ marked = otel_context.set_value(self._runtime.mark_key, mark, base)
221
+ parented = trace.get_current_span(base).get_span_context().is_valid
222
+ span = self._tracer.start_span(name, context=marked, links=[] if parented else state.links)
223
+ if not span.is_recording():
224
+ self._runtime.report("entry_not_recording", "traces")
225
+ return span, trace.set_span_in_context(span, base)
226
+
227
+ # Logs
228
+
229
+ def log(self, entry: StructuredLog) -> None:
230
+ """Record a structured log, correlated with the current managed span."""
231
+ if suppressed():
232
+ return
233
+ state = self._runtime.state()
234
+ if state is None and self._runtime.options.input.logs.exclude_uncorrelated:
235
+ return
236
+ span = None
237
+ if state is not None:
238
+ context = trace.get_current_span().get_span_context()
239
+ span = context if context.is_valid else None
240
+ now = time.time_ns()
241
+ self._logs.add(copy_log(entry, now, now, span, self._runtime))
242
+
243
+ # Delivery
244
+
245
+ def flush(self, timeout: float | None = None) -> None:
246
+ """Wait for delivery attempts of what was recorded so far. Not a delivery guarantee."""
247
+ limit = self._runtime.options.queue.flush_timeout if timeout is None else timeout
248
+ deadline = time.monotonic() + limit
249
+ self._spans.flush(limit)
250
+ self._logs.flush(max(0.0, deadline - time.monotonic()))
251
+
252
+ def shutdown(self) -> None:
253
+ """Drain once and stop. Terminal and idempotent."""
254
+ with self._shutdown_lock:
255
+ if self._closed:
256
+ return
257
+ self._closed = True
258
+ deadline = time.monotonic() + self._runtime.options.queue.shutdown_timeout
259
+ self._spans.shutdown()
260
+ self._logs.shutdown(max(0.0, deadline - time.monotonic()))
261
+ for stop in self._on_shutdown:
262
+ stop()
263
+
264
+ def diagnostics(self) -> Diagnostics:
265
+ return Diagnostics(self._spans.diagnostics(), self._logs.diagnostics())
266
+
267
+ # HTTP
268
+
269
+ def asgi(self, app: ASGIApp) -> AgentrailASGIMiddleware:
270
+ """Wrap an ASGI application (FastAPI, Starlette, Django ASGI, ...)."""
271
+ from .asgi import AgentrailASGIMiddleware
272
+
273
+ return AgentrailASGIMiddleware(app, client=self)
274
+
275
+ def wsgi(self, app: WSGIApp) -> AgentrailWSGIMiddleware:
276
+ """Wrap a WSGI application (Flask, Django WSGI, ...)."""
277
+ from .wsgi import AgentrailWSGIMiddleware
278
+
279
+ return AgentrailWSGIMiddleware(app, client=self)
280
+
281
+ def _managed(self) -> bool:
282
+ return not self._closed and not suppressed() and self._runtime.state() is not None
283
+
284
+ def _enter(self, request: IncomingRequest) -> tuple[Span, otel_context.Context] | None:
285
+ """Admit an incoming request: verify its handoff, then open its entry span."""
286
+ if self._closed or suppressed():
287
+ return None
288
+ if self._ignored(request):
289
+ return None
290
+ parent = request.headers.get("traceparent")
291
+ token = request.headers.get(HANDOFF_HEADER)
292
+ now = time.time_ns() // 1_000_000
293
+ state = RequestState()
294
+ ctx = otel_context.Context()
295
+ try:
296
+ remote = verify(self._runtime.options.input.hash_key, token, parent, request.method, request.target, now)
297
+ except Refused as refused:
298
+ external = parse_traceparent(parent)
299
+ if external is not None:
300
+ state.links.append(
301
+ Link(SpanContext(external.trace_id, external.span_id, True, TraceFlags(external.flags & 1)))
302
+ )
303
+ if refused.reason != "missing":
304
+ self._runtime.report("handoff_refused", "context")
305
+ else:
306
+ state.verified = True
307
+ remote_context = SpanContext(remote.trace_id, remote.span_id, True, TraceFlags(remote.flags & 1))
308
+ ctx = trace.set_span_in_context(NonRecordingSpan(remote_context), ctx)
309
+ state.location = self._location(request)
310
+ ctx = otel_context.set_value(self._runtime.state_key, state, ctx)
311
+ attributes: Attributes = {
312
+ "http.request.method": request.method,
313
+ "url.full": f"{request.scheme}://{request.host}{request.target}",
314
+ }
315
+ user_agent = request.headers.get("user-agent")
316
+ if user_agent:
317
+ attributes["user_agent.original"] = user_agent
318
+ span = self._tracer.start_span(
319
+ f"HTTP {request.method}", context=ctx, kind=SpanKind.SERVER, attributes=attributes
320
+ )
321
+ if not span.is_recording():
322
+ self._runtime.report("entry_not_recording", "traces")
323
+ return span, trace.set_span_in_context(span, ctx)
324
+
325
+ def _ignored(self, request: IncomingRequest) -> bool:
326
+ """A hook that raises never fails the request: it is reported, and recorded."""
327
+ hook = self._runtime.options.input.ignore_incoming_request
328
+ if hook is None:
329
+ return False
330
+ try:
331
+ return bool(hook(request))
332
+ except Exception: # noqa: BLE001 - the application's check must never fail its request
333
+ self._runtime.report("ignore_failed", "context")
334
+ return False
335
+
336
+ def _location(self, request: IncomingRequest) -> Attributes:
337
+ hook = self._runtime.options.input.incoming_location
338
+ if hook is None:
339
+ return {}
340
+ try:
341
+ location = hook(request)
342
+ except Exception: # noqa: BLE001 - the application's lookup must never fail its request
343
+ self._runtime.report("location_failed", "context")
344
+ return {}
345
+ if location is None:
346
+ return {}
347
+ places = (location.country, location.region, location.city)
348
+ if (
349
+ any(place is not None and not isinstance(place, str) for place in places) # pyright: ignore[reportUnnecessaryIsInstance]
350
+ or (location.country is not None and _COUNTRY.fullmatch(location.country) is None)
351
+ or location.source not in LOCATION_SOURCES
352
+ ):
353
+ self._runtime.report("location_failed", "context")
354
+ return {}
355
+ attributes: Attributes = {}
356
+ for name, value in zip(("country", "region", "city"), places, strict=True):
357
+ text = "" if value is None else bounded(value, 128)
358
+ if text:
359
+ attributes[f"agentrail.client.location.{name}"] = text
360
+ if attributes:
361
+ attributes["agentrail.client.location.source"] = location.source
362
+ return attributes
@@ -0,0 +1,214 @@
1
+ # Copyright 2026 Agentrail. Licensed under the Apache License, Version 2.0.
2
+ """The signed trace handoff, keyed hashes and response markers.
3
+
4
+ Byte-compatible with agentrail-core (JavaScript) and agentrail-go: the same key
5
+ ID, the same identity hash, the same handoff payload and token, and the same
6
+ marker reading. ``tests/test_interoperability.py`` holds this to the shared
7
+ vectors in ``testdata/interoperability``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import hmac
14
+ import re
15
+ from dataclasses import dataclass
16
+
17
+ from ._text import js_json, well_formed
18
+
19
+ HANDOFF_HEADER = "x-agentrail-trace-handoff"
20
+ # Headers a managed request never forwards as received.
21
+ OWNED_HEADERS = ("traceparent", "tracestate", "baggage", "sentry-trace", HANDOFF_HEADER)
22
+ MAX_AGE_MS = 60_000
23
+ _MAX_SAFE_INTEGER = 9_007_199_254_740_991
24
+
25
+ _HEX16 = re.compile(r"[0-9a-f]{16}")
26
+ _HEX64 = re.compile(r"[0-9a-f]{64}")
27
+ _TIME = re.compile(r"0|[1-9][0-9]{0,15}")
28
+ _TRACEPARENT = re.compile(r"00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})")
29
+ _INVALID_TRACE = "0" * 32
30
+ _INVALID_SPAN = "0" * 16
31
+ # ECMAScript String.prototype.trim whitespace, which core's parser applies.
32
+ _JS_WHITESPACE = "".join(
33
+ chr(code)
34
+ for code in (
35
+ *range(0x09, 0x0E),
36
+ 0x20,
37
+ 0xA0,
38
+ 0x1680,
39
+ *range(0x2000, 0x200B),
40
+ 0x2028,
41
+ 0x2029,
42
+ 0x202F,
43
+ 0x205F,
44
+ 0x3000,
45
+ 0xFEFF,
46
+ )
47
+ )
48
+ KELVIN_SIGN, LONG_S = chr(0x212A), chr(0x017F)
49
+ _METHOD_SYMBOLS = frozenset("!#$%&'*+.^_`|~-")
50
+
51
+
52
+ def key_id(hash_key: str) -> str:
53
+ """The first 16 lowercase hex characters of the key's SHA-256."""
54
+ return hashlib.sha256(hash_key.encode("utf-8")).hexdigest()[:16]
55
+
56
+
57
+ def keyed_hash(hash_key: str, value: str) -> str:
58
+ """HMAC-SHA256 of the value under the workspace key, first 32 hex characters."""
59
+ return _mac(hash_key, value.encode("utf-8")).hex()[:32]
60
+
61
+
62
+ def _mac(hash_key: str, payload: bytes) -> bytes:
63
+ return hmac.new(hash_key.encode("utf-8"), payload, hashlib.sha256).digest()
64
+
65
+
66
+ def _js_trim(value: str) -> str:
67
+ return value.strip(_JS_WHITESPACE)
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class TraceParent:
72
+ trace_id: int
73
+ span_id: int
74
+ flags: int
75
+
76
+
77
+ def parse_traceparent(header: str | None) -> TraceParent | None:
78
+ """Version-00 ``traceparent``, as core reads it: trimmed, lowercase, no zero IDs."""
79
+ if header is None:
80
+ return None
81
+ match = _TRACEPARENT.fullmatch(_js_trim(header))
82
+ if match is None:
83
+ return None
84
+ trace_hex, span_hex, flags = match.groups()
85
+ if trace_hex == _INVALID_TRACE or span_hex == _INVALID_SPAN:
86
+ return None
87
+ return TraceParent(int(trace_hex, 16), int(span_hex, 16), int(flags, 16))
88
+
89
+
90
+ def format_traceparent(trace_id: int, span_id: int, flags: int) -> str:
91
+ return f"00-{trace_id:032x}-{span_id:016x}-{flags & 0xFF:02x}"
92
+
93
+
94
+ def _valid_method(method: str) -> bool:
95
+ if not 1 <= len(method) <= 64:
96
+ return False
97
+ for character in method:
98
+ # JavaScript /[a-z]/iu also folds the Kelvin sign and the long s.
99
+ if (
100
+ character in (KELVIN_SIGN, LONG_S)
101
+ or ("a" <= character <= "z")
102
+ or ("A" <= character <= "Z")
103
+ or ("0" <= character <= "9")
104
+ or character in _METHOD_SYMBOLS
105
+ ):
106
+ continue
107
+ return False
108
+ return True
109
+
110
+
111
+ def _valid_request(method: str, target: str) -> bool:
112
+ return (
113
+ _valid_method(method)
114
+ and target.startswith("/")
115
+ and well_formed(target)
116
+ and not any(character in target for character in "\x00\r\n#")
117
+ )
118
+
119
+
120
+ def _payload(hash_key_id: str, issued_at: int, traceparent: str, method: str, target: str) -> bytes:
121
+ body = js_json([1, hash_key_id, issued_at, traceparent, method, target])
122
+ return f"agentrail.trace-handoff\x00{body}".encode()
123
+
124
+
125
+ class HandoffInputError(ValueError):
126
+ """The values to sign do not form a valid handoff."""
127
+
128
+
129
+ def sign(hash_key: str, traceparent: str, method: str, target: str, issued_at: int) -> str:
130
+ """A token binding the trace context to the exact method and wire target."""
131
+ if (
132
+ not well_formed(hash_key)
133
+ or not _valid_request(method, target)
134
+ or not 0 <= issued_at <= _MAX_SAFE_INTEGER
135
+ or traceparent != _js_trim(traceparent)
136
+ or parse_traceparent(traceparent) is None
137
+ ):
138
+ raise HandoffInputError
139
+ identifier = key_id(hash_key)
140
+ signature = _mac(hash_key, _payload(identifier, issued_at, traceparent, method, target))
141
+ return f"ah1.{identifier}.{issued_at}.{traceparent}.{signature.hex()}"
142
+
143
+
144
+ class Refused(Exception):
145
+ """Why a handoff did not verify. The reason is one of core's refusal names."""
146
+
147
+ def __init__(self, reason: str) -> None:
148
+ super().__init__(reason)
149
+ self.reason = reason
150
+
151
+
152
+ def verify(
153
+ hash_key: str,
154
+ token: str | None,
155
+ traceparent: str | None,
156
+ method: str,
157
+ target: str,
158
+ now: int,
159
+ ) -> TraceParent:
160
+ """The remote parent of a valid handoff; raises ``Refused`` otherwise."""
161
+ if token is None or token == "":
162
+ raise Refused("missing")
163
+ if len(token) > 256 or not _valid_request(method, target) or not well_formed(hash_key):
164
+ raise Refused("malformed")
165
+ parts = token.split(".")
166
+ if (
167
+ len(parts) != 5
168
+ or parts[0] != "ah1"
169
+ or not _HEX16.fullmatch(parts[1])
170
+ or not _TIME.fullmatch(parts[2])
171
+ or not _HEX64.fullmatch(parts[4])
172
+ ):
173
+ raise Refused("malformed")
174
+ issued_at = int(parts[2])
175
+ parent = parse_traceparent(parts[3]) if parts[3] == _js_trim(parts[3]) else None
176
+ if issued_at > _MAX_SAFE_INTEGER or parent is None:
177
+ raise Refused("malformed")
178
+ if parts[1] != key_id(hash_key):
179
+ raise Refused("key_mismatch")
180
+ if parts[3] != traceparent:
181
+ raise Refused("context_mismatch")
182
+ age = now - issued_at
183
+ if age < 0:
184
+ raise Refused("future")
185
+ if age > MAX_AGE_MS:
186
+ raise Refused("expired")
187
+ expected = _mac(hash_key, _payload(parts[1], issued_at, parts[3], method, target))
188
+ if not hmac.compare_digest(bytes.fromhex(parts[4]), expected):
189
+ raise Refused("signature_invalid")
190
+ return parent
191
+
192
+
193
+ _ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
194
+
195
+
196
+ def valid_marker(raw: str) -> bool:
197
+ """The collector's marker reading: Crockford folding, then a Luhn mod-32 check."""
198
+ # str.upper applies full Unicode case mapping, as String.prototype.toUpperCase does.
199
+ text = raw.upper().replace("-", "").replace("I", "1").replace("L", "1").replace("O", "0")
200
+ if len(text) not in (14, 16):
201
+ return False
202
+ values: list[int] = []
203
+ for character in text:
204
+ value = _ALPHABET.find(character)
205
+ if value < 0:
206
+ return False
207
+ values.append(value)
208
+ check = values.pop()
209
+ total, factor = 0, 2
210
+ for value in reversed(values):
211
+ addend = factor * value
212
+ total += addend // 32 + addend % 32
213
+ factor = 1 if factor == 2 else 2
214
+ return check == (32 - total % 32) % 32