forebot 0.2.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.
@@ -0,0 +1,24 @@
1
+ name: ci
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_call:
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ test:
14
+ runs-on: ubuntu-latest
15
+ strategy:
16
+ fail-fast: false
17
+ matrix:
18
+ python: ["3.9", "3.10", "3.11", "3.12", "3.13"]
19
+ steps:
20
+ - uses: actions/checkout@v5
21
+ - uses: astral-sh/setup-uv@v6
22
+ with:
23
+ python-version: ${{ matrix.python }}
24
+ - run: uv run --with pytest --with-editable . pytest -q
@@ -0,0 +1,46 @@
1
+ name: publish
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ test:
12
+ uses: ./.github/workflows/ci.yml
13
+
14
+ build:
15
+ needs: test
16
+ runs-on: ubuntu-latest
17
+ steps:
18
+ - uses: actions/checkout@v5
19
+ - uses: astral-sh/setup-uv@v6
20
+ - name: Check that the tag matches the package version
21
+ run: |
22
+ version=$(uv version --short)
23
+ if [ "v${version}" != "${GITHUB_REF_NAME}" ]; then
24
+ echo "::error::Tag ${GITHUB_REF_NAME} does not match pyproject version ${version}"
25
+ exit 1
26
+ fi
27
+ - run: uv build
28
+ - uses: actions/upload-artifact@v4
29
+ with:
30
+ name: dist
31
+ path: dist/
32
+
33
+ pypi:
34
+ needs: build
35
+ runs-on: ubuntu-latest
36
+ environment:
37
+ name: pypi
38
+ url: https://pypi.org/p/forebot
39
+ permissions:
40
+ id-token: write # PyPI trusted publishing
41
+ steps:
42
+ - uses: actions/download-artifact@v4
43
+ with:
44
+ name: dist
45
+ path: dist/
46
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ .pytest_cache/
5
+ dist/
6
+ build/
7
+ *.egg-info/
forebot-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,54 @@
1
+ Metadata-Version: 2.5
2
+ Name: forebot
3
+ Version: 0.2.0
4
+ Summary: Python SDK for bots managed by Forebot
5
+ Project-URL: Homepage, https://github.com/gridcell/forebot
6
+ Project-URL: Documentation, https://github.com/gridcell/forebot/blob/main/docs/sdk.md
7
+ Project-URL: Source, https://github.com/gridcell/forebot-sdk
8
+ Classifier: Operating System :: OS Independent
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3 :: Only
11
+ Classifier: Typing :: Typed
12
+ Requires-Python: >=3.9
13
+ Provides-Extra: test
14
+ Requires-Dist: pytest>=7; extra == 'test'
15
+ Description-Content-Type: text/markdown
16
+
17
+ # forebot (Python SDK)
18
+
19
+ The Forebot SDK lets a Python bot report its activity to Forebot, read its configuration, and cooperate with pause and stop requests. It has no dependencies.
20
+
21
+ ```bash
22
+ pip install forebot
23
+ ```
24
+
25
+ ```python
26
+ import forebot
27
+
28
+ forebot.setup_logging()
29
+ bot = forebot.connect()
30
+
31
+ for _ in bot.loop(interval=60, idle_detail="waiting for work"):
32
+ with bot.working("checking the queue"):
33
+ ...
34
+ ```
35
+
36
+ See the [SDK guide](https://github.com/gridcell/forebot/blob/main/docs/sdk.md) in the Forebot repository for the full guide.
37
+
38
+ ## Development
39
+
40
+ ```bash
41
+ uv run --with pytest --with-editable . pytest -q
42
+ ```
43
+
44
+ ## Release
45
+
46
+ 1. Set `version` in `pyproject.toml` and commit.
47
+ 2. Tag the commit with the same version and push the tag:
48
+
49
+ ```bash
50
+ git tag v0.2.0
51
+ git push origin v0.2.0
52
+ ```
53
+
54
+ The `publish` workflow runs the tests, checks that the tag matches the version, builds the package, and uploads it to PyPI.
@@ -0,0 +1,38 @@
1
+ # forebot (Python SDK)
2
+
3
+ The Forebot SDK lets a Python bot report its activity to Forebot, read its configuration, and cooperate with pause and stop requests. It has no dependencies.
4
+
5
+ ```bash
6
+ pip install forebot
7
+ ```
8
+
9
+ ```python
10
+ import forebot
11
+
12
+ forebot.setup_logging()
13
+ bot = forebot.connect()
14
+
15
+ for _ in bot.loop(interval=60, idle_detail="waiting for work"):
16
+ with bot.working("checking the queue"):
17
+ ...
18
+ ```
19
+
20
+ See the [SDK guide](https://github.com/gridcell/forebot/blob/main/docs/sdk.md) in the Forebot repository for the full guide.
21
+
22
+ ## Development
23
+
24
+ ```bash
25
+ uv run --with pytest --with-editable . pytest -q
26
+ ```
27
+
28
+ ## Release
29
+
30
+ 1. Set `version` in `pyproject.toml` and commit.
31
+ 2. Tag the commit with the same version and push the tag:
32
+
33
+ ```bash
34
+ git tag v0.2.0
35
+ git push origin v0.2.0
36
+ ```
37
+
38
+ The `publish` workflow runs the tests, checks that the tag matches the version, builds the package, and uploads it to PyPI.
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "forebot"
7
+ version = "0.2.0"
8
+ description = "Python SDK for bots managed by Forebot"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ dependencies = []
12
+ classifiers = [
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3 :: Only",
15
+ "Operating System :: OS Independent",
16
+ "Typing :: Typed",
17
+ ]
18
+
19
+ [project.urls]
20
+ Homepage = "https://github.com/gridcell/forebot"
21
+ Documentation = "https://github.com/gridcell/forebot/blob/main/docs/sdk.md"
22
+ Source = "https://github.com/gridcell/forebot-sdk"
23
+
24
+ [project.optional-dependencies]
25
+ test = ["pytest>=7"]
26
+
27
+ [tool.hatch.build.targets.wheel]
28
+ packages = ["src/forebot"]
29
+
30
+ [tool.pytest.ini_options]
31
+ testpaths = ["tests"]
@@ -0,0 +1,33 @@
1
+ """Forebot SDK: report status to Forebot and cooperate with its controls.
2
+
3
+ Typical use::
4
+
5
+ import forebot
6
+
7
+ forebot.setup_logging()
8
+ bot = forebot.connect()
9
+ api_key = bot.require("api_key")
10
+
11
+ for _ in bot.loop(interval=60, idle_detail="waiting for work"):
12
+ for job in fetch_jobs():
13
+ with bot.working(f"processing {job.id}"):
14
+ handle(job)
15
+ if not bot.checkpoint(): # honours pause and shutdown
16
+ break
17
+ """
18
+
19
+ from .bot import IDLE, PAUSED, WORKING, Bot, ForebotError, connect
20
+ from .logs import JSONFormatter, setup_logging
21
+
22
+ __all__ = [
23
+ "Bot",
24
+ "ForebotError",
25
+ "IDLE",
26
+ "JSONFormatter",
27
+ "PAUSED",
28
+ "WORKING",
29
+ "connect",
30
+ "setup_logging",
31
+ ]
32
+
33
+ __version__ = "0.2.0"
@@ -0,0 +1,341 @@
1
+ """The Forebot bot client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import http.client
6
+ import json
7
+ import logging
8
+ import os
9
+ import signal
10
+ import socket
11
+ import threading
12
+ import time
13
+ import urllib.error
14
+ import urllib.request
15
+ from typing import Any, Dict, Iterator, Optional
16
+
17
+ IDLE = "idle"
18
+ WORKING = "working"
19
+ PAUSED = "paused"
20
+
21
+ _log = logging.getLogger("forebot")
22
+
23
+
24
+ class _UnixHTTPConnection(http.client.HTTPConnection):
25
+ """An HTTP connection over a Unix socket.
26
+
27
+ A runner gives each bot in a container its own socket, with a
28
+ ``unix://`` agent URL.
29
+ """
30
+
31
+ def __init__(self, path: str, timeout: float) -> None:
32
+ super().__init__("localhost", timeout=timeout)
33
+ self._path = path
34
+
35
+ def connect(self) -> None:
36
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
37
+ sock.settimeout(self.timeout)
38
+ try:
39
+ sock.connect(self._path)
40
+ except OSError:
41
+ sock.close()
42
+ raise
43
+ self.sock = sock
44
+
45
+
46
+ class Bot:
47
+ """Connection between a bot process and its Forebot runner.
48
+
49
+ When the process is not started by a Forebot runner (for example during
50
+ local development), the bot runs in *standalone* mode: configuration is
51
+ read from ``FOREBOT_CONFIG`` (a JSON string) or ``FOREBOT_CONFIG_FILE``
52
+ (a path to a JSON file), and status reports are no-ops.
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ agent_url: Optional[str] = None,
58
+ token: Optional[str] = None,
59
+ heartbeat_interval: float = 5.0,
60
+ handle_signals: bool = True,
61
+ ) -> None:
62
+ self.agent_url = (agent_url or os.environ.get("FOREBOT_AGENT_URL", "")).rstrip("/")
63
+ self._token = token or os.environ.get("FOREBOT_AGENT_TOKEN", "")
64
+ self.managed = bool(self.agent_url and self._token)
65
+ self.id: Optional[int] = _int_or_none(os.environ.get("FOREBOT_BOT_ID"))
66
+ self.name: str = os.environ.get("FOREBOT_BOT_NAME", "")
67
+ self.revision: Optional[int] = None
68
+ self.heartbeat_interval = heartbeat_interval
69
+
70
+ self._lock = threading.Lock()
71
+ self._activity = IDLE
72
+ self._detail = ""
73
+ self._pause_requested = False
74
+ self._stop = threading.Event()
75
+ self._wake = threading.Event() # wakes the heartbeat thread early
76
+ self._unpaused = threading.Event()
77
+ self._unpaused.set()
78
+ self._cooperative = False
79
+ self._closed = False
80
+
81
+ self.config: Dict[str, Any] = self._load_config()
82
+
83
+ if handle_signals and threading.current_thread() is threading.main_thread():
84
+ self._install_signal_handlers()
85
+
86
+ self._thread: Optional[threading.Thread] = None
87
+ if self.managed:
88
+ self._send_heartbeat() # report immediately
89
+ self._thread = threading.Thread(target=self._heartbeat_loop, name="forebot-heartbeat", daemon=True)
90
+ self._thread.start()
91
+
92
+ # ------------------------------------------------------------------ config
93
+
94
+ def _load_config(self) -> Dict[str, Any]:
95
+ if self.managed:
96
+ try:
97
+ data = self._request("GET", "/v1/config")
98
+ except Exception as exc: # noqa: BLE001
99
+ raise ForebotError(f"could not load configuration from the runner: {exc}") from exc
100
+ self.id = data.get("bot_id", self.id)
101
+ self.name = data.get("bot_name", self.name)
102
+ self.revision = data.get("revision")
103
+ return dict(data.get("config") or {})
104
+ raw = os.environ.get("FOREBOT_CONFIG")
105
+ if raw:
106
+ return dict(json.loads(raw))
107
+ path = os.environ.get("FOREBOT_CONFIG_FILE")
108
+ if path:
109
+ with open(path, encoding="utf-8") as fh:
110
+ return dict(json.load(fh))
111
+ return {}
112
+
113
+ def get(self, key: str, default: Any = None) -> Any:
114
+ """Return a configuration value, or ``default`` if it is not set."""
115
+ return self.config.get(key, default)
116
+
117
+ def require(self, key: str) -> Any:
118
+ """Return a configuration value, raising ``KeyError`` with a clear message if it is missing."""
119
+ if key not in self.config:
120
+ raise KeyError(f"required configuration key {key!r} is not set in Forebot")
121
+ return self.config[key]
122
+
123
+ # ---------------------------------------------------------------- activity
124
+
125
+ def set_activity(self, state: str, detail: str = "") -> None:
126
+ """Report the bot's activity: ``idle``, ``working`` or ``paused``."""
127
+ if state not in (IDLE, WORKING, PAUSED):
128
+ raise ValueError(f"unknown activity {state!r}")
129
+ with self._lock:
130
+ changed = (state, detail) != (self._activity, self._detail)
131
+ self._activity, self._detail = state, detail
132
+ if changed:
133
+ self._wake.set()
134
+
135
+ def idle(self, detail: str = "") -> None:
136
+ """Report that the bot is waiting for work."""
137
+ self.set_activity(IDLE, detail)
138
+
139
+ def working(self, detail: str = "") -> "_Activity":
140
+ """Report that the bot is working.
141
+
142
+ Use it as a plain call, or as a context manager that reports idle
143
+ again when the block ends::
144
+
145
+ with bot.working("Triaging SENTRY-1234"):
146
+ ...
147
+ """
148
+ with self._lock:
149
+ prev = (self._activity, self._detail)
150
+ self.set_activity(WORKING, detail)
151
+ return _Activity(self, prev)
152
+
153
+ @property
154
+ def activity(self) -> str:
155
+ with self._lock:
156
+ return self._activity
157
+
158
+ # ------------------------------------------------------- pause / shutdown
159
+
160
+ @property
161
+ def pause_requested(self) -> bool:
162
+ """True when an operator asked the bot to pause."""
163
+ with self._lock:
164
+ return self._pause_requested
165
+
166
+ def should_stop(self) -> bool:
167
+ """True when the bot was asked to shut down (SIGTERM or SIGINT)."""
168
+ self._cooperative = True
169
+ return self._stop.is_set()
170
+
171
+ @property
172
+ def stopping(self) -> bool:
173
+ return self.should_stop()
174
+
175
+ def request_stop(self) -> None:
176
+ """Ask the bot's own loops to finish (same as receiving SIGTERM)."""
177
+ self._stop.set()
178
+ self._unpaused.set()
179
+ self._wake.set()
180
+
181
+ def checkpoint(self) -> bool:
182
+ """Call between units of work.
183
+
184
+ Blocks while the bot is paused, reporting ``paused`` to Forebot.
185
+ Returns ``False`` when the bot should stop, ``True`` otherwise.
186
+ """
187
+ self._cooperative = True
188
+ if self._stop.is_set():
189
+ return False
190
+ if not self.pause_requested:
191
+ return True
192
+ with self._lock:
193
+ before = (self._activity, self._detail)
194
+ self.set_activity(PAUSED, "paused by operator")
195
+ _log.info("paused by operator")
196
+ while self.pause_requested and not self._stop.is_set():
197
+ self._unpaused.wait(1.0)
198
+ if not self._stop.is_set():
199
+ _log.info("resumed")
200
+ self.set_activity(*before)
201
+ return not self._stop.is_set()
202
+
203
+ def sleep(self, seconds: float) -> bool:
204
+ """Sleep, waking early if the bot is asked to stop.
205
+
206
+ Returns ``False`` when the bot should stop.
207
+ """
208
+ self._cooperative = True
209
+ return not self._stop.wait(max(0.0, seconds))
210
+
211
+ def loop(self, interval: float, idle_detail: str = "") -> Iterator[int]:
212
+ """Run a polling loop until the bot is asked to stop.
213
+
214
+ Each iteration first honours pause requests. Between iterations the
215
+ bot reports idle and sleeps ``interval`` seconds::
216
+
217
+ for _ in bot.loop(interval=60, idle_detail="waiting for new alerts"):
218
+ check_for_alerts()
219
+ """
220
+ n = 0
221
+ while self.checkpoint():
222
+ yield n
223
+ n += 1
224
+ self.idle(idle_detail)
225
+ if not self.sleep(interval):
226
+ break
227
+
228
+ # --------------------------------------------------------------- internals
229
+
230
+ def _install_signal_handlers(self) -> None:
231
+ def handler(signum: int, frame: Any) -> None:
232
+ if self._stop.is_set() or not self._cooperative:
233
+ # Second signal, or the bot never checks should_stop():
234
+ # exit now so the bot does not wait for SIGKILL.
235
+ raise SystemExit(128 + signum)
236
+ _log.info("shutdown requested (signal %d)", signum)
237
+ self.request_stop()
238
+
239
+ for sig in (signal.SIGTERM, signal.SIGINT):
240
+ try:
241
+ signal.signal(sig, handler)
242
+ except (ValueError, OSError): # not supported on this platform/thread
243
+ pass
244
+
245
+ def _request(self, method: str, path: str, body: Optional[dict] = None) -> dict:
246
+ data = json.dumps(body).encode() if body is not None else None
247
+ if self.agent_url.startswith("unix://"):
248
+ return self._unix_request(method, path, data)
249
+ req = urllib.request.Request(self.agent_url + path, data=data, method=method)
250
+ req.add_header("Authorization", "Bearer " + self._token)
251
+ if data is not None:
252
+ req.add_header("Content-Type", "application/json")
253
+ with urllib.request.urlopen(req, timeout=5) as resp: # noqa: S310 - local runner URL
254
+ return json.loads(resp.read().decode() or "{}")
255
+
256
+ def _unix_request(self, method: str, path: str, data: Optional[bytes]) -> dict:
257
+ headers = {"Authorization": "Bearer " + self._token}
258
+ if data is not None:
259
+ headers["Content-Type"] = "application/json"
260
+ conn = _UnixHTTPConnection(self.agent_url[len("unix://") :], timeout=5)
261
+ try:
262
+ conn.request(method, path, body=data, headers=headers)
263
+ resp = conn.getresponse()
264
+ raw = resp.read()
265
+ if resp.status >= 400:
266
+ raise urllib.error.HTTPError(self.agent_url + path, resp.status, resp.reason, resp.headers, None)
267
+ return json.loads(raw.decode() or "{}")
268
+ finally:
269
+ conn.close()
270
+
271
+ def _send_heartbeat(self) -> None:
272
+ with self._lock:
273
+ body = {"activity": self._activity, "detail": self._detail}
274
+ try:
275
+ reply = self._request("POST", "/v1/heartbeat", body)
276
+ except (urllib.error.URLError, OSError, ValueError) as exc:
277
+ _log.debug("heartbeat failed: %s", exc)
278
+ return
279
+ paused = bool(reply.get("paused"))
280
+ with self._lock:
281
+ changed = paused != self._pause_requested
282
+ self._pause_requested = paused
283
+ if changed:
284
+ if paused:
285
+ self._unpaused.clear()
286
+ _log.info("pause requested")
287
+ else:
288
+ self._unpaused.set()
289
+
290
+ def _heartbeat_loop(self) -> None:
291
+ while not self._closed:
292
+ self._wake.wait(self.heartbeat_interval)
293
+ self._wake.clear()
294
+ if self._closed:
295
+ break
296
+ self._send_heartbeat()
297
+
298
+ def close(self) -> None:
299
+ """Stop the heartbeat thread."""
300
+ self._closed = True
301
+ self._wake.set()
302
+
303
+
304
+ class _Activity:
305
+ def __init__(self, bot: Bot, prev: tuple) -> None:
306
+ self._bot = bot
307
+ self._prev = prev
308
+
309
+ def __enter__(self) -> "_Activity":
310
+ return self
311
+
312
+ def __exit__(self, *exc: Any) -> None:
313
+ state, detail = self._prev
314
+ if state == WORKING: # nested: go back to the outer task
315
+ self._bot.set_activity(WORKING, detail)
316
+ else:
317
+ self._bot.idle()
318
+
319
+
320
+ class ForebotError(RuntimeError):
321
+ """Raised when the bot cannot talk to its runner."""
322
+
323
+
324
+ def _int_or_none(v: Optional[str]) -> Optional[int]:
325
+ try:
326
+ return int(v) if v else None
327
+ except ValueError:
328
+ return None
329
+
330
+
331
+ _default: Optional[Bot] = None
332
+ _default_lock = threading.Lock()
333
+
334
+
335
+ def connect(**kwargs: Any) -> Bot:
336
+ """Return the process-wide :class:`Bot`, creating it on first call."""
337
+ global _default
338
+ with _default_lock:
339
+ if _default is None:
340
+ _default = Bot(**kwargs)
341
+ return _default
@@ -0,0 +1,57 @@
1
+ """Structured logging that the Forebot runner understands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as _dt
6
+ import json
7
+ import logging
8
+ import os
9
+ import sys
10
+ from typing import Optional, TextIO, Union
11
+
12
+
13
+ class JSONFormatter(logging.Formatter):
14
+ """Formats records as one JSON object per line.
15
+
16
+ The runner reads ``level``, ``logger``, ``msg``, ``ts`` and ``exc_info``
17
+ so the web interface can filter by level and show tracebacks as one entry.
18
+ """
19
+
20
+ def format(self, record: logging.LogRecord) -> str:
21
+ out = {
22
+ "ts": _dt.datetime.fromtimestamp(record.created, tz=_dt.timezone.utc).isoformat(),
23
+ "level": record.levelname.lower(),
24
+ "logger": record.name,
25
+ "msg": record.getMessage(),
26
+ }
27
+ if record.exc_info:
28
+ out["exc_info"] = self.formatException(record.exc_info)
29
+ elif record.stack_info:
30
+ out["exc_info"] = self.formatStack(record.stack_info)
31
+ return json.dumps(out, ensure_ascii=False, default=str)
32
+
33
+
34
+ def setup_logging(
35
+ level: Union[int, str] = logging.INFO,
36
+ stream: Optional[TextIO] = None,
37
+ json_format: Optional[bool] = None,
38
+ ) -> logging.Logger:
39
+ """Configure the root logger for a bot.
40
+
41
+ Under a Forebot runner, logs are written as JSON lines to stdout. In
42
+ standalone mode they use a readable text format. Pass ``json_format`` to
43
+ force either one.
44
+ """
45
+ if json_format is None:
46
+ json_format = bool(os.environ.get("FOREBOT_AGENT_URL"))
47
+ handler = logging.StreamHandler(stream or sys.stdout)
48
+ if json_format:
49
+ handler.setFormatter(JSONFormatter())
50
+ else:
51
+ handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)-8s %(name)s: %(message)s"))
52
+ root = logging.getLogger()
53
+ for h in list(root.handlers):
54
+ root.removeHandler(h)
55
+ root.addHandler(handler)
56
+ root.setLevel(level)
57
+ return root
File without changes
@@ -0,0 +1,187 @@
1
+ import io
2
+ import json
3
+ import logging
4
+ import socketserver
5
+ import tempfile
6
+ import threading
7
+ import time
8
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
9
+
10
+ import pytest
11
+
12
+ import forebot
13
+ from forebot.bot import Bot
14
+
15
+
16
+ class FakeRunner:
17
+ """A minimal stand-in for the runner's local agent API."""
18
+
19
+ def __init__(self, config=None, token="tok", unix_path=None):
20
+ self.config = config or {}
21
+ self.token = token
22
+ self.paused = False
23
+ self.heartbeats = []
24
+ runner = self
25
+
26
+ class Handler(BaseHTTPRequestHandler):
27
+ def log_message(self, *a):
28
+ pass
29
+
30
+ def _auth(self):
31
+ if self.headers.get("Authorization") != "Bearer " + runner.token:
32
+ self.send_response(401)
33
+ self.end_headers()
34
+ return False
35
+ return True
36
+
37
+ def _json(self, obj):
38
+ data = json.dumps(obj).encode()
39
+ self.send_response(200)
40
+ self.send_header("Content-Type", "application/json")
41
+ self.send_header("Content-Length", str(len(data)))
42
+ self.end_headers()
43
+ self.wfile.write(data)
44
+
45
+ def do_GET(self):
46
+ if self._auth() and self.path == "/v1/config":
47
+ self._json({"bot_id": 7, "bot_name": "tester", "revision": 3, "config": runner.config})
48
+
49
+ def do_POST(self):
50
+ if self._auth() and self.path == "/v1/heartbeat":
51
+ n = int(self.headers.get("Content-Length", 0))
52
+ runner.heartbeats.append(json.loads(self.rfile.read(n)))
53
+ self._json({"paused": runner.paused})
54
+
55
+ if unix_path:
56
+ self.httpd = socketserver.ThreadingUnixStreamServer(unix_path, Handler)
57
+ self.url = "unix://" + unix_path
58
+ else:
59
+ self.httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
60
+ self.url = "http://127.0.0.1:%d" % self.httpd.server_address[1]
61
+ threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
62
+
63
+ def close(self):
64
+ self.httpd.shutdown()
65
+
66
+
67
+ @pytest.fixture
68
+ def runner():
69
+ r = FakeRunner(config={"api_key": "s3cret", "interval": 30})
70
+ yield r
71
+ r.close()
72
+
73
+
74
+ def wait_for(cond, timeout=3.0):
75
+ end = time.time() + timeout
76
+ while time.time() < end:
77
+ if cond():
78
+ return True
79
+ time.sleep(0.02)
80
+ return False
81
+
82
+
83
+ def test_managed_bot_loads_config(runner):
84
+ bot = Bot(agent_url=runner.url, token="tok", handle_signals=False)
85
+ try:
86
+ assert bot.managed
87
+ assert bot.id == 7 and bot.name == "tester" and bot.revision == 3
88
+ assert bot.require("api_key") == "s3cret"
89
+ assert bot.get("missing", 5) == 5
90
+ with pytest.raises(KeyError):
91
+ bot.require("missing")
92
+ finally:
93
+ bot.close()
94
+
95
+
96
+ def test_unix_socket_agent():
97
+ # Containers reach their runner through a Unix socket. Keep the path
98
+ # short: socket paths are limited to about 100 bytes.
99
+ with tempfile.TemporaryDirectory(prefix="fb") as d:
100
+ r = FakeRunner(config={"k": "v"}, unix_path=d + "/agent.sock")
101
+ try:
102
+ bot = Bot(agent_url=r.url, token="tok", heartbeat_interval=10, handle_signals=False)
103
+ try:
104
+ assert bot.managed and bot.get("k") == "v"
105
+ with bot.working("in a container"):
106
+ assert wait_for(lambda: r.heartbeats and r.heartbeats[-1]["detail"] == "in a container")
107
+ finally:
108
+ bot.close()
109
+ with pytest.raises(forebot.ForebotError):
110
+ Bot(agent_url=r.url, token="wrong", handle_signals=False)
111
+ finally:
112
+ r.close()
113
+
114
+
115
+ def test_bad_token_raises(runner):
116
+ with pytest.raises(forebot.ForebotError):
117
+ Bot(agent_url=runner.url, token="wrong", handle_signals=False)
118
+
119
+
120
+ def test_activity_is_reported(runner):
121
+ bot = Bot(agent_url=runner.url, token="tok", heartbeat_interval=10, handle_signals=False)
122
+ try:
123
+ with bot.working("job 1"):
124
+ assert wait_for(lambda: runner.heartbeats and runner.heartbeats[-1] == {"activity": "working", "detail": "job 1"})
125
+ assert wait_for(lambda: runner.heartbeats[-1]["activity"] == "idle")
126
+ finally:
127
+ bot.close()
128
+
129
+
130
+ def test_pause_blocks_checkpoint(runner):
131
+ runner.paused = True
132
+ bot = Bot(agent_url=runner.url, token="tok", heartbeat_interval=0.05, handle_signals=False)
133
+ try:
134
+ assert wait_for(lambda: bot.pause_requested)
135
+ done = threading.Event()
136
+
137
+ def work():
138
+ bot.checkpoint()
139
+ done.set()
140
+
141
+ threading.Thread(target=work, daemon=True).start()
142
+ assert wait_for(lambda: runner.heartbeats[-1]["activity"] == "paused")
143
+ assert not done.is_set()
144
+ runner.paused = False
145
+ assert done.wait(3)
146
+ finally:
147
+ bot.close()
148
+
149
+
150
+ def test_stop_ends_loop(runner):
151
+ bot = Bot(agent_url=runner.url, token="tok", handle_signals=False)
152
+ try:
153
+ seen = []
154
+ for i in bot.loop(interval=0.01):
155
+ seen.append(i)
156
+ if i == 2:
157
+ bot.request_stop()
158
+ assert seen == [0, 1, 2]
159
+ assert bot.should_stop()
160
+ assert bot.checkpoint() is False
161
+ finally:
162
+ bot.close()
163
+
164
+
165
+ def test_standalone_config_from_env(monkeypatch):
166
+ monkeypatch.delenv("FOREBOT_AGENT_URL", raising=False)
167
+ monkeypatch.delenv("FOREBOT_AGENT_TOKEN", raising=False)
168
+ monkeypatch.setenv("FOREBOT_CONFIG", '{"a": 1}')
169
+ bot = Bot(handle_signals=False)
170
+ assert not bot.managed
171
+ assert bot.get("a") == 1
172
+ bot.working("x") # no-op, must not fail
173
+ assert bot.checkpoint()
174
+
175
+
176
+ def test_json_log_format():
177
+ buf = io.StringIO()
178
+ forebot.setup_logging(stream=buf, json_format=True)
179
+ log = logging.getLogger("mybot")
180
+ log.warning("hello %s", "world")
181
+ try:
182
+ raise ValueError("boom")
183
+ except ValueError:
184
+ log.exception("failed")
185
+ lines = [json.loads(line) for line in buf.getvalue().splitlines()]
186
+ assert lines[0]["level"] == "warning" and lines[0]["msg"] == "hello world" and lines[0]["logger"] == "mybot"
187
+ assert lines[1]["level"] == "error" and "ValueError: boom" in lines[1]["exc_info"]
forebot-0.2.0/uv.lock ADDED
@@ -0,0 +1,232 @@
1
+ version = 1
2
+ revision = 5
3
+ requires-python = ">=3.9"
4
+ resolution-markers = [
5
+ "python_full_version >= '3.10'",
6
+ "python_full_version < '3.10'",
7
+ ]
8
+
9
+ [[package]]
10
+ name = "colorama"
11
+ version = "0.4.6"
12
+ source = { registry = "https://pypi.org/simple" }
13
+ sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
14
+ wheels = [
15
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
16
+ ]
17
+
18
+ [[package]]
19
+ name = "exceptiongroup"
20
+ version = "1.3.1"
21
+ source = { registry = "https://pypi.org/simple" }
22
+ dependencies = [
23
+ { name = "typing-extensions" },
24
+ ]
25
+ sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
26
+ wheels = [
27
+ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
28
+ ]
29
+
30
+ [[package]]
31
+ name = "forebot"
32
+ version = "0.2.0"
33
+ source = { editable = "." }
34
+
35
+ [package.optional-dependencies]
36
+ test = [
37
+ { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
38
+ { name = "pytest", version = "9.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
39
+ ]
40
+
41
+ [package.metadata]
42
+ requires-dist = [{ name = "pytest", marker = "extra == 'test'", specifier = ">=7" }]
43
+ provides-extras = ["test"]
44
+
45
+ [[package]]
46
+ name = "iniconfig"
47
+ version = "2.1.0"
48
+ source = { registry = "https://pypi.org/simple" }
49
+ resolution-markers = [
50
+ "python_full_version < '3.10'",
51
+ ]
52
+ sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
53
+ wheels = [
54
+ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
55
+ ]
56
+
57
+ [[package]]
58
+ name = "iniconfig"
59
+ version = "2.3.1"
60
+ source = { registry = "https://pypi.org/simple" }
61
+ resolution-markers = [
62
+ "python_full_version >= '3.10'",
63
+ ]
64
+ sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
65
+ wheels = [
66
+ { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
67
+ ]
68
+
69
+ [[package]]
70
+ name = "packaging"
71
+ version = "26.3"
72
+ source = { registry = "https://pypi.org/simple" }
73
+ sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
74
+ wheels = [
75
+ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
76
+ ]
77
+
78
+ [[package]]
79
+ name = "pluggy"
80
+ version = "1.6.0"
81
+ source = { registry = "https://pypi.org/simple" }
82
+ resolution-markers = [
83
+ "python_full_version < '3.10'",
84
+ ]
85
+ sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
86
+ wheels = [
87
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
88
+ ]
89
+
90
+ [[package]]
91
+ name = "pluggy"
92
+ version = "1.7.0"
93
+ source = { registry = "https://pypi.org/simple" }
94
+ resolution-markers = [
95
+ "python_full_version >= '3.10'",
96
+ ]
97
+ sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" }
98
+ wheels = [
99
+ { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" },
100
+ ]
101
+
102
+ [[package]]
103
+ name = "pygments"
104
+ version = "2.21.0"
105
+ source = { registry = "https://pypi.org/simple" }
106
+ sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
107
+ wheels = [
108
+ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
109
+ ]
110
+
111
+ [[package]]
112
+ name = "pytest"
113
+ version = "8.4.2"
114
+ source = { registry = "https://pypi.org/simple" }
115
+ resolution-markers = [
116
+ "python_full_version < '3.10'",
117
+ ]
118
+ dependencies = [
119
+ { name = "colorama", marker = "sys_platform == 'win32'" },
120
+ { name = "exceptiongroup" },
121
+ { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" } },
122
+ { name = "packaging" },
123
+ { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" } },
124
+ { name = "pygments" },
125
+ { name = "tomli" },
126
+ ]
127
+ sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
128
+ wheels = [
129
+ { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
130
+ ]
131
+
132
+ [[package]]
133
+ name = "pytest"
134
+ version = "9.2.0"
135
+ source = { registry = "https://pypi.org/simple" }
136
+ resolution-markers = [
137
+ "python_full_version >= '3.10'",
138
+ ]
139
+ dependencies = [
140
+ { name = "colorama", marker = "sys_platform == 'win32'" },
141
+ { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
142
+ { name = "iniconfig", version = "2.3.1", source = { registry = "https://pypi.org/simple" } },
143
+ { name = "packaging" },
144
+ { name = "pluggy", version = "1.7.0", source = { registry = "https://pypi.org/simple" } },
145
+ { name = "pygments" },
146
+ { name = "tomli", marker = "python_full_version < '3.11'" },
147
+ ]
148
+ sdist = { url = "https://files.pythonhosted.org/packages/79/42/bb4641652e58dedeac2ec51abc1f8cae4fee4ec63899a0999016229a0f2a/pytest-9.2.0.tar.gz", hash = "sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b", size = 1797555, upload-time = "2026-10-27T21:30:20.728Z" }
149
+ wheels = [
150
+ { url = "https://files.pythonhosted.org/packages/51/39/1f5d8c8877a0fa327db959c19341d4be61660de1ab6b7391f9eb805bcd67/pytest-9.2.0-py3-none-any.whl", hash = "sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588", size = 411776, upload-time = "2026-10-27T21:30:19.131Z" },
151
+ ]
152
+
153
+ [[package]]
154
+ name = "tomli"
155
+ version = "2.5.0"
156
+ source = { registry = "https://pypi.org/simple" }
157
+ sdist = { url = "https://files.pythonhosted.org/packages/b0/78/9ad63712633ed3ab5cc1a648d863d7e7da371e9425e209555a0fe711b695/tomli-2.5.0.tar.gz", hash = "sha256:264507556cd8b8c8e7c6ee037cdf443a463f03f4c958e57195e3d369711b8ff6", size = 17662, upload-time = "2026-10-07T12:23:37.892Z" }
158
+ wheels = [
159
+ { url = "https://files.pythonhosted.org/packages/22/a6/ab99b60ee52acd949684febabc3005d0045d0f66bebd9cdebd67372d26dd/tomli-2.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c4dc1c1781f2f716de763d1e9a7b34c6a894e167e291c7c5d16c72f7a9538545", size = 163901, upload-time = "2026-10-07T12:22:15.601Z" },
160
+ { url = "https://files.pythonhosted.org/packages/bc/00/ee01b7ed4579180fff07142d290257f25ba786f23f3ec6005f620933c2f5/tomli-2.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:eff8babca5a7999bc137acbc7482a8b7e17ffca5075ab41f5d770ab408c7bfef", size = 163756, upload-time = "2026-10-07T12:22:16.957Z" },
161
+ { url = "https://files.pythonhosted.org/packages/72/c2/4efebf65372f6583185f79799312109dddb61102d47e5c33dcfd1a297aca/tomli-2.5.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86665cee9c4835b7a7f1e8ec2c719b5258d4dc782887aded5a8ae7352a96843b", size = 268038, upload-time = "2026-10-07T12:22:18.135Z" },
162
+ { url = "https://files.pythonhosted.org/packages/53/07/5850468e925d898abb36038666f9c333a94d2a223e802a8ba5b6d319d23f/tomli-2.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e369fd63331746182360977b1892bfc215476a30d61612d732425311639f56", size = 276422, upload-time = "2026-10-07T12:22:19.567Z" },
163
+ { url = "https://files.pythonhosted.org/packages/b4/87/f293984cdcf83c054196d4fd3dad44fc68ae55b4b8c44bc76cef360c3150/tomli-2.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ad1ea345759240d6463efa0ed1c704402752e49aa21476620738d74d72d8aa1", size = 272616, upload-time = "2026-10-07T12:22:20.794Z" },
164
+ { url = "https://files.pythonhosted.org/packages/ce/ce/db582886b3c1219d3fec93ebd669332482e5aee7a91e0f7838d84f2d1759/tomli-2.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:96243987194634bd411066ce40c952e108f86af04db533ecd8ac3ff2a85b1885", size = 276593, upload-time = "2026-10-07T12:22:22.12Z" },
165
+ { url = "https://files.pythonhosted.org/packages/bf/72/7619b87dea4261fc27dd7b54c4461c129c1f7d9bb7ba3aec89c797a431b8/tomli-2.5.0-cp311-cp311-win32.whl", hash = "sha256:610b27d99f28ec5f191c7064a48f3ddb179a1fe6ca73d571483ae859f57b605e", size = 101830, upload-time = "2026-10-07T12:22:23.651Z" },
166
+ { url = "https://files.pythonhosted.org/packages/1e/74/220106da34502304b6751a2a9b8a9fbca6c3fd47e737a2e2e3da7c61c9db/tomli-2.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:c804ae44fe7b4bab5da295e4f980a1ff04670bca9d23fe0a4e887e08ebd741a8", size = 112742, upload-time = "2026-10-07T12:22:24.972Z" },
167
+ { url = "https://files.pythonhosted.org/packages/27/99/7d9c8b41837a7773613e169504147375c157a290167aa59ad74a085f521f/tomli-2.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:cfac177ebd6236003846ea339981f71457cb6eb748f23381eb257e45092e3980", size = 109332, upload-time = "2026-10-07T12:22:26.117Z" },
168
+ { url = "https://files.pythonhosted.org/packages/52/ed/7baa86f87493646a594de388c7c1c40a39dd0461f7e9c0359cbeefc91fe8/tomli-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f4a40d03fb9f63424f0979855bdeaf44dd7696b8d59501822c10ed30ba532df", size = 164854, upload-time = "2026-10-07T12:22:27.444Z" },
169
+ { url = "https://files.pythonhosted.org/packages/a5/b1/44c0341f2224397855723c7a8a39f718ea6fcbcc3dacc66e5aeca0f334e3/tomli-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9ebf8d19b17bd0daeb7b7dec81a946a439b753942fd0210d6e96c532249eea6b", size = 164074, upload-time = "2026-10-07T12:22:28.679Z" },
170
+ { url = "https://files.pythonhosted.org/packages/23/04/e2d5b7d3fba47adedb23de616c16d428ea076c79a3d8e1d95d649ffe197e/tomli-2.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf0b5e8e0f68ebb494356e577c06c139161efd8d3b9050f93b39b7c26cc54ff0", size = 274274, upload-time = "2026-10-07T12:22:29.804Z" },
171
+ { url = "https://files.pythonhosted.org/packages/43/90/6090e706ff27a6f89f4a40578e3324b95c3cd8c4150868aabf33a8f414c3/tomli-2.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cf74416bdc94ae458b14e37286c1073081850ac8459a00d0c5efef5d44294c6", size = 286435, upload-time = "2026-10-07T12:22:31.297Z" },
172
+ { url = "https://files.pythonhosted.org/packages/0a/9e/a2c40768df16c408f22430afb0a73e9d7e5f79c950884954649d1146b74d/tomli-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:61ea1ebe1e55a34ea8199cc8dbff398d35027b82271c8ac4802fd3a1fd5b1bcc", size = 278119, upload-time = "2026-10-07T12:22:32.601Z" },
173
+ { url = "https://files.pythonhosted.org/packages/12/25/3c0cb485b98e9cfac495629b1c93c87ccf0b72fbe9d2689fd8fe62c6d5a3/tomli-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed53f7e89bb04f6d9e8e7799112360b0c4d5cbff067de0814c98c37c39b920f7", size = 286177, upload-time = "2026-10-07T12:22:33.745Z" },
174
+ { url = "https://files.pythonhosted.org/packages/77/8b/0144c65f0e37e51c18d04ae15c21b19431c165002d0131fe9aa8b0b8b1e8/tomli-2.5.0-cp312-cp312-win32.whl", hash = "sha256:e7ad033e27a516a233bea839cdb77b80146facb3b4f40bf02cd0cac165cdd5c2", size = 102760, upload-time = "2026-10-07T12:22:34.887Z" },
175
+ { url = "https://files.pythonhosted.org/packages/de/32/5d6d8f42fc9a05fce69354e00ff256484192f5f2fc9a2165718fa0de61ec/tomli-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:bd05de8c1698f8413dd7d869492693a0bf2211543b787ac78cd5e7536af1a6d7", size = 112722, upload-time = "2026-10-07T12:22:36.162Z" },
176
+ { url = "https://files.pythonhosted.org/packages/30/65/df18032218db0fb9b769fb23c8039a051f15c811993995ea04c350273a32/tomli-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:069435bd5480429b98c5e5afb02ab21c219b6f0064680671c6dc0d46817346ea", size = 109534, upload-time = "2026-10-07T12:22:37.296Z" },
177
+ { url = "https://files.pythonhosted.org/packages/42/e5/51736d70da209350969e15aca5c5ab6e2ce1ea87a0a892a6c13aec172a86/tomli-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:943276cf269e0071948d9ff697159c1735e623c1151d88abb09b74659ef0cbea", size = 163328, upload-time = "2026-10-07T12:22:38.373Z" },
178
+ { url = "https://files.pythonhosted.org/packages/ec/55/086f80dab4ab497602644274e6dea7ec5dd0b4e262e443a8ad3bb7edee2d/tomli-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:463b16086865b97facd8d0b3fb4cb7c544e3f58d2a69dc3113d6db9653fdb043", size = 162246, upload-time = "2026-10-07T12:22:39.673Z" },
179
+ { url = "https://files.pythonhosted.org/packages/aa/eb/3ecc94459f3635c92321f4e7bde571323fdb2267c50e19e3188a281eae3b/tomli-2.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1245a6638fc4bb0a60af38a7d45413db34a13842027c77597c712c998c62fdf0", size = 272655, upload-time = "2026-10-07T12:22:41.08Z" },
180
+ { url = "https://files.pythonhosted.org/packages/c0/d7/494fd1f0c37a621f1ad9975c2efadb523e8101f144ed6edb2e7fe64738f2/tomli-2.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d8bac3d603c97e6854424e5b2b5b741bdbde387e09f162fb0446812b4a8362b", size = 283595, upload-time = "2026-10-07T12:22:42.222Z" },
181
+ { url = "https://files.pythonhosted.org/packages/70/51/bb8d62b1317e6640866f6949b2d5855e5300f2c99d46de1cd245570bba65/tomli-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:21e4cae4114aba25aa0d4f85cdf486d290fb35c0954d7bba536248da64d43066", size = 276253, upload-time = "2026-10-07T12:22:43.625Z" },
182
+ { url = "https://files.pythonhosted.org/packages/66/f4/f46bd7f0763cd47de2db697dca9257c6a4adfd1a93b018cc75c8190ed5a8/tomli-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bbaefc84548d754be821bba7c4141c4787dda182f9e77f2f87b71213529efa7b", size = 283582, upload-time = "2026-10-07T12:22:44.983Z" },
183
+ { url = "https://files.pythonhosted.org/packages/ac/03/70f2bcb2923a6db37818d917e124270a7f4cfd38ea576f5aa753a91c0ef5/tomli-2.5.0-cp313-cp313-win32.whl", hash = "sha256:abdbf6313b8d9efe157edeb7ab6eae4de064b1300ad31abf73755154b30abe68", size = 102628, upload-time = "2026-10-07T12:22:46.508Z" },
184
+ { url = "https://files.pythonhosted.org/packages/dc/98/d52024bb5b0ff68b4f0d276d867f634c84a67319a7e9f6b7708a37742333/tomli-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:fd4dc129784e0c5335bd4e61dfcc4487499a013419e655cf2da1d091b7e0efdc", size = 113301, upload-time = "2026-10-07T12:22:47.647Z" },
185
+ { url = "https://files.pythonhosted.org/packages/6f/f2/540db3a70572a8c23a28aba3e9c358ce0ffffbafc990905c1343aa265b31/tomli-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:69491c143d2fe063046e0301e62a810bed338fa4d1ce0fd870c27dc1e09b0d84", size = 109744, upload-time = "2026-10-07T12:22:48.925Z" },
186
+ { url = "https://files.pythonhosted.org/packages/e4/49/caf6b307766eb9567664a8707e9d6be5fcc0e8903f18781c6677a60d80c7/tomli-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d3182ee2d887e507bd67319a0a61105d1dd33facc111329559a233b772c1a105", size = 162899, upload-time = "2026-10-07T12:22:50.088Z" },
187
+ { url = "https://files.pythonhosted.org/packages/d3/c8/68cfce773a2733a49c74f99d627fb461bd990756860099eac25617889585/tomli-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:521345fd1f19d45b8df87657aaa38b6f2ca3800059fadf428e7ebf479a383646", size = 162080, upload-time = "2026-10-07T12:22:51.558Z" },
188
+ { url = "https://files.pythonhosted.org/packages/7e/b2/e5bb8651fdad593f670501a7d718b1a7f73f064d44dea15e04c04dfef45d/tomli-2.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e95c7614e705bfe2b04b27aa124adec59752d15813df37e2156747cab3a006b", size = 273380, upload-time = "2026-10-07T12:22:52.918Z" },
189
+ { url = "https://files.pythonhosted.org/packages/8d/d2/9e2d7f8b1dfe0e2b34c245986ebd55c4c553ea4ce6c47c443b332673253f/tomli-2.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ac2027d37c3afbdf4bdd377f2676f6f1d2122a5be1f1137b49dced590b37e75", size = 283228, upload-time = "2026-10-07T12:22:54.173Z" },
190
+ { url = "https://files.pythonhosted.org/packages/ba/df/ec7b876b7b1a2718bd74a3743c076fff565b04029ba33e8f61fac262739f/tomli-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c414be4ed9d3cac80c42e348fa5a956117d1a48227f48026e31f59cb4a7671eb", size = 277189, upload-time = "2026-10-07T12:22:55.342Z" },
191
+ { url = "https://files.pythonhosted.org/packages/7d/7b/e192d9eed0b9cb80da799f4d77052297fb9a2c3cc9b19f571f56ea88add6/tomli-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9b03d7dc168353b4132965bde20feceabaa470e570c6f59660dfae59b1f9eeb3", size = 283632, upload-time = "2026-10-07T12:22:56.735Z" },
192
+ { url = "https://files.pythonhosted.org/packages/84/50/ff94454e75461d75623e47401ed323d65c10aab8fe9033242c20cd2fdf32/tomli-2.5.0-cp314-cp314-win32.whl", hash = "sha256:6f041843c4d3a37245c0c056fd955b186bf8b1fb85690cbe40b81230891dc34b", size = 103535, upload-time = "2026-10-07T12:22:58.084Z" },
193
+ { url = "https://files.pythonhosted.org/packages/54/0b/bdacf05f963bd6026ebf6eeb0beda847d1d60e03e440725c64a4e08a0afd/tomli-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:f4b653094e18f9031102d3a1da5c729c8f222d85225b18037dac621695e46e1a", size = 114621, upload-time = "2026-10-07T12:22:59.2Z" },
194
+ { url = "https://files.pythonhosted.org/packages/61/99/53f438fa6ae4f9d4ed0ddde3e7242b3bdc34b48c8f9948b72b9e9b127676/tomli-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3f89d10c1ff6a38d992c27fc8a4816af71a909e08a40ec66934240b1e74347c3", size = 111572, upload-time = "2026-10-07T12:23:00.479Z" },
195
+ { url = "https://files.pythonhosted.org/packages/b9/20/1f88f19427d380a40e90a770e087489eaafe4aeee070ae88ed2bbec00acd/tomli-2.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:e9e15b4a6c7dd6b85b5fbab29488a73f1f70de516942308daa266bf0e0aeb0d4", size = 171814, upload-time = "2026-10-07T12:23:01.914Z" },
196
+ { url = "https://files.pythonhosted.org/packages/d0/56/cbe5079c9f9a54b9b3e27fc82f08f3cb36edee75561679f53d2380c801d6/tomli-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e12bbcd32897272fb05929110362ae9ff4c1b9bb26bd9e971e71dcd3275b4c3d", size = 171324, upload-time = "2026-10-07T12:23:03.18Z" },
197
+ { url = "https://files.pythonhosted.org/packages/2b/30/1d53fd3b0f1cb3ba542e345ec32c26aefdddc4e829e4f3429af8a4f27782/tomli-2.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20aa36de8f2cf87237143bc1fa1aae8d6612c09118f4da21c6a684db5dd1f6f9", size = 297441, upload-time = "2026-10-07T12:23:04.345Z" },
198
+ { url = "https://files.pythonhosted.org/packages/66/d9/0800acb6a111686f764c1b91ef15cc42a20a66a46013bb42220f1d2c61c1/tomli-2.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22185fad8a1e622f064e78008018a0dd3323550dcb479cb7a1d296888d74024f", size = 307476, upload-time = "2026-10-07T12:23:05.671Z" },
199
+ { url = "https://files.pythonhosted.org/packages/e8/63/30a8f3cd51b5bec37f04744bad0b0dc6160df84aad4f27b0e9283d66f221/tomli-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:984012f71908165449a951de2050d52f276bfe3aa5d5f570f63ddad814370374", size = 296113, upload-time = "2026-10-07T12:23:07.202Z" },
200
+ { url = "https://files.pythonhosted.org/packages/ab/18/0b9ffc597e69c5a1e20a7823cb60d54b39a9f54e91edcb8574f022186758/tomli-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f79203b3965b4000e91808aaa7c040206093f2b8bf86f455982f2274c9ccf442", size = 307725, upload-time = "2026-10-07T12:23:08.508Z" },
201
+ { url = "https://files.pythonhosted.org/packages/ab/c7/18f8baae0b5607a60e8e19b4a7fedee43a8ff6458e3896dcbbadeeac9c22/tomli-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:91294a9fb94a75542f6e46e4a2ae709bd8d9b51134098cae5cf3bea5478b6d03", size = 108546, upload-time = "2026-10-07T12:23:09.956Z" },
202
+ { url = "https://files.pythonhosted.org/packages/72/34/4cca9739254130627bde87500b3f2b512154fe2f278efa7e2a5e10ad4bcb/tomli-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f15e3e0b835a6d68b10c86bf80a3149780498d6911c93c3ffd1861d19f9200f1", size = 117814, upload-time = "2026-10-07T12:23:11.486Z" },
203
+ { url = "https://files.pythonhosted.org/packages/7d/fb/afa530d47dd80a78fce43beac6bc6e00f84558eafcffbc6f37b21e80d056/tomli-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6664b7ae7af7294256c53960a6103077f4914cec8ff98479c352f622c6f6b2f0", size = 115188, upload-time = "2026-10-07T12:23:12.728Z" },
204
+ { url = "https://files.pythonhosted.org/packages/66/98/316fdc00f8c0939e6fe50461dd343c162d3ad51d1286eb25b7db54361d50/tomli-2.5.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a525685c2f97da40762b8695eb7aa0af4c8344ca1905c73e4e29cb04d34607dc", size = 162775, upload-time = "2026-10-07T12:23:13.941Z" },
205
+ { url = "https://files.pythonhosted.org/packages/c5/22/7b10fa5bb01c9539f53f69b619361b19350acc73657772ea7ac70ba309a8/tomli-2.5.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:9dbb18c1cfb2f6517942fc9314437f66aa06d94436ffb1f06102ef3572f35276", size = 161406, upload-time = "2026-10-07T12:23:15.215Z" },
206
+ { url = "https://files.pythonhosted.org/packages/9c/e7/1a069d86dfd20f1f84f71c63faed9f83c1d890bc06c27d82dc7d888fb573/tomli-2.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:752e8b1aa6a4367ef8bf6a1a1e005540f7ed055ba36d7193796812ca5404eb52", size = 273855, upload-time = "2026-10-07T12:23:16.471Z" },
207
+ { url = "https://files.pythonhosted.org/packages/ae/83/d1ef43d1687d092ab9c235455c76e6e709483b346b056f086095c7c263a5/tomli-2.5.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47300f9bf791808f77d82747691c4bb09cb14bdf3060cca99b42cdc4361d5a7", size = 284910, upload-time = "2026-10-07T12:23:18.166Z" },
208
+ { url = "https://files.pythonhosted.org/packages/cc/05/f4d9cf7de61822ece0c3873f30d291e324911c71a378b8bfe5ced13fd9f5/tomli-2.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:19b0dd8749f4ea2f112c5fcfb3c5248390c899d7e2e173f1d91abee1fa0ff391", size = 277723, upload-time = "2026-10-07T12:23:19.355Z" },
209
+ { url = "https://files.pythonhosted.org/packages/42/28/78262493141fa543151cf005760c3cb01d09fc28a11f993c05109902cb8c/tomli-2.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:57b1c3b01fab802e2899bc3d168dca320e14165e2fd9fd584760fb4ca5826859", size = 285115, upload-time = "2026-10-07T12:23:20.698Z" },
210
+ { url = "https://files.pythonhosted.org/packages/1a/b9/e1dab9a30bcb677b5cc5cee810609cfd64f24306a3055767dd3fda00b1e0/tomli-2.5.0-cp315-cp315-win32.whl", hash = "sha256:667e521b37a6c5ccaa044202c235b530f90177ffe2cd4a64ecc213c7dd535feb", size = 103475, upload-time = "2026-10-07T12:23:21.941Z" },
211
+ { url = "https://files.pythonhosted.org/packages/4c/bd/31a3790c11d6ea95fcf5e6022ac0f8d0543c9b61120b730fc481bd43d3b4/tomli-2.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:d747252933c8a65ef6bd8da0fbb7ce28a90eb6119d8cd00772cd528aa07b68d5", size = 114589, upload-time = "2026-10-07T12:23:23.098Z" },
212
+ { url = "https://files.pythonhosted.org/packages/47/a2/4f6310fa699364f0e3af7ee3af88dddd9af066d33e716a0265bbe2b3ea84/tomli-2.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:75dbcde8751b0a960aa3de173aa5e894d590755c6d7758b7e774c06f1dc3cbdd", size = 111493, upload-time = "2026-10-07T12:23:24.233Z" },
213
+ { url = "https://files.pythonhosted.org/packages/68/14/00853f0b396d8971107ae1921bb5b322fdee1650d2f16bf06c20adb532e5/tomli-2.5.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2419c2a189551987b59d80e63ec355671283336f41c6b9b89462df679c7d0c57", size = 171380, upload-time = "2026-10-07T12:23:25.512Z" },
214
+ { url = "https://files.pythonhosted.org/packages/89/ad/fa6949321dadee46b27363974fb197b94c911c3b0f7a5fd26d7dc18fc2a0/tomli-2.5.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0dc598040da8d42cf20f0be588ed7004f46db12a0ac6c32e03a59dccedaaadcd", size = 170553, upload-time = "2026-10-07T12:23:26.855Z" },
215
+ { url = "https://files.pythonhosted.org/packages/53/aa/3056c919eb3e084df3752b2cf5f865dcc04af0b27dba2f66d7b28af4633a/tomli-2.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49096930c8d886c9bbdab62d2d0d17ce823ddeea522309a190b36245d5b49e01", size = 294428, upload-time = "2026-10-07T12:23:28.132Z" },
216
+ { url = "https://files.pythonhosted.org/packages/96/b2/faeeb5d8769ea3832021d73e892c8391eae7b4b4f8b55a789127bd8b18a9/tomli-2.5.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8ade5023067f99fe72b88accd30d0ea05a158e9e32a11f124e731ea9695313f", size = 304909, upload-time = "2026-10-07T12:23:29.381Z" },
217
+ { url = "https://files.pythonhosted.org/packages/f6/52/f094c09e73fb654b621716d019acb5d29bdfd1be01df80c281d552bda48d/tomli-2.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b69564772b5c8f22ea5f498dff08cfa825045b4d4c4400529000bdf818aa3b2a", size = 293220, upload-time = "2026-10-07T12:23:30.608Z" },
218
+ { url = "https://files.pythonhosted.org/packages/86/f5/0c30541078ca4b505ce3bd76ed931facbfec524dd018535d691d1af0a6d2/tomli-2.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:8ff3a2ca028c7eee0c777f9a092038d0a594a9fa04e215f929a22c329e2cb142", size = 305705, upload-time = "2026-10-07T12:23:32.181Z" },
219
+ { url = "https://files.pythonhosted.org/packages/05/74/590e7d19d6a118fc5cc5704ff358e21d95b8573f6b9443b1519f29ca8825/tomli-2.5.0-cp315-cp315t-win32.whl", hash = "sha256:62fc1bc8eb03e3a9cadfca713d65614ed8e09d974a283295ffe3a831976b4dc5", size = 108432, upload-time = "2026-10-07T12:23:33.496Z" },
220
+ { url = "https://files.pythonhosted.org/packages/1c/b8/63a75cfb27a17c38550e44025d3a6e7be64516fd8608a3b75703bf37d81b/tomli-2.5.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f3fcbc57b1791fa6cbe5d8434179d51de12be1a4811469529f47f6e7487a2571", size = 117281, upload-time = "2026-10-07T12:23:34.648Z" },
221
+ { url = "https://files.pythonhosted.org/packages/72/01/e8c1debb2173973372934c68fc8e46170ab60ef23ed4592dff4dec6e8993/tomli-2.5.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d2ba24db8a9376921b5e87b4762b9adb0f3f1deaea68f2b8b0bb2c11efb9c3e7", size = 115069, upload-time = "2026-10-07T12:23:35.77Z" },
222
+ { url = "https://files.pythonhosted.org/packages/60/3f/3e3f8fd0919249b0200c80fbc4f9a1e70be19f9883da71dfb7f8b9ab8aca/tomli-2.5.0-py3-none-any.whl", hash = "sha256:32a7b79ac57a2e83670ce329ccf675798bc5a2094783a63676866b70503f2e2b", size = 14765, upload-time = "2026-10-07T12:23:36.875Z" },
223
+ ]
224
+
225
+ [[package]]
226
+ name = "typing-extensions"
227
+ version = "4.16.0"
228
+ source = { registry = "https://pypi.org/simple" }
229
+ sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
230
+ wheels = [
231
+ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
232
+ ]