responsible-request 0.4.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.
- responsible_request/__init__.py +94 -0
- responsible_request/_http.py +31 -0
- responsible_request/analysis.py +81 -0
- responsible_request/cache.py +59 -0
- responsible_request/client.py +169 -0
- responsible_request/config.py +266 -0
- responsible_request/controller.py +79 -0
- responsible_request/cost.py +96 -0
- responsible_request/estimator.py +113 -0
- responsible_request/helpers/__init__.py +21 -0
- responsible_request/helpers/batch.py +127 -0
- responsible_request/helpers/messages.py +18 -0
- responsible_request/helpers/params.py +18 -0
- responsible_request/helpers/structured.py +150 -0
- responsible_request/limiter.py +165 -0
- responsible_request/logfiles.py +120 -0
- responsible_request/logging.py +183 -0
- responsible_request/providers.py +105 -0
- responsible_request/py.typed +0 -0
- responsible_request/records.py +265 -0
- responsible_request/runs.py +116 -0
- responsible_request/sources.py +207 -0
- responsible_request/throttle.py +229 -0
- responsible_request/transport.py +376 -0
- responsible_request-0.4.0.dist-info/METADATA +319 -0
- responsible_request-0.4.0.dist-info/RECORD +28 -0
- responsible_request-0.4.0.dist-info/WHEEL +4 -0
- responsible_request-0.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Running many requests and calibrating the latency baseline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import statistics
|
|
7
|
+
import uuid
|
|
8
|
+
from collections.abc import Awaitable, Callable, Iterable, Sequence
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import openai
|
|
12
|
+
|
|
13
|
+
from ..client import get_throttle
|
|
14
|
+
from ..config import ThrottleConfig
|
|
15
|
+
from ..cost import BudgetExceeded
|
|
16
|
+
from ..estimator import LatencyEstimator, metric_value
|
|
17
|
+
from ..records import tags
|
|
18
|
+
|
|
19
|
+
Call = Callable[..., Awaitable[Any]]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def run_batch(
|
|
23
|
+
client: openai.AsyncOpenAI,
|
|
24
|
+
requests: Iterable[dict[str, Any]],
|
|
25
|
+
*,
|
|
26
|
+
call: Call | None = None,
|
|
27
|
+
progress: bool = False,
|
|
28
|
+
return_exceptions: bool = True,
|
|
29
|
+
batch_id: str | None = None,
|
|
30
|
+
) -> list[Any]:
|
|
31
|
+
"""Run many requests concurrently and return their results in input order.
|
|
32
|
+
|
|
33
|
+
The throttle paces the requests, so all of them can be submitted at once. Each request
|
|
34
|
+
record is tagged with ``batch_id`` and ``item_index`` for joining logs with results.
|
|
35
|
+
Once a cost budget is exhausted, the remaining requests fail fast with
|
|
36
|
+
:class:`responsible_request.BudgetExceeded`.
|
|
37
|
+
|
|
38
|
+
:param requests: Keyword arguments for ``call``, one dict per request.
|
|
39
|
+
:param call: Coroutine function to call, defaults to ``client.chat.completions.create``.
|
|
40
|
+
:param progress: Show a tqdm progress bar (requires the ``progress`` extra).
|
|
41
|
+
:param return_exceptions: Put exceptions into the result list instead of raising the first.
|
|
42
|
+
:param batch_id: Tag value, a random id by default.
|
|
43
|
+
"""
|
|
44
|
+
call = call or client.chat.completions.create
|
|
45
|
+
items: Sequence[dict[str, Any]] = list(requests)
|
|
46
|
+
batch_id = batch_id or uuid.uuid4().hex[:12]
|
|
47
|
+
bar = _progress_bar(len(items)) if progress else None
|
|
48
|
+
|
|
49
|
+
async def one(index: int, kwargs: dict[str, Any]) -> Any:
|
|
50
|
+
try:
|
|
51
|
+
with tags(batch_id=batch_id, item_index=index):
|
|
52
|
+
return await call(**kwargs)
|
|
53
|
+
except openai.APIConnectionError as exc:
|
|
54
|
+
# older SDKs wrap exceptions raised by the transport
|
|
55
|
+
if isinstance(exc.__cause__, BudgetExceeded):
|
|
56
|
+
raise exc.__cause__ from None
|
|
57
|
+
raise
|
|
58
|
+
finally:
|
|
59
|
+
if bar is not None:
|
|
60
|
+
bar.update(1)
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
return await asyncio.gather(
|
|
64
|
+
*(one(i, kw) for i, kw in enumerate(items)), return_exceptions=return_exceptions
|
|
65
|
+
)
|
|
66
|
+
finally:
|
|
67
|
+
if bar is not None:
|
|
68
|
+
bar.close()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def run_batch_sync(
|
|
72
|
+
client: openai.AsyncOpenAI, requests: Iterable[dict[str, Any]], **kwargs: Any
|
|
73
|
+
) -> list[Any]:
|
|
74
|
+
"""Blocking version of :func:`run_batch` for scripts (runs its own event loop)."""
|
|
75
|
+
|
|
76
|
+
async def main() -> list[Any]:
|
|
77
|
+
try:
|
|
78
|
+
return await run_batch(client, requests, **kwargs)
|
|
79
|
+
finally:
|
|
80
|
+
await client.close()
|
|
81
|
+
|
|
82
|
+
return asyncio.run(main())
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _progress_bar(total: int) -> Any:
|
|
86
|
+
try:
|
|
87
|
+
from tqdm.auto import tqdm
|
|
88
|
+
except ImportError as exc: # pragma: no cover
|
|
89
|
+
raise ImportError(
|
|
90
|
+
"progress=True requires tqdm: pip install responsible-request[progress]"
|
|
91
|
+
) from exc
|
|
92
|
+
return tqdm(total=total)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def calibrate(
|
|
96
|
+
client: openai.AsyncOpenAI,
|
|
97
|
+
model: str,
|
|
98
|
+
*,
|
|
99
|
+
n: int = 5,
|
|
100
|
+
messages: list[Any] | None = None,
|
|
101
|
+
**kwargs: Any,
|
|
102
|
+
) -> float:
|
|
103
|
+
"""Measure the latency baseline of ``model`` with ``n`` sequential requests and pin it.
|
|
104
|
+
|
|
105
|
+
Run this while the endpoint is idle (e.g. at night) or use a representative request of your
|
|
106
|
+
experiment via ``messages``/``kwargs`` so that the baseline matches your workload. Returns
|
|
107
|
+
the pinned baseline (in units of the configured metric).
|
|
108
|
+
"""
|
|
109
|
+
throttle = get_throttle(client)
|
|
110
|
+
config: ThrottleConfig = throttle.config
|
|
111
|
+
messages = messages or [{"role": "user", "content": "Reply with the single word: OK"}]
|
|
112
|
+
values: list[float] = []
|
|
113
|
+
for _ in range(n):
|
|
114
|
+
with tags(calibration=True):
|
|
115
|
+
await client.chat.completions.create(model=model, messages=messages, **kwargs)
|
|
116
|
+
# Use the transport's own measurement, which excludes time spent in the rate limiter.
|
|
117
|
+
records = [lane.last_record for lane in throttle.find(model) if lane.last_record]
|
|
118
|
+
value = metric_value(records[-1], config) if records else None
|
|
119
|
+
if value is not None:
|
|
120
|
+
values.append(value)
|
|
121
|
+
if not values:
|
|
122
|
+
raise RuntimeError("calibration produced no usable measurements")
|
|
123
|
+
baseline = statistics.median(values)
|
|
124
|
+
for lane in throttle.find(model):
|
|
125
|
+
if isinstance(lane.estimator, LatencyEstimator):
|
|
126
|
+
lane.estimator.pin_baseline(baseline)
|
|
127
|
+
return baseline
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Message builders."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def cached_system_message(content: str) -> dict[str, Any]:
|
|
9
|
+
"""A system message marked for OpenRouter's prompt cache (``cache_control: ephemeral``).
|
|
10
|
+
|
|
11
|
+
The provider caches the prompt prefix up to this message, so repeated requests with the same
|
|
12
|
+
system prompt pay less for input tokens; the model still runs. This is unrelated to the
|
|
13
|
+
response cache (``cache=True``), which replays stored responses without sending a request.
|
|
14
|
+
"""
|
|
15
|
+
return {
|
|
16
|
+
"role": "system",
|
|
17
|
+
"content": [{"type": "text", "text": content, "cache_control": {"type": "ephemeral"}}],
|
|
18
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Sampling-parameter presets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def reproducible(seed: int = 0, **overrides: Any) -> dict[str, Any]:
|
|
9
|
+
"""Parameters that make chat completions as repeatable as the backend allows.
|
|
10
|
+
|
|
11
|
+
Greedy decoding (``temperature=0``, ``top_p=1``) plus a fixed ``seed``. Spread it into a call
|
|
12
|
+
(``create(..., **rr.reproducible())``) or set it for every request with
|
|
13
|
+
``rr.AsyncOpenAI(default_params=rr.reproducible())``.
|
|
14
|
+
|
|
15
|
+
Note: outputs can still differ between runs, because batched GPU inference is not bit-exact
|
|
16
|
+
(the batch composition depends on the other requests the server is processing).
|
|
17
|
+
"""
|
|
18
|
+
return {"temperature": 0.0, "top_p": 1.0, "seed": seed, **overrides}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Structured output: request JSON matching a Pydantic model and validate the answer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Iterable
|
|
9
|
+
from typing import Any, TypeVar
|
|
10
|
+
|
|
11
|
+
import openai
|
|
12
|
+
from pydantic import BaseModel, ValidationError
|
|
13
|
+
|
|
14
|
+
T = TypeVar("T", bound=BaseModel)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class StructuredOutputError(Exception):
|
|
18
|
+
"""The model did not produce valid output within the allowed number of attempts."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, message: str, *, last_content: str | None, errors: list[Exception]) -> None:
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.last_content = last_content
|
|
23
|
+
self.errors = errors
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _strictify(node: Any) -> None:
|
|
27
|
+
if isinstance(node, dict):
|
|
28
|
+
if node.get("type") == "object" and "properties" in node:
|
|
29
|
+
node["additionalProperties"] = False
|
|
30
|
+
node["required"] = list(node["properties"])
|
|
31
|
+
node.pop("default", None)
|
|
32
|
+
for value in node.values():
|
|
33
|
+
_strictify(value)
|
|
34
|
+
elif isinstance(node, list):
|
|
35
|
+
for item in node:
|
|
36
|
+
_strictify(item)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def response_format_from_model(model: type[BaseModel], *, strict: bool = True) -> dict[str, Any]:
|
|
40
|
+
"""Build a ``json_schema`` ``response_format`` from a Pydantic model.
|
|
41
|
+
|
|
42
|
+
With ``strict=True`` every object forbids additional properties and marks all properties as
|
|
43
|
+
required, as strict structured-output backends demand.
|
|
44
|
+
"""
|
|
45
|
+
schema = copy.deepcopy(model.model_json_schema())
|
|
46
|
+
if strict:
|
|
47
|
+
_strictify(schema)
|
|
48
|
+
return {
|
|
49
|
+
"type": "json_schema",
|
|
50
|
+
"json_schema": {"name": model.__name__, "schema": schema, "strict": strict},
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_FENCE = re.compile(r"^\s*```(?:json)?\s*(.*?)\s*```\s*$", re.DOTALL)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def extract_json(text: str) -> str:
|
|
58
|
+
"""Strip Markdown code fences and surrounding prose around a JSON object."""
|
|
59
|
+
match = _FENCE.match(text)
|
|
60
|
+
if match:
|
|
61
|
+
return match.group(1)
|
|
62
|
+
start, end = text.find("{"), text.rfind("}")
|
|
63
|
+
if start != -1 and end > start and not text.lstrip().startswith("{"):
|
|
64
|
+
return text[start : end + 1]
|
|
65
|
+
return text
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _answer_text(message: Any) -> str:
|
|
69
|
+
"""The answer text: ``content``, else ``reasoning``, else ``reasoning_content`` (some
|
|
70
|
+
reasoning models put the JSON there and leave ``content`` empty)."""
|
|
71
|
+
for key in ("content", "reasoning", "reasoning_content"):
|
|
72
|
+
value = getattr(message, key, None) # the SDK's models keep unknown fields as attributes
|
|
73
|
+
if isinstance(value, str) and value.strip():
|
|
74
|
+
return value
|
|
75
|
+
return ""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def structured(
|
|
79
|
+
client: openai.AsyncOpenAI,
|
|
80
|
+
*,
|
|
81
|
+
model: str,
|
|
82
|
+
messages: Iterable[Any],
|
|
83
|
+
schema: type[T],
|
|
84
|
+
retries: int = 2,
|
|
85
|
+
strict: bool = True,
|
|
86
|
+
fallback: bool = True,
|
|
87
|
+
**kwargs: Any,
|
|
88
|
+
) -> T:
|
|
89
|
+
"""Ask for JSON matching ``schema`` and return it as a validated ``schema`` instance.
|
|
90
|
+
|
|
91
|
+
If the answer does not validate, the model is shown the validation error and asked again
|
|
92
|
+
(up to ``retries`` times). If the backend rejects ``json_schema`` response formats and
|
|
93
|
+
``fallback`` is set, it switches to ``json_object`` mode with the schema in the prompt.
|
|
94
|
+
If the message ``content`` is empty, the answer is read from ``reasoning``, then from
|
|
95
|
+
``reasoning_content`` (some reasoning models put the JSON there).
|
|
96
|
+
|
|
97
|
+
Extra keyword arguments are passed to ``client.chat.completions.create``.
|
|
98
|
+
"""
|
|
99
|
+
msgs: list[Any] = list(messages)
|
|
100
|
+
response_format: dict[str, Any] = response_format_from_model(schema, strict=strict)
|
|
101
|
+
errors: list[Exception] = []
|
|
102
|
+
content: str | None = None
|
|
103
|
+
|
|
104
|
+
attempt = 0
|
|
105
|
+
while attempt <= retries:
|
|
106
|
+
try:
|
|
107
|
+
params: dict[str, Any] = {
|
|
108
|
+
"model": model,
|
|
109
|
+
"messages": msgs,
|
|
110
|
+
"response_format": response_format,
|
|
111
|
+
**kwargs,
|
|
112
|
+
}
|
|
113
|
+
response = await client.chat.completions.create(**params)
|
|
114
|
+
except openai.BadRequestError as exc:
|
|
115
|
+
if not fallback or response_format["type"] != "json_schema":
|
|
116
|
+
raise
|
|
117
|
+
errors.append(exc)
|
|
118
|
+
schema_text = json.dumps(response_format["json_schema"]["schema"])
|
|
119
|
+
msgs = [
|
|
120
|
+
{
|
|
121
|
+
"role": "system",
|
|
122
|
+
"content": f"Respond only with a JSON object matching this schema:\n"
|
|
123
|
+
f"{schema_text}",
|
|
124
|
+
},
|
|
125
|
+
*msgs,
|
|
126
|
+
]
|
|
127
|
+
response_format = {"type": "json_object"}
|
|
128
|
+
continue # the fallback does not count as a retry
|
|
129
|
+
|
|
130
|
+
content = _answer_text(response.choices[0].message)
|
|
131
|
+
try:
|
|
132
|
+
return schema.model_validate_json(extract_json(content))
|
|
133
|
+
except ValidationError as exc:
|
|
134
|
+
errors.append(exc)
|
|
135
|
+
msgs = [
|
|
136
|
+
*msgs,
|
|
137
|
+
{"role": "assistant", "content": content},
|
|
138
|
+
{
|
|
139
|
+
"role": "user",
|
|
140
|
+
"content": "Your answer did not match the required JSON schema:\n"
|
|
141
|
+
f"{exc}\nRespond again with only the corrected JSON object.",
|
|
142
|
+
},
|
|
143
|
+
]
|
|
144
|
+
attempt += 1
|
|
145
|
+
|
|
146
|
+
raise StructuredOutputError(
|
|
147
|
+
f"no valid {schema.__name__} after {retries + 1} attempts",
|
|
148
|
+
last_content=content,
|
|
149
|
+
errors=errors,
|
|
150
|
+
)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Evenly spaced rate limiting whose rate can change at runtime, plus a concurrency cap.
|
|
2
|
+
|
|
3
|
+
The limiter is safe to share between threads that each run their own event loop (e.g. several
|
|
4
|
+
threads calling ``asyncio.run``): its state is guarded by a ``threading.Lock``, and every waiter
|
|
5
|
+
sleeps in its own loop and is woken through ``call_soon_threadsafe``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from collections import deque
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _Waiter:
|
|
17
|
+
"""A future in the caller's event loop that another thread can resolve."""
|
|
18
|
+
|
|
19
|
+
__slots__ = ("future", "loop")
|
|
20
|
+
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
self.loop = asyncio.get_running_loop()
|
|
23
|
+
self.future: asyncio.Future[None] = self.loop.create_future()
|
|
24
|
+
|
|
25
|
+
def renew(self) -> None:
|
|
26
|
+
self.future = self.loop.create_future()
|
|
27
|
+
|
|
28
|
+
def wake(self) -> bool:
|
|
29
|
+
"""Resolve the current future. Returns False if the waiter's loop is closed."""
|
|
30
|
+
try:
|
|
31
|
+
self.loop.call_soon_threadsafe(_resolve, self.future)
|
|
32
|
+
except RuntimeError:
|
|
33
|
+
return False
|
|
34
|
+
return True
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _resolve(future: asyncio.Future[None]) -> None:
|
|
38
|
+
if not future.done():
|
|
39
|
+
future.set_result(None)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AdaptiveLimiter:
|
|
43
|
+
"""Paces acquisitions at ``rpm`` and caps the number of concurrent holders.
|
|
44
|
+
|
|
45
|
+
Use as ``async with limiter: ...``. The concurrency slot is held for the duration of the
|
|
46
|
+
block. Requests leave the limiter at least ``60 / rpm`` seconds apart (no bursts), in the
|
|
47
|
+
order in which they got a concurrency slot. ``set_rpm`` applies to requests that are already
|
|
48
|
+
waiting, and ``pause`` holds back all requests for a while.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, rpm: float, max_concurrency: int) -> None:
|
|
52
|
+
self._lock = threading.Lock()
|
|
53
|
+
self._rpm = rpm
|
|
54
|
+
self._interval = 60 / rpm
|
|
55
|
+
self.max_concurrency = max_concurrency
|
|
56
|
+
self.in_flight = 0
|
|
57
|
+
self._slots_held = 0
|
|
58
|
+
self._last_sent = -float("inf")
|
|
59
|
+
self._paused_until = -float("inf")
|
|
60
|
+
self._slot_waiters: deque[_Waiter] = deque()
|
|
61
|
+
self._rate_waiters: deque[_Waiter] = deque()
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def rpm(self) -> float:
|
|
65
|
+
return self._rpm
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def paused_s(self) -> float:
|
|
69
|
+
"""Remaining time of the current pause (0 if not paused)."""
|
|
70
|
+
return max(0.0, self._paused_until - time.monotonic())
|
|
71
|
+
|
|
72
|
+
def set_rpm(self, rpm: float) -> None:
|
|
73
|
+
with self._lock:
|
|
74
|
+
self._rpm = rpm
|
|
75
|
+
self._interval = 60 / rpm
|
|
76
|
+
self._wake_rate_head() # it re-computes its wake-up time for the new rate
|
|
77
|
+
|
|
78
|
+
def pause(self, seconds: float) -> None:
|
|
79
|
+
"""Send nothing for ``seconds`` (extends, never shortens, a running pause)."""
|
|
80
|
+
with self._lock:
|
|
81
|
+
self._paused_until = max(self._paused_until, time.monotonic() + seconds)
|
|
82
|
+
|
|
83
|
+
async def acquire(self) -> None:
|
|
84
|
+
# Concurrency first, then rate: requests leave the limiter evenly spaced.
|
|
85
|
+
await self._acquire_slot()
|
|
86
|
+
try:
|
|
87
|
+
await self._acquire_rate()
|
|
88
|
+
except BaseException:
|
|
89
|
+
self._release_slot()
|
|
90
|
+
raise
|
|
91
|
+
|
|
92
|
+
def release(self) -> None:
|
|
93
|
+
with self._lock:
|
|
94
|
+
self.in_flight -= 1
|
|
95
|
+
self._release_slot()
|
|
96
|
+
|
|
97
|
+
async def __aenter__(self) -> None:
|
|
98
|
+
await self.acquire()
|
|
99
|
+
|
|
100
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
101
|
+
self.release()
|
|
102
|
+
|
|
103
|
+
# ------------------------------------------------------------------ internals
|
|
104
|
+
|
|
105
|
+
async def _acquire_slot(self) -> None:
|
|
106
|
+
with self._lock:
|
|
107
|
+
if self._slots_held < self.max_concurrency and not self._slot_waiters:
|
|
108
|
+
self._slots_held += 1
|
|
109
|
+
return
|
|
110
|
+
waiter = _Waiter()
|
|
111
|
+
self._slot_waiters.append(waiter)
|
|
112
|
+
try:
|
|
113
|
+
await waiter.future # resolved by _release_slot, which hands over its slot
|
|
114
|
+
except BaseException:
|
|
115
|
+
with self._lock:
|
|
116
|
+
granted = waiter not in self._slot_waiters
|
|
117
|
+
if not granted:
|
|
118
|
+
self._slot_waiters.remove(waiter)
|
|
119
|
+
if granted: # the slot was already handed to us: pass it on
|
|
120
|
+
self._release_slot()
|
|
121
|
+
raise
|
|
122
|
+
|
|
123
|
+
def _release_slot(self) -> None:
|
|
124
|
+
with self._lock:
|
|
125
|
+
while self._slot_waiters:
|
|
126
|
+
if self._slot_waiters.popleft().wake():
|
|
127
|
+
return # the slot moves to the woken waiter
|
|
128
|
+
self._slots_held -= 1
|
|
129
|
+
|
|
130
|
+
async def _acquire_rate(self) -> None:
|
|
131
|
+
waiter = _Waiter()
|
|
132
|
+
with self._lock:
|
|
133
|
+
self._rate_waiters.append(waiter)
|
|
134
|
+
try:
|
|
135
|
+
while True:
|
|
136
|
+
with self._lock:
|
|
137
|
+
waiter.renew() # wake-ups from now on resolve the new future
|
|
138
|
+
timeout: float | None = None
|
|
139
|
+
if self._rate_waiters[0] is waiter:
|
|
140
|
+
now = time.monotonic()
|
|
141
|
+
ready = max(self._last_sent + self._interval, self._paused_until)
|
|
142
|
+
if now >= ready:
|
|
143
|
+
self._last_sent = now
|
|
144
|
+
self._rate_waiters.popleft()
|
|
145
|
+
self._wake_rate_head()
|
|
146
|
+
self.in_flight += 1
|
|
147
|
+
return
|
|
148
|
+
timeout = ready - now
|
|
149
|
+
# Sleep in this thread's own loop until the slot is due or we are woken.
|
|
150
|
+
await asyncio.wait({waiter.future}, timeout=timeout)
|
|
151
|
+
except BaseException:
|
|
152
|
+
with self._lock:
|
|
153
|
+
if waiter in self._rate_waiters:
|
|
154
|
+
was_head = self._rate_waiters[0] is waiter
|
|
155
|
+
self._rate_waiters.remove(waiter)
|
|
156
|
+
if was_head:
|
|
157
|
+
self._wake_rate_head()
|
|
158
|
+
raise
|
|
159
|
+
|
|
160
|
+
def _wake_rate_head(self) -> None:
|
|
161
|
+
"""Wake the first waiter for the rate slot (call with the lock held)."""
|
|
162
|
+
while self._rate_waiters:
|
|
163
|
+
if self._rate_waiters[0].wake():
|
|
164
|
+
return
|
|
165
|
+
self._rate_waiters.popleft() # its loop is gone
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""The files of a JSONL log: the active file plus the segments loguru rotated away from it,
|
|
2
|
+
optionally compressed."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import bz2
|
|
7
|
+
import gzip
|
|
8
|
+
import io
|
|
9
|
+
import lzma
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import IO, Any
|
|
17
|
+
|
|
18
|
+
COMPRESSIONS = ("gz", "bz2", "xz", "zst")
|
|
19
|
+
"""Values of ``LogConfig.compression``: formats that are read back as a single JSONL stream."""
|
|
20
|
+
|
|
21
|
+
# loguru names a rotated segment ``{stem}.{%Y-%m-%d_%H-%M-%S_%f}[.{n}]{suffix}``
|
|
22
|
+
_DATE = r"\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}_\d{6}(?:\.\d+)?"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _zstd() -> Any:
|
|
26
|
+
try:
|
|
27
|
+
from compression import zstd # Python 3.14+
|
|
28
|
+
|
|
29
|
+
return zstd
|
|
30
|
+
except ImportError:
|
|
31
|
+
pass
|
|
32
|
+
try:
|
|
33
|
+
import zstandard
|
|
34
|
+
|
|
35
|
+
return zstandard
|
|
36
|
+
except ImportError:
|
|
37
|
+
raise ImportError(
|
|
38
|
+
"zstd needs Python 3.14+ or: pip install responsible-request[zstd]"
|
|
39
|
+
) from None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def compression_of(path: Path) -> str | None:
|
|
43
|
+
"""The compression format of ``path`` from its extension, or None for a plain file."""
|
|
44
|
+
ext = path.suffix.lstrip(".")
|
|
45
|
+
return ext if ext in COMPRESSIONS else None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def strip_compression(path: Path) -> Path:
|
|
49
|
+
"""``requests.jsonl.gz`` -> ``requests.jsonl``."""
|
|
50
|
+
return path.with_suffix("") if compression_of(path) else path
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def open_binary(path: Path) -> IO[bytes]:
|
|
54
|
+
"""Open a plain or compressed log file for reading (decompressed bytes)."""
|
|
55
|
+
fmt = compression_of(path)
|
|
56
|
+
if fmt == "zst": # zstandard's reader can't iterate over lines, a buffer on top of it can
|
|
57
|
+
return io.BufferedReader(_zstd().open(path, "rb"))
|
|
58
|
+
opener: Any = {"gz": gzip.open, "bz2": bz2.open, "xz": lzma.open}.get(fmt or "")
|
|
59
|
+
stream: IO[bytes] = opener(path, "rb") if opener else path.open("rb")
|
|
60
|
+
return stream
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def rotated_segments(path: Path) -> list[Path]:
|
|
64
|
+
"""Segments rotated away from the log at ``path`` (and compressed copies of ``path`` itself,
|
|
65
|
+
which loguru writes when a sink without rotation is removed), oldest first."""
|
|
66
|
+
pattern = re.compile(
|
|
67
|
+
rf"{re.escape(path.stem)}(?:\.{_DATE})?{re.escape(path.suffix)}"
|
|
68
|
+
rf"(?:\.(?:{'|'.join(COMPRESSIONS)}))?"
|
|
69
|
+
)
|
|
70
|
+
try:
|
|
71
|
+
names = {p.name for p in path.parent.iterdir()}
|
|
72
|
+
except FileNotFoundError:
|
|
73
|
+
return []
|
|
74
|
+
found = []
|
|
75
|
+
for name in names:
|
|
76
|
+
if name == path.name or not pattern.fullmatch(name):
|
|
77
|
+
continue
|
|
78
|
+
plain = name.rsplit(".", 1)[0]
|
|
79
|
+
if compression_of(Path(name)) and plain != path.name and plain in names:
|
|
80
|
+
continue # still being compressed: loguru removes the plain file once it is done
|
|
81
|
+
found.append(path.parent / name)
|
|
82
|
+
return sorted(found, key=lambda p: (_mtime(p), p.name))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _mtime(path: Path) -> float:
|
|
86
|
+
try:
|
|
87
|
+
return path.stat().st_mtime
|
|
88
|
+
except FileNotFoundError:
|
|
89
|
+
return 0.0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def log_segments(path: Path) -> list[Path]:
|
|
93
|
+
"""All files of the log at ``path``, oldest first. A compressed ``path`` is read on its own."""
|
|
94
|
+
if compression_of(path):
|
|
95
|
+
return [path]
|
|
96
|
+
return rotated_segments(path) + ([path] if path.exists() else [])
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _compress_zstd(path_in: str) -> None:
|
|
100
|
+
"""Compress a finished segment to ``{path_in}.zst``, the way loguru does for other formats."""
|
|
101
|
+
path_out = Path(f"{path_in}.zst")
|
|
102
|
+
if path_out.exists(): # keep the earlier archive under a dated name
|
|
103
|
+
root, ext = os.path.splitext(path_in)
|
|
104
|
+
date = datetime.fromtimestamp(path_out.stat().st_mtime).strftime("%Y-%m-%d_%H-%M-%S_%f")
|
|
105
|
+
renamed, n = Path(f"{root}.{date}{ext}.zst"), 1
|
|
106
|
+
while renamed.exists():
|
|
107
|
+
n += 1
|
|
108
|
+
renamed = Path(f"{root}.{date}.{n}{ext}.zst")
|
|
109
|
+
path_out.rename(renamed)
|
|
110
|
+
with open(path_in, "rb") as src, _zstd().open(path_out, "wb") as dst:
|
|
111
|
+
shutil.copyfileobj(src, dst)
|
|
112
|
+
os.remove(path_in)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def loguru_compression(fmt: str | None) -> str | Callable[[str], None] | None:
|
|
116
|
+
"""The ``compression`` argument for loguru's ``logger.add``."""
|
|
117
|
+
if fmt == "zst":
|
|
118
|
+
_zstd() # fail now rather than in the writer thread at the first rotation
|
|
119
|
+
return _compress_zstd
|
|
120
|
+
return fmt
|