forkreduce 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,173 @@
1
+ """Documents and blocks frozen into the shared prefix."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from dataclasses import dataclass, field
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+ from forkreduce.errors import ForkReduceError
11
+ from forkreduce.textutil import strip_control_chars
12
+
13
+ END_MARKER = "===END_FIXED_DOCUMENTS==="
14
+
15
+
16
+ class Block(BaseModel):
17
+ """One addressable span of a document.
18
+
19
+ Citation checks look up ``block_id`` and search for a quote inside ``text``.
20
+ """
21
+
22
+ block_id: str
23
+ text: str
24
+ meta: dict[str, str] = Field(default_factory=dict)
25
+
26
+
27
+ class Document(BaseModel):
28
+ """A document made of blocks. ``doc_id`` is stable and appears in the rendered prefix."""
29
+
30
+ doc_id: str
31
+ title: str = ""
32
+ blocks: list[Block]
33
+
34
+
35
+ @dataclass
36
+ class DocumentIndex:
37
+ """Cleaned documents plus the exact text that is frozen into the prefix."""
38
+
39
+ documents: list[Document]
40
+ blocks: dict[str, Block] = field(default_factory=dict)
41
+ rendered: str = ""
42
+
43
+ def block_text(self, block_id: str) -> str | None:
44
+ block = self.blocks.get(block_id)
45
+ if block is None:
46
+ return None
47
+ return block.text
48
+
49
+
50
+ DocumentsInput = str | Document | Block | Sequence[str | Document | Block]
51
+
52
+
53
+ def coerce_documents(documents: DocumentsInput) -> DocumentIndex:
54
+ """Normalize caller input into cleaned blocks and a stable rendering.
55
+
56
+ A bare string becomes one document with a single block ``b1``. A sequence of
57
+ strings becomes blocks ``b1``, ``b2``, ... inside one document. Control
58
+ characters are stripped before the text is hashed or shown to the model, so a
59
+ null byte cannot break JSON tool arguments later.
60
+ """
61
+ docs = _coerce_many(documents)
62
+ if not docs:
63
+ raise ForkReduceError("documents must contain at least one block of text")
64
+ blocks: dict[str, Block] = {}
65
+ for doc in docs:
66
+ if not doc.blocks:
67
+ raise ForkReduceError(f"document {doc.doc_id!r} has no blocks")
68
+ for block in doc.blocks:
69
+ if not block.block_id:
70
+ raise ForkReduceError("block_id must be a non-empty string")
71
+ if block.block_id in blocks:
72
+ raise ForkReduceError(f"duplicate block_id {block.block_id!r}")
73
+ if not block.text.strip():
74
+ raise ForkReduceError(f"block {block.block_id!r} is empty after cleaning")
75
+ blocks[block.block_id] = block
76
+ rendered = render_documents(docs)
77
+ return DocumentIndex(documents=docs, blocks=blocks, rendered=rendered)
78
+
79
+
80
+ def render_documents(documents: Sequence[Document]) -> str:
81
+ """Render documents into the frozen prefix body, ending with :data:`END_MARKER`."""
82
+ lines: list[str] = []
83
+ for doc in documents:
84
+ title = f" title={doc.title}" if doc.title else ""
85
+ lines.append(f"[document id={doc.doc_id}{title}]")
86
+ for block in doc.blocks:
87
+ lines.append(f"[block {block.block_id}]")
88
+ lines.append(block.text)
89
+ lines.append("")
90
+ lines.append(END_MARKER)
91
+ return "\n".join(lines)
92
+
93
+
94
+ def _coerce_many(documents: DocumentsInput) -> list[Document]:
95
+ if isinstance(documents, str):
96
+ return [_string_document("doc", [documents])]
97
+ if isinstance(documents, Document):
98
+ return [_clean_document(documents)]
99
+ if isinstance(documents, Block):
100
+ return [_blocks_document("doc", [documents])]
101
+ if isinstance(documents, Sequence):
102
+ # A homogeneous sequence keeps the historical single-document shape.
103
+ # A mixed sequence keeps the caller's order: consecutive loose blocks
104
+ # or strings become one generated document at that position.
105
+ if documents and all(isinstance(item, str) for item in documents):
106
+ return [_string_document("doc", [item for item in documents if isinstance(item, str)])]
107
+ if documents and all(isinstance(item, Block) for item in documents):
108
+ return [_blocks_document("doc", [item for item in documents if isinstance(item, Block)])]
109
+ docs: list[Document] = []
110
+ pending_blocks: list[Block] = []
111
+ pending_strings: list[str] = []
112
+
113
+ def flush() -> None:
114
+ nonlocal pending_blocks, pending_strings
115
+ if pending_blocks:
116
+ docs.append(_blocks_document(_generated_doc_id(docs), pending_blocks))
117
+ pending_blocks = []
118
+ if pending_strings:
119
+ docs.append(_string_document(_generated_doc_id(docs), pending_strings))
120
+ pending_strings = []
121
+
122
+ for item in documents:
123
+ if isinstance(item, str):
124
+ if pending_blocks:
125
+ flush()
126
+ pending_strings.append(item)
127
+ elif isinstance(item, Block):
128
+ if pending_strings:
129
+ flush()
130
+ pending_blocks.append(item)
131
+ elif isinstance(item, Document):
132
+ flush()
133
+ docs.append(_clean_document(item))
134
+ else:
135
+ raise ForkReduceError("documents must be a string, Block, Document, or a sequence of those")
136
+ flush()
137
+ return docs
138
+ raise ForkReduceError("documents must be a string, Block, Document, or a sequence of those")
139
+
140
+
141
+ def _generated_doc_id(existing: list[Document]) -> str:
142
+ used = {doc.doc_id for doc in existing}
143
+ if "doc" not in used:
144
+ return "doc"
145
+ number = 2
146
+ while f"doc-{number}" in used:
147
+ number += 1
148
+ return f"doc-{number}"
149
+
150
+
151
+ def _string_document(doc_id: str, parts: list[str]) -> Document:
152
+ blocks: list[Block] = []
153
+ for index, part in enumerate(parts, start=1):
154
+ blocks.append(Block(block_id=f"b{index}", text=strip_control_chars(part)))
155
+ return Document(doc_id=doc_id, blocks=blocks)
156
+
157
+
158
+ def _blocks_document(doc_id: str, blocks: Sequence[Block]) -> Document:
159
+ cleaned = [
160
+ Block(block_id=block.block_id, text=strip_control_chars(block.text), meta=dict(block.meta)) for block in blocks
161
+ ]
162
+ return Document(doc_id=doc_id, blocks=cleaned)
163
+
164
+
165
+ def _clean_document(document: Document) -> Document:
166
+ return Document(
167
+ doc_id=document.doc_id,
168
+ title=strip_control_chars(document.title),
169
+ blocks=[
170
+ Block(block_id=block.block_id, text=strip_control_chars(block.text), meta=dict(block.meta))
171
+ for block in document.blocks
172
+ ],
173
+ )
forkreduce/errors.py ADDED
@@ -0,0 +1,39 @@
1
+ """Errors raised for caller mistakes.
2
+
3
+ Model, transport, and budget failures are returned as :class:`forkreduce.failures.Failure`
4
+ on a result object. The exceptions in this module mean the caller asked for something the
5
+ session cannot do, and they are raised before a model call when that is possible.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+
11
+ class ForkReduceError(Exception):
12
+ """Base class for forkreduce caller errors.
13
+
14
+ ``code`` is an optional machine-readable label. A provider context-length
15
+ error is a :class:`forkreduce.failures.Failure` with code ``context_overflow``,
16
+ not an exception from this class.
17
+ """
18
+
19
+ code: str
20
+
21
+ def __init__(self, message: str, *, code: str = "") -> None:
22
+ super().__init__(message)
23
+ self.code = code
24
+
25
+
26
+ class UnregisteredOutputError(ForkReduceError):
27
+ """A query or reduce asked for an output type that was not registered at build time.
28
+
29
+ Registering it later would change the tool set and invalidate the shared prefix cache,
30
+ so the call fails immediately instead of silently sending a different schema.
31
+ """
32
+
33
+
34
+ class PrefixMismatchWarning(UserWarning):
35
+ """The supplied message history does not start with this session's frozen prefix.
36
+
37
+ The call still runs. Provider prefix caches will miss, because the shared head of the
38
+ prompt is no longer identical to the other branches.
39
+ """
forkreduce/executor.py ADDED
@@ -0,0 +1,200 @@
1
+ """Single-attempt execution and the default local executor.
2
+
3
+ :class:`LocalExecutor` is what :meth:`Session.ask` uses: a semaphore, bounded
4
+ retries, and backoff for transport errors. One attempt lives on
5
+ :meth:`Session.run_once` so a Temporal activity can do the same call and let
6
+ Temporal own the retry.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from collections.abc import AsyncIterator
13
+ from contextlib import asynccontextmanager
14
+ from dataclasses import dataclass, replace
15
+ from typing import TYPE_CHECKING, Any, Protocol
16
+
17
+ from pydantic_ai.usage import RunUsage
18
+
19
+ from forkreduce.failures import Failure, budget_failure
20
+ from forkreduce.policy import Accept, BudgetReport, Fail, Policy
21
+ from forkreduce.query import Query, QueryResult
22
+ from forkreduce.stats import add_usage
23
+
24
+ if TYPE_CHECKING:
25
+ from forkreduce.session import Session
26
+
27
+
28
+ class Executor(Protocol):
29
+ """Runs one query to a :class:`QueryResult`, including whatever retries it owns."""
30
+
31
+ async def run_query(self, session: Session, query: Query[Any]) -> QueryResult[Any]:
32
+ """Execute ``query`` against ``session``."""
33
+ ...
34
+
35
+
36
+ @dataclass
37
+ class BudgetLedger:
38
+ policy: Policy
39
+ requests: int = 0
40
+ input_tokens: int = 0
41
+ output_tokens: int = 0
42
+ _lock: asyncio.Lock | None = None
43
+ _token_gate: asyncio.Lock | None = None
44
+
45
+ def _lock_for(self) -> asyncio.Lock:
46
+ if self._lock is None:
47
+ self._lock = asyncio.Lock()
48
+ return self._lock
49
+
50
+ async def try_reserve(self) -> BudgetReport | None:
51
+ budget = self.policy.budget
52
+ if budget is None:
53
+ return None
54
+ async with self._lock_for():
55
+ problem = self._problem()
56
+ if problem is not None:
57
+ return problem
58
+ self.requests += 1
59
+ return None
60
+
61
+ async def force_reserve(self) -> None:
62
+ async with self._lock_for():
63
+ self.requests += 1
64
+
65
+ async def add_usage(self, usage: RunUsage) -> None:
66
+ async with self._lock_for():
67
+ self.input_tokens += usage.input_tokens
68
+ self.output_tokens += usage.output_tokens
69
+
70
+ def limits_tokens(self) -> bool:
71
+ """Whether a token ceiling requires calls to run one at a time."""
72
+ budget = self.policy.budget
73
+ if budget is None:
74
+ return False
75
+ return any(
76
+ value is not None for value in (budget.max_input_tokens, budget.max_output_tokens, budget.max_total_tokens)
77
+ )
78
+
79
+ @asynccontextmanager
80
+ async def token_turn(self) -> AsyncIterator[None]:
81
+ """Hold the token gate for one call when a token ceiling is set.
82
+
83
+ Request-only budgets do not take this gate, so they stay concurrent.
84
+ """
85
+ if not self.limits_tokens():
86
+ yield
87
+ return
88
+ async with self._lock_for():
89
+ if self._token_gate is None:
90
+ self._token_gate = asyncio.Lock()
91
+ gate = self._token_gate
92
+ await gate.acquire()
93
+ try:
94
+ yield
95
+ finally:
96
+ gate.release()
97
+
98
+ def _problem(self) -> BudgetReport | None:
99
+ budget = self.policy.budget
100
+ if budget is None:
101
+ return None
102
+ message: str | None = None
103
+ if budget.max_requests is not None and self.requests >= budget.max_requests:
104
+ message = f"request budget exhausted ({self.requests}/{budget.max_requests})"
105
+ elif budget.max_input_tokens is not None and self.input_tokens >= budget.max_input_tokens:
106
+ message = f"input token budget exhausted ({self.input_tokens}/{budget.max_input_tokens})"
107
+ elif budget.max_output_tokens is not None and self.output_tokens >= budget.max_output_tokens:
108
+ message = f"output token budget exhausted ({self.output_tokens}/{budget.max_output_tokens})"
109
+ elif budget.max_total_tokens is not None and (
110
+ self.input_tokens + self.output_tokens >= budget.max_total_tokens
111
+ ):
112
+ total = self.input_tokens + self.output_tokens
113
+ message = f"total token budget exhausted ({total}/{budget.max_total_tokens})"
114
+ if message is None:
115
+ return None
116
+ return BudgetReport(
117
+ kind="budget",
118
+ message=message,
119
+ requests=self.requests,
120
+ input_tokens=self.input_tokens,
121
+ output_tokens=self.output_tokens,
122
+ )
123
+
124
+
125
+ class LocalExecutor:
126
+ """asyncio executor with a concurrency semaphore and bounded retries.
127
+
128
+ Validation failures retry immediately. Transport and rate-limit failures
129
+ sleep with exponential backoff. Permanent failures and budget exhaustion
130
+ are returned on the result and are not retried.
131
+ """
132
+
133
+ def __init__(self, policy: Policy) -> None:
134
+ self.policy = policy
135
+ self._semaphore = asyncio.Semaphore(policy.concurrency)
136
+
137
+ async def run_query(self, session: Session, query: Query[Any]) -> QueryResult[Any]:
138
+ async with self._semaphore:
139
+ return await self._run_with_retries(session, query)
140
+
141
+ async def _run_with_retries(self, session: Session, query: Query[Any]) -> QueryResult[Any]:
142
+ usage = RunUsage()
143
+ cache = 0
144
+ elapsed = 0.0
145
+ last: QueryResult[object] | None = None
146
+ attempts = 0
147
+ for attempt in range(self.policy.retries + 1):
148
+ attempts = attempt + 1
149
+ result = await session.run_once(query)
150
+ usage = add_usage(usage, result.usage)
151
+ cache += result.cache_hit_tokens
152
+ elapsed += result.elapsed_ms
153
+ last = result
154
+ # A budget rejection happens before any model call.
155
+ if result.attempts == 0:
156
+ return self._folded(result, usage, cache, elapsed, 0)
157
+ if result.failure is None:
158
+ return self._folded(result, usage, cache, elapsed, attempts)
159
+ if not result.failure.retryable or attempt >= self.policy.retries:
160
+ return self._folded(result, usage, cache, elapsed, attempts)
161
+ if result.failure.kind.value == "transport":
162
+ await self._sleep(attempt + 1)
163
+ assert last is not None
164
+ return self._folded(last, usage, cache, elapsed, attempts)
165
+
166
+ def _folded(
167
+ self,
168
+ result: QueryResult[object],
169
+ usage: RunUsage,
170
+ cache: int,
171
+ elapsed: float,
172
+ attempts: int,
173
+ ) -> QueryResult[object]:
174
+ failure = result.failure.with_attempts(attempts) if result.failure is not None else None
175
+ return replace(
176
+ result,
177
+ usage=usage,
178
+ cache_hit_tokens=cache,
179
+ elapsed_ms=elapsed,
180
+ attempts=attempts,
181
+ failure=failure,
182
+ )
183
+
184
+ async def _sleep(self, attempt: int) -> None:
185
+ delay = min(self.policy.retry_backoff * (2 ** (attempt - 1)), self.policy.max_backoff)
186
+ if delay > 0:
187
+ await asyncio.sleep(delay)
188
+
189
+
190
+ def budget_action_failure(report: BudgetReport, policy: Policy) -> Failure | None:
191
+ """Apply the budget policy. ``None`` means the call may proceed anyway."""
192
+ action: Accept | Fail
193
+ if policy.budget_policy is None:
194
+ action = Fail(report.message)
195
+ else:
196
+ decided = policy.budget_policy(report)
197
+ if isinstance(decided, Accept):
198
+ return None
199
+ action = decided if isinstance(decided, Fail) else Fail(report.message)
200
+ return budget_failure(action.reason)
forkreduce/failures.py ADDED
@@ -0,0 +1,239 @@
1
+ """Failure classification.
2
+
3
+ Every model call ends as either a value or a :class:`Failure`. Nothing is dropped
4
+ on the floor: transport and validation failures are retryable up to the policy
5
+ bound, and permanent failures are returned to the caller.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from enum import StrEnum
14
+ from typing import Any
15
+
16
+ from pydantic import ValidationError
17
+ from pydantic_ai.exceptions import (
18
+ ModelAPIError,
19
+ ModelHTTPError,
20
+ UnexpectedModelBehavior,
21
+ UsageLimitExceeded,
22
+ UserError,
23
+ )
24
+
25
+ try:
26
+ import httpx
27
+ except ImportError: # pragma: no cover - pydantic-ai always brings an http client
28
+ httpx = None # type: ignore[assignment]
29
+
30
+
31
+ class FailureKind(StrEnum):
32
+ """Coarse class used for retry decisions and Temporal non-retryable mapping."""
33
+
34
+ VALIDATION = "validation"
35
+ TRANSPORT = "transport"
36
+ PERMANENT = "permanent"
37
+ BUDGET = "budget"
38
+
39
+
40
+ # 408/429 and the usual gateway/unavailable statuses are worth another attempt.
41
+ # Other 4xx responses (auth, bad request, not found) will not succeed if repeated.
42
+ _RETRYABLE_STATUS = frozenset({408, 409, 425, 429, 500, 502, 503, 504})
43
+
44
+
45
+ @dataclass
46
+ class Failure:
47
+ """A classified failure attached to a query or a run.
48
+
49
+ ``retryable`` is the executor's signal. Temporal maps it to
50
+ ``ApplicationError(non_retryable=not retryable)``.
51
+ """
52
+
53
+ kind: FailureKind
54
+ message: str
55
+ retryable: bool
56
+ code: str = ""
57
+ attempts: int = 1
58
+ detail: dict[str, Any] = field(default_factory=dict)
59
+
60
+ def with_attempts(self, attempts: int) -> Failure:
61
+ return Failure(
62
+ kind=self.kind,
63
+ message=self.message,
64
+ retryable=self.retryable,
65
+ code=self.code,
66
+ attempts=attempts,
67
+ detail=dict(self.detail),
68
+ )
69
+
70
+
71
+ def classify_exception(exc: BaseException) -> Failure:
72
+ """Map an exception from a model call onto a :class:`Failure`."""
73
+ message = _short_message(exc)
74
+ if isinstance(exc, ModelHTTPError):
75
+ overflow = _context_overflow_from_provider(exc)
76
+ if overflow is not None:
77
+ return overflow
78
+ retryable = exc.status_code in _RETRYABLE_STATUS or exc.status_code >= 500
79
+ kind = FailureKind.TRANSPORT if retryable else FailureKind.PERMANENT
80
+ return Failure(
81
+ kind=kind,
82
+ message=message,
83
+ retryable=retryable,
84
+ code=f"http_{exc.status_code}",
85
+ detail={"status_code": exc.status_code},
86
+ )
87
+ if isinstance(exc, ModelAPIError):
88
+ return Failure(
89
+ kind=FailureKind.TRANSPORT,
90
+ message=message,
91
+ retryable=True,
92
+ code="transport",
93
+ )
94
+ if httpx is not None and isinstance(exc, (httpx.TimeoutException, httpx.TransportError)):
95
+ return Failure(
96
+ kind=FailureKind.TRANSPORT,
97
+ message=message,
98
+ retryable=True,
99
+ code="transport",
100
+ )
101
+ if isinstance(exc, (TimeoutError, asyncio.TimeoutError, ConnectionError)):
102
+ return Failure(
103
+ kind=FailureKind.TRANSPORT,
104
+ message=message,
105
+ retryable=True,
106
+ code="transport",
107
+ )
108
+ if isinstance(exc, (UnexpectedModelBehavior, ValidationError, UsageLimitExceeded)):
109
+ return Failure(
110
+ kind=FailureKind.VALIDATION,
111
+ message=message,
112
+ retryable=True,
113
+ code="validation",
114
+ )
115
+ if isinstance(exc, UserError):
116
+ return Failure(
117
+ kind=FailureKind.PERMANENT,
118
+ message=message,
119
+ retryable=False,
120
+ code="user_error",
121
+ )
122
+ return Failure(
123
+ kind=FailureKind.PERMANENT,
124
+ message=message,
125
+ retryable=False,
126
+ code="unexpected",
127
+ detail={"exception_type": type(exc).__name__},
128
+ )
129
+
130
+
131
+ def wrong_output_failure(expected: str, actual: str) -> Failure:
132
+ """The model called a registered output tool other than the one this query asked for."""
133
+ return Failure(
134
+ kind=FailureKind.VALIDATION,
135
+ message=f"Expected output {expected}, got {actual}",
136
+ retryable=True,
137
+ code="wrong_output_type",
138
+ detail={"expected": expected, "actual": actual},
139
+ )
140
+
141
+
142
+ def context_overflow_failure(
143
+ *,
144
+ estimated_tokens: int | None,
145
+ limit: int | None,
146
+ where: str,
147
+ reported_tokens: int | None = None,
148
+ ) -> Failure:
149
+ """Permanent failure when the provider reports that one prompt does not fit.
150
+
151
+ The local character estimate never produces this failure. ``where`` is
152
+ ``provider``. The library does not truncate or split the prompt.
153
+ """
154
+ size = reported_tokens if reported_tokens is not None else estimated_tokens
155
+ size_label = "reported" if reported_tokens is not None else "estimated"
156
+ size_text = "unknown" if size is None else str(size)
157
+ limit_text = "unknown" if limit is None else str(limit)
158
+ detail: dict[str, Any] = {"where": where}
159
+ if estimated_tokens is not None:
160
+ detail["estimated_tokens"] = estimated_tokens
161
+ if reported_tokens is not None:
162
+ detail["reported_tokens"] = reported_tokens
163
+ if limit is not None:
164
+ detail["limit"] = limit
165
+ return Failure(
166
+ kind=FailureKind.PERMANENT,
167
+ message=(f"context overflow: {size_label} {size_text} tokens exceeds context limit {limit_text} ({where})"),
168
+ retryable=False,
169
+ code="context_overflow",
170
+ detail=detail,
171
+ )
172
+
173
+
174
+ def budget_failure(message: str) -> Failure:
175
+ return Failure(
176
+ kind=FailureKind.BUDGET,
177
+ message=message,
178
+ retryable=False,
179
+ code="budget_exceeded",
180
+ )
181
+
182
+
183
+ _OVERFLOW_MARKERS = (
184
+ "context_length_exceeded",
185
+ "context_overflow",
186
+ "maximum context length",
187
+ "context window",
188
+ "too many tokens",
189
+ "prompt is too long",
190
+ "input is too long",
191
+ "exceeds the context",
192
+ "exceeds the model's maximum context",
193
+ )
194
+
195
+
196
+ def _context_overflow_from_provider(exc: ModelHTTPError) -> Failure | None:
197
+ text = _provider_error_text(exc)
198
+ if not any(marker in text.lower() for marker in _OVERFLOW_MARKERS):
199
+ return None
200
+ reported, reported_limit = _reported_context_sizes(text)
201
+ failure = context_overflow_failure(
202
+ estimated_tokens=None,
203
+ limit=reported_limit,
204
+ where="provider",
205
+ reported_tokens=reported,
206
+ )
207
+ failure.detail["status_code"] = exc.status_code
208
+ return failure
209
+
210
+
211
+ def _provider_error_text(exc: ModelHTTPError) -> str:
212
+ body = exc.body
213
+ if isinstance(body, str):
214
+ rendered = body
215
+ elif body is None:
216
+ rendered = ""
217
+ else:
218
+ rendered = str(body)
219
+ return f"{exc} {rendered}"
220
+
221
+
222
+ def _reported_context_sizes(text: str) -> tuple[int | None, int | None]:
223
+ """Pull ``(requested tokens, window)`` out of a provider error, when present."""
224
+ match = re.search(
225
+ r"maximum context length is (\d+).*?requested (\d+)",
226
+ text,
227
+ flags=re.IGNORECASE | re.DOTALL,
228
+ )
229
+ if match is None:
230
+ return None, None
231
+ return int(match.group(2)), int(match.group(1))
232
+
233
+
234
+ def _short_message(exc: BaseException) -> str:
235
+ text = str(exc).strip() or type(exc).__name__
236
+ text = " ".join(text.split())
237
+ if len(text) > 500:
238
+ text = text[:500] + "..."
239
+ return f"{type(exc).__name__}: {text}"