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.
- forebot-0.2.0/.github/workflows/ci.yml +24 -0
- forebot-0.2.0/.github/workflows/publish.yml +46 -0
- forebot-0.2.0/.gitignore +7 -0
- forebot-0.2.0/PKG-INFO +54 -0
- forebot-0.2.0/README.md +38 -0
- forebot-0.2.0/pyproject.toml +31 -0
- forebot-0.2.0/src/forebot/__init__.py +33 -0
- forebot-0.2.0/src/forebot/bot.py +341 -0
- forebot-0.2.0/src/forebot/logs.py +57 -0
- forebot-0.2.0/src/forebot/py.typed +0 -0
- forebot-0.2.0/tests/test_bot.py +187 -0
- forebot-0.2.0/uv.lock +232 -0
|
@@ -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
|
forebot-0.2.0/.gitignore
ADDED
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.
|
forebot-0.2.0/README.md
ADDED
|
@@ -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
|
+
]
|