19 Commits

Author SHA1 Message Date
klemek 5f95afd0a3 chore: release 1.5.0
Python Lint CI / ruff (push) Successful in 1m59s
Docker CI / docker-build (push) Successful in 3m6s
Python Lint CI / ruff-format-check (push) Successful in 1m50s
Python Lint CI / ty (push) Successful in 3m57s
Python Test CI / coverage (push) Successful in 3m21s
2026-06-03 19:32:58 +02:00
klemek 132cbe9b20 feat: rate limit 2026-06-03 19:32:18 +02:00
klemek ada69f773e fix: force upgrade for all http methods
Python Lint CI / ruff-format-check (push) Successful in 1m2s
Python Lint CI / ruff (push) Successful in 1m4s
Python Lint CI / ty (push) Successful in 1m10s
Docker CI / docker-build (push) Successful in 2m32s
Python Test CI / coverage (push) Successful in 3m26s
2026-06-03 18:59:59 +02:00
klemek c5f3e53d88 feat: stapler challenge for self discovery 2026-06-03 18:56:07 +02:00
klemek 24b29f5716 chore: release 1.4.2
Python Lint CI / ruff (push) Successful in 2m37s
Python Lint CI / ruff-format-check (push) Successful in 2m23s
Docker CI / docker-build (push) Successful in 3m40s
Python Lint CI / ty (push) Successful in 3m19s
Python Test CI / coverage (push) Successful in 2m20s
2026-06-03 00:13:05 +02:00
klemek fa0fab0f15 fix(cert_manager): indicate no email in certbot 2026-06-03 00:12:03 +02:00
klemek cc3bd48ddf fix(cert_manager): typo in valid_host 2026-06-03 00:00:48 +02:00
klemek 48b52f9fea perf(cert_manager): slightly faster self signed generation
Python Lint CI / ruff-format-check (push) Successful in 1m10s
Python Lint CI / ruff (push) Failing after 1m11s
Docker CI / docker-build (push) Successful in 2m17s
Python Lint CI / ty (push) Failing after 1m56s
Python Test CI / coverage (push) Failing after 4m38s
2026-06-02 23:54:51 +02:00
klemek 2040b709d3 perf(registry): compute host-based dict
Python Lint CI / ruff-format-check (push) Successful in 1m11s
Python Lint CI / ruff (push) Successful in 1m11s
Python Lint CI / ty (push) Successful in 1m30s
Docker CI / docker-build (push) Successful in 2m31s
Python Test CI / coverage (push) Has been cancelled
2026-06-02 23:51:37 +02:00
klemek e0c8eb1724 fix(params): isdecimal instead of isnumeric
Python Lint CI / ruff (push) Successful in 3m42s
Python Lint CI / ruff-format-check (push) Successful in 3m51s
Python Lint CI / ty (push) Successful in 1m13s
Docker CI / docker-build (push) Successful in 5m28s
Python Test CI / coverage (push) Successful in 3m31s
2026-06-02 23:41:12 +02:00
klemek d84c5911b0 fix(handlers): handle non integer content-length
Docker CI / docker-build (push) Has been cancelled
Python Lint CI / ty (push) Has been cancelled
Python Lint CI / ruff (push) Has been cancelled
Python Lint CI / ruff-format-check (push) Has been cancelled
Python Test CI / coverage (push) Has been cancelled
2026-06-02 23:40:12 +02:00
klemek 2bc2593bc9 fix(registry): race condition handled with page 'ready'
Python Lint CI / ruff (push) Successful in 1m0s
Python Lint CI / ty (push) Successful in 1m0s
Python Lint CI / ruff-format-check (push) Successful in 1m0s
Docker CI / docker-build (push) Has been cancelled
Python Test CI / coverage (push) Has been cancelled
2026-06-02 23:36:35 +02:00
klemek 81b3007efd fix(token_manager): race condition for file stat
Python Lint CI / ruff-format-check (push) Successful in 9m32s
Python Lint CI / ruff (push) Successful in 9m33s
Python Lint CI / ty (push) Successful in 9m18s
Docker CI / docker-build (push) Successful in 10m35s
Python Test CI / coverage (push) Successful in 2m20s
2026-06-02 23:08:29 +02:00
klemek c3131acc88 refactor(token_manager): use pbkdf2 hmac
Docker CI / docker-build (push) Has been cancelled
Python Lint CI / ruff (push) Has been cancelled
Python Lint CI / ruff-format-check (push) Has been cancelled
Python Lint CI / ty (push) Has been cancelled
Python Test CI / coverage (push) Has been cancelled
2026-06-02 23:07:30 +02:00
klemek 60b6b0e592 fix(data_dir): empty dir check if path exists before remove
Python Lint CI / ty (push) Successful in 2m7s
Docker CI / docker-build (push) Successful in 2m59s
Python Lint CI / ruff-format-check (push) Successful in 2m10s
Python Lint CI / ruff (push) Successful in 2m10s
Python Test CI / coverage (push) Successful in 6m32s
2026-06-02 22:41:31 +02:00
klemek 247dd7dda3 fix(handlers): keep proxy and redirect case
Docker CI / docker-build (push) Has been cancelled
Python Lint CI / ty (push) Has been cancelled
Python Lint CI / ruff (push) Has been cancelled
Python Lint CI / ruff-format-check (push) Has been cancelled
Python Test CI / coverage (push) Has been cancelled
2026-06-02 22:37:23 +02:00
klemek d2a656a839 fix(handlers): positive content-length check
Python Lint CI / ruff (push) Successful in 1m8s
Python Lint CI / ty (push) Successful in 1m8s
Python Lint CI / ruff-format-check (push) Successful in 1m8s
Docker CI / docker-build (push) Has been cancelled
Python Test CI / coverage (push) Has been cancelled
2026-06-02 22:35:03 +02:00
klemek b234504b49 fix(handlers): check certbot challenge 2026-06-02 22:35:03 +02:00
klemek 66f7879c0f fix(cert_manager): check host on SNI callback
Python Lint CI / ruff (push) Successful in 1m22s
Python Lint CI / ruff-format-check (push) Successful in 1m23s
Python Lint CI / ty (push) Successful in 1m23s
Docker CI / docker-build (push) Successful in 2m27s
Python Test CI / coverage (push) Successful in 3m4s
2026-06-02 22:05:16 +02:00
12 changed files with 303 additions and 103 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "stapler"
version = "1.4.1"
version = "1.5.0"
description = "Static pages as simple as a gzip file"
requires-python = ">=3.14"
dependencies = [
+3 -2
View File
@@ -106,7 +106,7 @@ class CertManager:
"req",
"-new",
"-newkey",
"rsa:4096",
"rsa:2048",
"-days",
str(self.SELF_SIGNED_DAYS),
"-nodes",
@@ -161,6 +161,7 @@ class CertManager:
"certonly",
"--non-interactive",
"--agree-tos",
"--register-unsafely-without-email",
"--webroot",
"--webroot-path",
self.certbot_www,
@@ -192,7 +193,7 @@ class CertManager:
_: ssl.SSLSocket,
/,
) -> None | int:
if host is None:
if host is None or not valid_host(host):
return None
self.logger.debug("servername callback: %s", host)
if not self.exists(host) and not self.create_or_update(host):
+1
View File
@@ -99,6 +99,7 @@ class DataDir:
self.logger.debug("Deleted %s", target_path)
def empty(self, path: str) -> None:
if self.exists(path):
self.remove(path)
target_path = self.root_path / path
target_path.mkdir()
+74 -7
View File
@@ -1,5 +1,6 @@
import abc
import contextlib
import datetime
import http
import http.cookiejar
import http.server
@@ -252,7 +253,10 @@ class BaseHandler(abc.ABC, http.server.BaseHTTPRequestHandler):
return self.__in_size
def _get_length(self) -> int:
return int(self._get_header("Content-Length", "0"))
try:
return max(0, int(self._get_header("Content-Length", "0")))
except ValueError:
return 0
def _get_header(self, key: str, default_value: str = "") -> str:
if self._has_header(key):
@@ -286,6 +290,7 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler):
protocol_version = "HTTP/1.1"
server_version = "StaplerServer/" + PKG_VERSION
CERTBOT_CHALLENGE_PATH = "/.well-known/acme-challenge"
STAPLER_CHALLENGE_PATH = "/.well-known/stapler"
UPDATE_PATH_REGEX = re.compile(r"^\/([\w-]+)\/?$")
GET_PATH_REGEX = re.compile(r"^\/([\w-]+)($|\/)")
HOST_PART_REGEX = re.compile(r"^([a-z0-9]|[a-z0-9][a-z0-9-]{,61}[a-z0-9])$")
@@ -296,6 +301,7 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler):
REDIRECT_HEADER = "X-Redirect"
PROXY_HEADER = "X-Proxy"
SPA_HEADER = "X-SPA"
RATE_LIMIT = datetime.timedelta(seconds=1)
@typing.override
def __init__(
@@ -319,6 +325,7 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler):
self.__target_redirect: str | None = None
self.__target_proxy: str | None = None
self.__target_spa: str | None = None
self.rate_limits: dict[str, datetime.datetime] = {}
try:
super().__init__(*args, directory=params.data_dir, **kwargs, params=params) # ty:ignore[unknown-argument]
except (BrokenPipeError, ConnectionResetError) as e:
@@ -369,7 +376,7 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler):
@property
def target_redirect(self) -> str:
if self.__target_redirect is None:
self.__target_redirect = self._get_header(self.REDIRECT_HEADER).lower()
self.__target_redirect = self._get_header(self.REDIRECT_HEADER)
return self.__target_redirect
@property
@@ -379,7 +386,7 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler):
@property
def target_proxy(self) -> str:
if self.__target_proxy is None:
self.__target_proxy = self._get_header(self.PROXY_HEADER).lower()
self.__target_proxy = self._get_header(self.PROXY_HEADER)
return self.__target_proxy
@property
@@ -389,7 +396,7 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler):
@property
def target_spa(self) -> str:
if self.__target_spa is None:
self.__target_spa = self._get_header(self.SPA_HEADER).lower()
self.__target_spa = self._get_header(self.SPA_HEADER)
return self.__target_spa
@property
@@ -402,6 +409,8 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler):
self._pre_log_request()
if self._proxy_or_redirect():
return None
if self._is_stapler_challenge(self.path):
return self.send_status_only(http.HTTPStatus.NO_CONTENT)
if self.path == "/" and self.host == self.default_host:
return self.send_basic_body(self.server_signature())
super().do_HEAD()
@@ -414,6 +423,8 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler):
self._pre_log_request()
if self._proxy_or_redirect():
return None
if self._is_stapler_challenge(self.path):
return self.send_status_only(http.HTTPStatus.NO_CONTENT)
if self.path == "/" and self.host == self.default_host:
return self.send_basic_body(self.server_signature())
super().do_GET()
@@ -493,16 +504,20 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler):
"Archive too large",
)
return False
existing = self.registry.remove(path)
try:
file_bytes = io.BytesIO(self.rfile.read(self.in_size))
self.data_dir.extract_tar_bytes(path, file_bytes)
except tarfile.TarError:
self.send_error(http.HTTPStatus.BAD_REQUEST, "Invalid tar archive")
if existing:
self.registry.add(path) # restore path on error
return False
self.registry.add(path)
self.token_manager.set_token(path, self.token)
if self.has_target_spa:
self.registry.set_spa(path, self.target_spa)
self.registry.mark_ready(path)
return True
def _update_redirect(self, path: str) -> bool:
@@ -514,6 +529,7 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler):
return False
self.registry.set_redirect(path, self.target_redirect)
self.token_manager.set_token(path, self.token)
self.registry.mark_ready(path)
return True
def _update_proxy(self, path: str) -> bool:
@@ -525,6 +541,7 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler):
return False
self.registry.set_proxy(path, self.target_proxy)
self.token_manager.set_token(path, self.token)
self.registry.mark_ready(path)
return True
def _update_remove(self, path: str) -> bool:
@@ -536,7 +553,11 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler):
return True
def _proxy_or_redirect(self) -> bool:
if self.has_token or self.path.startswith(self.CERTBOT_CHALLENGE_PATH):
if (
self.has_token
or self._is_certbot_challenge(self.path)
or self._is_stapler_challenge(self.path)
):
return False
if (page := self.__get_page(self.path)) is None:
return False
@@ -556,9 +577,17 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler):
"""Disable default directory listing."""
self.send_error(http.HTTPStatus.NOT_FOUND, "File not found")
def _is_certbot_challenge(self, path: str) -> bool:
return path.startswith(self.CERTBOT_CHALLENGE_PATH) and pathlib.Path(
self.certbot_www + path
).resolve().is_relative_to(self.certbot_www)
def _is_stapler_challenge(self, path: str) -> bool:
return path.startswith(self.STAPLER_CHALLENGE_PATH)
@typing.override
def translate_path(self, path: str) -> str:
if path.startswith(self.CERTBOT_CHALLENGE_PATH):
if self._is_certbot_challenge(path):
return self.certbot_www + path
page = self.__get_page(path)
if page is None:
@@ -582,7 +611,20 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler):
path = f"/{page.path}/{page.spa}"
return super().translate_path(path)
def __check_rate_limit(self) -> bool:
now = datetime.datetime.now(tz=datetime.UTC)
address = self.address_string()
last_atempt = self.rate_limits.get(address, None)
self.rate_limits[address] = now
return last_atempt is None or now - last_atempt > self.RATE_LIMIT
def __clear_rate_limit(self) -> None:
del self.rate_limits[self.address_string()]
def __check_update_request(self) -> str | None:
if not self.__check_rate_limit():
self.send_error(http.HTTPStatus.TOO_MANY_REQUESTS, "Rate limit exceeded")
return None
if not self._has_header(self.TOKEN_HEADER):
self.send_error(
http.HTTPStatus.BAD_REQUEST, f"No {self.TOKEN_HEADER} header in request"
@@ -591,6 +633,7 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler):
if not self.token_manager.is_valid(self.token):
self.send_error(http.HTTPStatus.UNAUTHORIZED, "Invalid token")
return None
self.__clear_rate_limit()
if (sub_path := self.__get_path(self.path, self.UPDATE_PATH_REGEX)) is None:
self.send_error(http.HTTPStatus.BAD_REQUEST, "Invalid path")
return None
@@ -650,13 +693,37 @@ class UpgradeHandler(RequestHandler):
def do_HEAD(self) -> None:
with self.handle_errors():
self._pre_log_request()
if self._is_stapler_challenge(self.path):
self.send_status_only(http.HTTPStatus.NO_CONTENT)
else:
self.send_redirect(f"https://{self.host}{self.path}")
self.close_connection = True
def do_GET(self) -> None:
with self.handle_errors():
if self.path.startswith(self.CERTBOT_CHALLENGE_PATH):
if self._is_certbot_challenge(self.path):
super().do_GET()
self.close_connection = True
else:
self.do_HEAD()
def do_POST(self) -> None:
self.do_HEAD()
def do_PUT(self) -> None:
self.do_HEAD()
def do_DELETE(self) -> None:
self.do_HEAD()
def do_OPTIONS(self) -> None:
self.do_HEAD()
def do_PATCH(self) -> None:
self.do_HEAD()
def do_CONNECT(self) -> None:
self.do_HEAD()
def do_TRACE(self) -> None:
self.do_HEAD()
+1
View File
@@ -11,6 +11,7 @@ class Page:
redirect: str | None = None
proxy: str | None = None
spa: str | None = None
ready: bool = True
def __repr__(self) -> str:
out = f"/{self.path}/"
+1 -1
View File
@@ -40,7 +40,7 @@ def __get_env_str(var: str, default: str) -> str:
def __get_env_int(var: str, default: int) -> int:
value = __get_env_str(var, str(default))
if value.isnumeric():
if value.isdecimal():
return int(value)
return default
+33 -8
View File
@@ -10,6 +10,7 @@ if typing.TYPE_CHECKING:
class Registry:
__slots__ = [
"_host_pages",
"data_dir",
"logger",
"pages",
@@ -26,14 +27,24 @@ class Registry:
self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)
self.pages: dict[str, Page] = {}
self.data_dir = DataDir(params.data_dir)
self._host_pages: dict[str, Page] | None = None
@property
def host_pages(self) -> dict[str, Page]:
if self._host_pages is None:
self._host_pages = {
p.host: p for p in self.pages.values() if p.host is not None
}
return self._host_pages
def load_pages(self) -> None:
self.pages = {}
for path in self.data_dir.list_paths():
self.add(path)
self.mark_ready(path)
def get_hosts(self) -> list[str]:
return [p.host for p in self.pages.values() if p.host is not None]
return list(self.host_pages.keys())
def add(self, path: str) -> None:
host = self.data_dir.get_file(path, self.HOST_FILE)
@@ -47,7 +58,9 @@ class Registry:
redirect=self.data_dir.get_file(path, self.REDIRECT_FILE),
proxy=self.data_dir.get_file(path, self.PROXY_FILE),
spa=self.data_dir.get_file(path, self.SPA_FILE),
ready=False,
)
self._host_pages = None
self.logger.info("Updated %s", self.pages[path])
def set_host(self, path: str, host: str) -> None:
@@ -57,6 +70,7 @@ class Registry:
self.data_dir.set_file(path, self.HOST_FILE, host)
self.data_dir.remove_file(path, self.HOST_ONLY_FILE)
self.pages[path].host = host
self._host_pages = None
self.logger.debug("Updated %s", self.pages[path])
def set_host_only(self, path: str, host: str) -> None:
@@ -67,6 +81,7 @@ class Registry:
self.data_dir.remove_file(path, self.HOST_FILE)
self.pages[path].host = host
self.pages[path].host_only = True
self._host_pages = None
self.logger.debug("Updated %s", self.pages[path])
def set_token_hash(self, path: str, token_hash: str) -> None:
@@ -77,19 +92,23 @@ class Registry:
def set_redirect(self, path: str, redirect: str) -> None:
if path not in self.pages or self.pages[path].redirect != redirect:
if path in self.pages:
self.pages[path].ready = False
self.data_dir.empty(path)
self.data_dir.set_file(path, self.REDIRECT_FILE, redirect)
if path not in self.pages:
self.pages[path] = Page(path)
self.pages[path] = Page(path, ready=False)
self.pages[path].redirect = redirect
self.logger.debug("Updated %s", self.pages[path])
def set_proxy(self, path: str, proxy: str) -> None:
if path not in self.pages or self.pages[path].proxy != proxy:
if path in self.pages:
self.pages[path].ready = False
self.data_dir.empty(path)
self.data_dir.set_file(path, self.PROXY_FILE, proxy)
if path not in self.pages:
self.pages[path] = Page(path)
self.pages[path] = Page(path, ready=False)
self.pages[path].proxy = proxy
self.logger.debug("Updated %s", self.pages[path])
@@ -99,19 +118,25 @@ class Registry:
self.pages[path].spa = spa
self.logger.debug("Updated %s", self.pages[path])
def remove(self, path: str) -> None:
def mark_ready(self, path: str) -> None:
if path in self.pages:
self.pages[path].ready = True
def remove(self, path: str) -> bool:
if path in self.pages:
page = self.pages[path]
del self.pages[path]
self._host_pages = None
self.logger.info("Removed %s", page)
return True
return False
def get_from_path(self, path: str) -> Page | None:
if path in self.pages:
if path in self.pages and self.pages[path].ready:
return self.pages[path]
return None
def get_from_host(self, host: str) -> Page | None:
for p in self.pages.values():
if p.host == host:
return p
if host in self.host_pages and self.host_pages[host].ready:
return self.host_pages[host]
return None
+12 -7
View File
@@ -15,6 +15,7 @@ class TokenManager:
__slots__ = [
"last_file_change",
"logger",
"pbkdf2_iterations",
"registry",
"token_hashes",
"token_salt",
@@ -23,11 +24,14 @@ class TokenManager:
FILE = ".tokens"
def __init__(self, params: Parameters, registry: Registry) -> None:
def __init__(
self, params: Parameters, registry: Registry, pbkdf2_iterations: int = 500_000
) -> None:
self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)
self.token_salt: str = params.token_salt
self.token_salt: bytes = params.token_salt.encode()
self.tokens_file: pathlib.Path = pathlib.Path(params.data_dir) / self.FILE
self.registry: Registry = registry
self.pbkdf2_iterations: int = pbkdf2_iterations
self.token_hashes: list[str] = []
self.last_file_change: int | float = 0
@@ -63,17 +67,18 @@ class TokenManager:
def detect_file_change(self) -> bool:
if (
self.tokens_file.exists()
and self.tokens_file.stat().st_mtime != self.last_file_change
and (file_change := self.tokens_file.stat().st_mtime)
!= self.last_file_change
):
self.logger.debug("Detected change: %s", self.tokens_file)
self.last_file_change = self.tokens_file.stat().st_mtime
self.last_file_change = file_change
return True
return False
def __hash_token(self, token: str) -> str:
return hashlib.sha512(
(self.token_salt + token).encode(), usedforsecurity=True
).hexdigest()
return hashlib.pbkdf2_hmac(
"sha256", token.encode(), self.token_salt, self.pbkdf2_iterations
).hex()
def __load_hashes(self) -> list[str]:
if self.tokens_file.is_file():
+68 -4
View File
@@ -1,6 +1,7 @@
import abc
import collections
import contextlib
import datetime
import http
import http.server
import io
@@ -204,6 +205,17 @@ class TestRequestHandler(BaseHandlerTestCase):
):
handler.do_HEAD()
def test_do_head_stapler(self) -> None:
handler = self._get_handler("/.well-known/stapler/something")
with (
self.expects_status_only(
handler,
http.HTTPStatus.NO_CONTENT,
),
self.seal_mocks(),
):
handler.do_HEAD()
def test_do_head_forward(self) -> None:
handler = self._get_handler("/file")
with (
@@ -222,6 +234,17 @@ class TestRequestHandler(BaseHandlerTestCase):
):
handler.do_GET()
def test_do_get_stapler(self) -> None:
handler = self._get_handler("/.well-known/stapler/something")
with (
self.expects_status_only(
handler,
http.HTTPStatus.NO_CONTENT,
),
self.seal_mocks(),
):
handler.do_GET()
def test_do_get_forward_on_other_path(self) -> None:
handler = self._get_handler("/file")
with (
@@ -253,6 +276,7 @@ class TestRequestHandler(BaseHandlerTestCase):
self.seal_mocks(),
):
handler.do_PUT()
assert "127.0.0.1" in handler.rate_limits
def test_do_post_is_do_put(self) -> None:
handler = self._get_handler("/path")
@@ -284,6 +308,18 @@ class TestRequestHandler(BaseHandlerTestCase):
self.seal_mocks(),
):
handler.do_PUT()
assert "127.0.0.1" in handler.rate_limits
def test_do_put_rate_limit(self) -> None:
handler = self._get_handler("/path", {"X-Token": "secret"})
handler.rate_limits["127.0.0.1"] = datetime.datetime.now(tz=datetime.UTC)
with (
self.expects_error(
handler, http.HTTPStatus.TOO_MANY_REQUESTS, "Rate limit exceeded"
),
self.seal_mocks(),
):
handler.do_PUT()
def test_do_put_invalid_path(self) -> None:
handler = self._get_handler("/pa.th", {"X-Token": "secret"})
@@ -436,6 +472,8 @@ class TestRequestHandler(BaseHandlerTestCase):
["secret", "path"],
True, # noqa: FBT003
),
self.mock_call(self.registry.remove, ["path"], True), # noqa: FBT003
self.mock_call(self.registry.add, ["path"]),
self.expects_error(
handler, http.HTTPStatus.BAD_REQUEST, "Invalid tar archive"
),
@@ -457,6 +495,7 @@ class TestRequestHandler(BaseHandlerTestCase):
["secret", "path"],
True, # noqa: FBT003
),
self.mock_call(self.registry.remove, ["path"], False), # noqa: FBT003
self.expects_error(handler, http.HTTPStatus.INTERNAL_SERVER_ERROR, ""),
self.seal_mocks(),
):
@@ -476,8 +515,10 @@ class TestRequestHandler(BaseHandlerTestCase):
True, # noqa: FBT003
),
self.mock_call_unchecked(self.data_dir.extract_tar_bytes),
self.mock_call(self.registry.remove, ["path"], False), # noqa: FBT003
self.mock_call(self.registry.add, ["path"]),
self.mock_call(self.token_manager.set_token, ["path", "secret"]),
self.mock_call(self.registry.mark_ready, ["path"]),
self.mock_call(self.registry.get_from_path, ["path"]),
self.expects_status_only(
handler, http.HTTPStatus.CREATED, "Resource updated"
@@ -501,9 +542,11 @@ class TestRequestHandler(BaseHandlerTestCase):
),
self.mock_call(self.registry.get_from_host, ["example.com"], Page("path")),
self.mock_call_unchecked(self.data_dir.extract_tar_bytes),
self.mock_call(self.registry.remove, ["path"], False), # noqa: FBT003
self.mock_call(self.registry.add, ["path"]),
self.mock_call(self.token_manager.set_token, ["path", "secret"]),
self.mock_call(self.registry.set_host, ["path", "example.com"]),
self.mock_call(self.registry.mark_ready, ["path"]),
self.mock_call(self.registry.get_from_path, ["path"]),
self.expects_status_only(
handler, http.HTTPStatus.CREATED, "Resource updated"
@@ -525,9 +568,11 @@ class TestRequestHandler(BaseHandlerTestCase):
True, # noqa: FBT003
),
self.mock_call_unchecked(self.data_dir.extract_tar_bytes),
self.mock_call(self.registry.remove, ["path"], False), # noqa: FBT003
self.mock_call(self.registry.add, ["path"]),
self.mock_call(self.token_manager.set_token, ["path", "secret"]),
self.mock_call(self.registry.set_spa, ["path", "index.html"]),
self.mock_call(self.registry.mark_ready, ["path"]),
self.mock_call(self.registry.get_from_path, ["path"]),
self.expects_status_only(
handler, http.HTTPStatus.CREATED, "Resource updated"
@@ -578,6 +623,7 @@ class TestRequestHandler(BaseHandlerTestCase):
),
self.mock_call(self.registry.set_redirect, ["path", "https://example.com"]),
self.mock_call(self.token_manager.set_token, ["path", "secret"]),
self.mock_call(self.registry.mark_ready, ["path"]),
self.mock_call(self.registry.get_from_path, ["path"]),
self.expects_status_only(
handler, http.HTTPStatus.CREATED, "Resource updated"
@@ -606,6 +652,7 @@ class TestRequestHandler(BaseHandlerTestCase):
self.mock_call(self.registry.set_redirect, ["path", "https://example.com"]),
self.mock_call(self.token_manager.set_token, ["path", "secret"]),
self.mock_call(self.registry.set_host, ["path", "example.com"]),
self.mock_call(self.registry.mark_ready, ["path"]),
self.mock_call(self.registry.get_from_path, ["path"]),
self.expects_status_only(
handler, http.HTTPStatus.CREATED, "Resource updated"
@@ -634,6 +681,7 @@ class TestRequestHandler(BaseHandlerTestCase):
self.mock_call(self.registry.set_redirect, ["path", "https://example.com"]),
self.mock_call(self.token_manager.set_token, ["path", "secret"]),
self.mock_call(self.registry.set_host_only, ["path", "example.com"]),
self.mock_call(self.registry.mark_ready, ["path"]),
self.mock_call(self.registry.get_from_path, ["path"]),
self.expects_status_only(
handler, http.HTTPStatus.CREATED, "Resource updated"
@@ -684,6 +732,7 @@ class TestRequestHandler(BaseHandlerTestCase):
),
self.mock_call(self.registry.set_proxy, ["path", "https://example.com"]),
self.mock_call(self.token_manager.set_token, ["path", "secret"]),
self.mock_call(self.registry.mark_ready, ["path"]),
self.mock_call(self.registry.get_from_path, ["path"]),
self.expects_status_only(
handler, http.HTTPStatus.CREATED, "Resource updated"
@@ -712,6 +761,7 @@ class TestRequestHandler(BaseHandlerTestCase):
self.mock_call(self.registry.set_proxy, ["path", "https://example.com"]),
self.mock_call(self.token_manager.set_token, ["path", "secret"]),
self.mock_call(self.registry.set_host, ["path", "example.com"]),
self.mock_call(self.registry.mark_ready, ["path"]),
self.mock_call(self.registry.get_from_path, ["path"]),
self.expects_status_only(
handler, http.HTTPStatus.CREATED, "Resource updated"
@@ -756,6 +806,7 @@ class TestRequestHandler(BaseHandlerTestCase):
self.seal_mocks(),
):
handler.do_DELETE()
assert "127.0.0.1" in handler.rate_limits
def test_do_delete_invalid_token(self) -> None:
handler = self._get_handler("/path", {"X-Token": "secret"})
@@ -765,6 +816,7 @@ class TestRequestHandler(BaseHandlerTestCase):
self.seal_mocks(),
):
handler.do_DELETE()
assert "127.0.0.1" in handler.rate_limits
def test_do_delete_invalid_path(self) -> None:
handler = self._get_handler("/pa.th", {"X-Token": "secret"})
@@ -1322,21 +1374,22 @@ class TestUpgradeHandler(BaseHandlerTestCase):
handler.data_dir = self.data_dir
return handler
def test_do_get(self) -> None:
def test_do_upgrade(self) -> None:
handler = self._get_handler("/file")
for method in [method.value for method in http.HTTPMethod]:
with (
self.subTest(None, method=method),
self.expects_status_only(
handler,
http.HTTPStatus.MOVED_PERMANENTLY,
headers={"Location": "https://localhost/file"},
),
self.patch(
"http.server.SimpleHTTPRequestHandler.do_GET",
count=0,
f"http.server.SimpleHTTPRequestHandler.do_{method}", count=0
),
self.seal_mocks(),
):
handler.do_GET()
getattr(handler, f"do_{method}")()
def test_do_get_certbot(self) -> None:
handler = self._get_handler("/.well-known/acme-challenge/abcde")
@@ -1359,3 +1412,14 @@ class TestUpgradeHandler(BaseHandlerTestCase):
self.seal_mocks(),
):
handler.do_HEAD()
def test_do_head_stapler(self) -> None:
handler = self._get_handler("/.well-known/stapler/something")
with (
self.expects_status_only(
handler,
http.HTTPStatus.NO_CONTENT,
),
self.seal_mocks(),
):
handler.do_HEAD()
+36 -1
View File
@@ -185,6 +185,7 @@ class TestRegistry(BaseTestCase):
self.assertEqual(
self.registry.pages["test_1"].redirect, "https://new-example.com"
)
assert not self.registry.pages["test_1"].ready
def test_set_redirect_no_change(self) -> None:
self.registry.pages["test_1"] = Page(
@@ -214,6 +215,7 @@ class TestRegistry(BaseTestCase):
self.assertEqual(
self.registry.pages["test_1"].redirect, "https://new-example.com"
)
assert not self.registry.pages["test_1"].ready
def test_set_proxy(self) -> None:
self.registry.pages["test_1"] = Page(
@@ -233,6 +235,7 @@ class TestRegistry(BaseTestCase):
):
self.registry.set_proxy("test_1", "https://new-example.com")
self.assertEqual(self.registry.pages["test_1"].proxy, "https://new-example.com")
assert not self.registry.pages["test_1"].ready
def test_set_proxy_no_change(self) -> None:
self.registry.pages["test_1"] = Page(
@@ -260,6 +263,7 @@ class TestRegistry(BaseTestCase):
self.registry.set_proxy("test_1", "https://new-example.com")
self.assertIn("test_1", self.registry.pages)
self.assertEqual(self.registry.pages["test_1"].proxy, "https://new-example.com")
assert not self.registry.pages["test_1"].ready
def test_set_spa(self) -> None:
self.registry.pages["test_1"] = Page(
@@ -298,9 +302,27 @@ class TestRegistry(BaseTestCase):
"test_1",
)
self.seal_mocks()
self.registry.remove("test_1")
assert self.registry.remove("test_1")
self.assertNotIn("test_1", self.registry.pages)
def test_remove_not_found(self) -> None:
self.seal_mocks()
assert not self.registry.remove("test_1")
def test_mark_ready(self) -> None:
self.registry.pages["test_1"] = Page("test_1", ready=False)
with (
self.seal_mocks(),
):
self.registry.mark_ready("test_1")
assert self.registry.pages["test_1"].ready
def test_mark_ready_not_found(self) -> None:
with (
self.seal_mocks(),
):
self.registry.mark_ready("test_1")
def test_get_from_path(self) -> None:
self.registry.pages["test_1"] = (
target := Page(
@@ -313,6 +335,14 @@ class TestRegistry(BaseTestCase):
self.seal_mocks()
self.assertEqual(self.registry.get_from_path("test_1"), target)
def test_get_from_path_not_ready(self) -> None:
self.registry.pages["test_1"] = Page(
"test_1",
ready=False,
)
self.seal_mocks()
self.assertIsNone(self.registry.get_from_path("test_1"))
def test_get_from_path_not_found(self) -> None:
self.registry.pages["test_1"] = Page(
"test_1",
@@ -329,6 +359,11 @@ class TestRegistry(BaseTestCase):
self.seal_mocks()
self.assertEqual(self.registry.get_from_host("host_1"), target)
def test_get_from_host_not_ready(self) -> None:
self.registry.pages["test_1"] = Page("test_1", host="host_1", ready=False)
self.seal_mocks()
self.assertIsNone(self.registry.get_from_host("host_1"))
def test_get_from_host_not_found(self) -> None:
self.registry.pages["test_1"] = Page("test_1", host="host_1")
self.registry.pages["test_2"] = Page("test_2", host="host_2")
+5 -4
View File
@@ -11,9 +11,9 @@ from . import BaseTestCase
class TestTokenManager(BaseTestCase):
EMPTY_SALT_HASH = "a04ca803c9fd73c21b721ece14b8b30cd3d9ca1bff752904a46982b881e152d0cdaa463a32e6bce71408de611953bc304ca8000d40d4b06b3f2a70769f69fecc"
SALT_HASH = "a5f2d8785eb4f064eae60f94e6025f93be32c2c93d2bbd73a982ee5c7ebcc484536487a4f60cfdfcb9ba72da7cebe0ce11afa91f191272e51d8c14be6874824b"
SECRET_HASH = "9901847ff8c76bd5fb473b7bd2e4f4ddd110332a52a888fd69deb276613885ddf382e5cf1210ed0decdb8010ae3994331a9e0639c3ca7e9e8b110dd50978ce76" # noqa: S105
EMPTY_SALT_HASH = "5f88941ac5e26c430d97411ac1103af7a35c753f14aec088fbf34801c099135a"
SALT_HASH = "d71b1f52657c77d00b2a8c59b8d12d13c1c1bb2bcfbb85d2a9b804c36ad57a70"
SECRET_HASH = "38df428b309308e48c3687e7f90bda0e9cf253568c21ec754a0e076ab4ab6423" # noqa: S105
@typing.override
def setUp(self) -> None:
@@ -21,6 +21,7 @@ class TestTokenManager(BaseTestCase):
self.token_manager = TokenManager(
Parameters(data_dir=self.get_tmp_dir(), token_salt="salt"), # noqa: S106
self.registry,
pbkdf2_iterations=1,
)
self.token_manager.logger = unittest.mock.Mock(logging.Logger)
self.tmp_tokens_file = self.tmp_path / TokenManager.FILE
@@ -34,7 +35,7 @@ class TestTokenManager(BaseTestCase):
self.assertListEqual(self.token_manager.token_hashes, [])
def test_init_weak_salt(self) -> None:
self.token_manager.token_salt = ""
self.token_manager.token_salt = b""
self.seal_mocks()
self.token_manager.init()
self.assert_file_content(
Generated
+56 -56
View File
@@ -63,50 +63,50 @@ wheels = [
[[package]]
name = "coverage"
version = "7.14.0"
version = "7.14.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" }
sdist = { url = "https://files.pythonhosted.org/packages/54/fd/0ab2772530e946e1be1abd0bc09e647ec9b02e88f0867857601fefca8953/coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be", size = 920132, upload-time = "2026-05-26T20:41:36.783Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1c/18/b9a6586d73992807c26f9a5f274131be3d76b56b18a82b9392e2a25d2e45/coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d", size = 220036, upload-time = "2026-05-10T18:01:33.057Z" },
{ url = "https://files.pythonhosted.org/packages/f3/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63", size = 220368, upload-time = "2026-05-10T18:01:34.705Z" },
{ url = "https://files.pythonhosted.org/packages/69/aa/c12e52a5ba148d9995229d557e3be6e554fe469addc0e9241b2f0956d8ea/coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212", size = 251417, upload-time = "2026-05-10T18:01:36.949Z" },
{ url = "https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3", size = 253924, upload-time = "2026-05-10T18:01:38.985Z" },
{ url = "https://files.pythonhosted.org/packages/33/c4/59c3de0bd1b538824173fd518fed51c1ce740ca5ed68e74545983f4053a9/coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97", size = 255269, upload-time = "2026-05-10T18:01:40.957Z" },
{ url = "https://files.pythonhosted.org/packages/7b/a9/36dfa153a62040296f6e7febfdb20a5720622f6ef5a81a41e8237b9a5344/coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8", size = 257583, upload-time = "2026-05-10T18:01:42.607Z" },
{ url = "https://files.pythonhosted.org/packages/26/7b/cc2c048d4114d9ab1c2409e9ee365e5ae10736df6dffcfc9444effa6c708/coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb", size = 251434, upload-time = "2026-05-10T18:01:44.537Z" },
{ url = "https://files.pythonhosted.org/packages/ee/df/6770eaa576e604575e9a78055313250faef5faa84bd6f71a39fece519c43/coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe", size = 253280, upload-time = "2026-05-10T18:01:46.175Z" },
{ url = "https://files.pythonhosted.org/packages/ad/9e/1c0264514a3f98259a6d64765a397b2c8373e3ba59ee722a4802d3ec0c61/coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa", size = 251241, upload-time = "2026-05-10T18:01:48.732Z" },
{ url = "https://files.pythonhosted.org/packages/64/16/4efdf3e3c4079cdbf0ece56a2fea872df9e8a3e15a13a0af4400e1075944/coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5", size = 255516, upload-time = "2026-05-10T18:01:50.819Z" },
{ url = "https://files.pythonhosted.org/packages/93/69/b1de96346603881b3d1bc8d6447c83200e1c9700ffbaff926ba01ff5724c/coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c", size = 251059, upload-time = "2026-05-10T18:01:52.773Z" },
{ url = "https://files.pythonhosted.org/packages/a4/66/2881853e0363a5e0a724d1103e53650795367471b6afb234f8b49e713bc6/coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca", size = 252716, upload-time = "2026-05-10T18:01:54.506Z" },
{ url = "https://files.pythonhosted.org/packages/55/5c/0d3305d002c41dcde873dbe456491e663dc55152ca526b630b5c47efd62f/coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828", size = 222788, upload-time = "2026-05-10T18:01:56.487Z" },
{ url = "https://files.pythonhosted.org/packages/f9/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d", size = 223600, upload-time = "2026-05-10T18:01:58.497Z" },
{ url = "https://files.pythonhosted.org/packages/00/70/a18c408e674bc26281cadaedc7351f929bd2094e191e4b15271c30b084cc/coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9", size = 222168, upload-time = "2026-05-10T18:02:00.411Z" },
{ url = "https://files.pythonhosted.org/packages/3d/89/2681f071d238b62aff8dfc2ab44fc24cfdb38d1c01f391a80522ff5d3a16/coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1", size = 220766, upload-time = "2026-05-10T18:02:02.313Z" },
{ url = "https://files.pythonhosted.org/packages/bd/c7/c987babafd9207ffa1995e1ef1f9b26762cf4963aa768a66b6f0501e4616/coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c", size = 221035, upload-time = "2026-05-10T18:02:04.017Z" },
{ url = "https://files.pythonhosted.org/packages/5a/e9/d6a5ac3b333088143d6fc877d398a9a674dc03124a2f776e131f03864823/coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84", size = 262405, upload-time = "2026-05-10T18:02:05.915Z" },
{ url = "https://files.pythonhosted.org/packages/38/b1/e70838d29a7c08e22d44398a46db90815bbcbf28de06992bd9210d1a8d8e/coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436", size = 264530, upload-time = "2026-05-10T18:02:07.582Z" },
{ url = "https://files.pythonhosted.org/packages/6b/73/5c31ef97763288d03d9995152b96d5475b527c63d91c84b01caea894b83a/coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a", size = 266932, upload-time = "2026-05-10T18:02:09.401Z" },
{ url = "https://files.pythonhosted.org/packages/e1/76/dd56d80f29c5f05b4d76f7e7c6d47cafacae017189c75c5759d24f9ff0cc/coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f", size = 268062, upload-time = "2026-05-10T18:02:11.399Z" },
{ url = "https://files.pythonhosted.org/packages/6e/c7/27ba85cd5b95614f159ff93ebff1901584a8d192e2e5e24c4943a7453f59/coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb", size = 261504, upload-time = "2026-05-10T18:02:13.257Z" },
{ url = "https://files.pythonhosted.org/packages/13/2e/e8149f60ab5d5684c6eee881bdf34b127115cddbb958b196768dd9d63473/coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490", size = 264398, upload-time = "2026-05-10T18:02:15.063Z" },
{ url = "https://files.pythonhosted.org/packages/d9/7f/1261b025285323225f4b4abffa5a643649dfd67e25ddca7ebcbdea3b7cb3/coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9", size = 262000, upload-time = "2026-05-10T18:02:16.756Z" },
{ url = "https://files.pythonhosted.org/packages/d3/dc/829c54f60b9d08389439c00f813c752781c496fc5788c78d8006db4b4f2b/coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020", size = 265732, upload-time = "2026-05-10T18:02:18.817Z" },
{ url = "https://files.pythonhosted.org/packages/ed/b0/70bd1419941652fa062689cba9c3eeafb8f5e6fbb890bce41c3bdda5dbd6/coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6", size = 260847, upload-time = "2026-05-10T18:02:20.528Z" },
{ url = "https://files.pythonhosted.org/packages/f2/73/be40b2390656c654d35ea0015ea7ba3d945769cf80790ad5e0bb2d56d2ba/coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db", size = 263166, upload-time = "2026-05-10T18:02:22.337Z" },
{ url = "https://files.pythonhosted.org/packages/29/55/4a643f712fcf7cf2881f8ec1e0ccb7b164aff3108f69b51801246c8799f2/coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2", size = 223573, upload-time = "2026-05-10T18:02:24.11Z" },
{ url = "https://files.pythonhosted.org/packages/27/96/3acae5da0953be042c0b4dea6d6789d2f080701c77b88e44d5bd41b9219b/coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644", size = 224680, upload-time = "2026-05-10T18:02:25.896Z" },
{ url = "https://files.pythonhosted.org/packages/93/3d/6ab5d2dd8325d838737c6f8d83d62eb6230e0d70b87b51b57bbfd08fa767/coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b", size = 222703, upload-time = "2026-05-10T18:02:27.822Z" },
{ url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" },
{ url = "https://files.pythonhosted.org/packages/d6/34/fc2f101b151af3799a101f0550b0454aa008afdc0add677394ec4aa8ea10/coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee", size = 220091, upload-time = "2026-05-26T20:40:27.249Z" },
{ url = "https://files.pythonhosted.org/packages/3d/a7/1ebae2ab5b961b5c79bb09fe7b3ac99edb190d8be4a8c510b2cf66f46468/coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500", size = 220421, upload-time = "2026-05-26T20:40:30.084Z" },
{ url = "https://files.pythonhosted.org/packages/5e/90/92aca9cf0acc95123c96cd1eb1f08917897a7f5dee01e15738922971ec31/coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906", size = 251466, upload-time = "2026-05-26T20:40:32.542Z" },
{ url = "https://files.pythonhosted.org/packages/26/2b/78048cbe3b999f6cbf9cc0d90abba6a88a3e0863a8c1c6cbc762f3f8802f/coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42", size = 253973, upload-time = "2026-05-26T20:40:34.473Z" },
{ url = "https://files.pythonhosted.org/packages/8e/21/c2e33b29d1cfde484a19d437afc343c6cd30b08d78cbbf9f5aff14e57b2b/coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8", size = 255318, upload-time = "2026-05-26T20:40:38.154Z" },
{ url = "https://files.pythonhosted.org/packages/8e/ee/aad2f108d63b769121005302f16bf66db8625c88ceaba466942e09a2607e/coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851", size = 257633, upload-time = "2026-05-26T20:40:40.164Z" },
{ url = "https://files.pythonhosted.org/packages/c2/f8/11a2c29b4fd76d9849f81d0bb812ec0017a9396df3217214e38934a8c837/coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034", size = 251488, upload-time = "2026-05-26T20:40:42.631Z" },
{ url = "https://files.pythonhosted.org/packages/c9/b8/9a5820de4b8ac2b71d85e3b5fb49108d7469c665f0e2ad0dd7569023e305/coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c", size = 253329, upload-time = "2026-05-26T20:40:45.208Z" },
{ url = "https://files.pythonhosted.org/packages/6b/ff/f33e4823667e27548e8fd8df44217515303f9808d0ff29817db56f87d990/coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36", size = 251291, upload-time = "2026-05-26T20:40:47.502Z" },
{ url = "https://files.pythonhosted.org/packages/68/9b/489db0ebb209054766b90a9014a45f6d26eb724c02ec21311c3733b5a644/coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5", size = 255564, upload-time = "2026-05-26T20:40:49.372Z" },
{ url = "https://files.pythonhosted.org/packages/27/b5/16bc2d4c2409b23c7737edb68c83bc89e345f378050549fe1d75ac7d34d5/coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4", size = 251107, upload-time = "2026-05-26T20:40:51.677Z" },
{ url = "https://files.pythonhosted.org/packages/7d/0c/2629997469a00cd069d588a41c9dc887610f2775ae89d250c4791e65272a/coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d", size = 252764, upload-time = "2026-05-26T20:40:54.267Z" },
{ url = "https://files.pythonhosted.org/packages/d2/ee/f78d63c8f079e0d7211c7e2401fa17e311514534ba61bae03e4b287ce4ab/coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee", size = 222837, upload-time = "2026-05-26T20:40:56.496Z" },
{ url = "https://files.pythonhosted.org/packages/dc/b9/be539854f93a70dfbeec69117f33ec70dc42ff0b65b5b07ab8d40d04228e/coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7", size = 223650, upload-time = "2026-05-26T20:40:58.351Z" },
{ url = "https://files.pythonhosted.org/packages/fe/9e/24e2842fef40f35ac82ba3a7719c8023d011bf3bf652d0675316a9d088a1/coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343", size = 222218, upload-time = "2026-05-26T20:41:00.321Z" },
{ url = "https://files.pythonhosted.org/packages/0a/1d/ac0a9df5fe31c1e8bdd658074905fc12844a05c1a7e3fdb8417e97c31e23/coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1", size = 220822, upload-time = "2026-05-26T20:41:02.281Z" },
{ url = "https://files.pythonhosted.org/packages/32/cf/f964fd9aff20323f9f1a726c97135f8a76bcd87b92dad141a456a43f3c64/coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b", size = 221084, upload-time = "2026-05-26T20:41:04.593Z" },
{ url = "https://files.pythonhosted.org/packages/d8/5e/7e5ef2aba844de2b80d678619fcf0841b42e3f37f16411226f3fe4c1016f/coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474", size = 262454, upload-time = "2026-05-26T20:41:06.641Z" },
{ url = "https://files.pythonhosted.org/packages/64/62/75809bded87015cc4935524218a2a8ed8dd1a8498bfed30a2f4f7a4b4d34/coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86", size = 264578, upload-time = "2026-05-26T20:41:08.556Z" },
{ url = "https://files.pythonhosted.org/packages/f3/42/d33392dc14633525012d2d504fa1a33b05538bf535f5c1d64675e5754b78/coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e", size = 266981, upload-time = "2026-05-26T20:41:10.824Z" },
{ url = "https://files.pythonhosted.org/packages/2a/49/0157c4428c2aca7f1e09d5565930586fd5ae36f1655f08b0daa7cf1fcae1/coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65", size = 268112, upload-time = "2026-05-26T20:41:12.966Z" },
{ url = "https://files.pythonhosted.org/packages/96/26/86b9ce71f4092b1ed325ce1421698081df1286b833400b6836912834d6e0/coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e", size = 261558, upload-time = "2026-05-26T20:41:15Z" },
{ url = "https://files.pythonhosted.org/packages/20/4c/c311210c5472cf5401d8422b0d7812cdd520f24417673afabda6c323faca/coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8", size = 264447, upload-time = "2026-05-26T20:41:17.369Z" },
{ url = "https://files.pythonhosted.org/packages/fb/71/59513f8710ed3e6b0ac0a050a5b7e977bb9c9e880354863b5d00d8809256/coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07", size = 262048, upload-time = "2026-05-26T20:41:19.309Z" },
{ url = "https://files.pythonhosted.org/packages/84/8d/bceed32dc494f5bbf50f775cd2e78ca814953942b5ea28d3c1c3ac316f14/coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de", size = 265781, upload-time = "2026-05-26T20:41:21.559Z" },
{ url = "https://files.pythonhosted.org/packages/e7/c5/9348fe40dbfd4991aaf78df2c6c3098bfb2cc834d1fd362a64b4efef855a/coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890", size = 260896, upload-time = "2026-05-26T20:41:23.428Z" },
{ url = "https://files.pythonhosted.org/packages/ca/92/1ea0f03929da7cf87206b1fa24f4c8e9c158be0455481af29ec0a1f3503f/coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd", size = 263214, upload-time = "2026-05-26T20:41:25.419Z" },
{ url = "https://files.pythonhosted.org/packages/f6/a9/b2493c054c0e01a643266742ab45e15744e60743f9260cd930c7142b1124/coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e", size = 223624, upload-time = "2026-05-26T20:41:27.795Z" },
{ url = "https://files.pythonhosted.org/packages/fc/bd/3e1e6a57fccd2d7c83fcdf338e93ba98eb85c6e877dd34731ac585375490/coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c", size = 224728, upload-time = "2026-05-26T20:41:30.098Z" },
{ url = "https://files.pythonhosted.org/packages/bb/d7/31066cf1d2f0c6c797fce911bcfa01dd35642dc6da992a950256097c5860/coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af", size = 222752, upload-time = "2026-05-26T20:41:32.123Z" },
{ url = "https://files.pythonhosted.org/packages/8a/3c/1a983b9a745d7f83d53f057bcc5bf79ba6a2bbc08266b3f0c7d6fe630c9b/coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2", size = 211815, upload-time = "2026-05-26T20:41:34.078Z" },
]
[[package]]
name = "idna"
version = "3.16"
version = "3.18"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1a/88/bcf9709822fe69d02c2a6a77956c98ce6ea8ca8767a9aadcedc7eb6a2390/idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d", size = 203770, upload-time = "2026-05-22T00:16:18.781Z" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" },
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
]
[[package]]
@@ -187,32 +187,32 @@ wheels = [
[[package]]
name = "ruff"
version = "0.15.14"
version = "0.15.15"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" }
sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" },
{ url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" },
{ url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" },
{ url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" },
{ url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" },
{ url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" },
{ url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" },
{ url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" },
{ url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" },
{ url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" },
{ url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" },
{ url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" },
{ url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" },
{ url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" },
{ url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" },
{ url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" },
{ url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" },
{ url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" },
{ url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" },
{ url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" },
{ url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" },
{ url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" },
{ url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" },
{ url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" },
{ url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" },
{ url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" },
{ url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" },
{ url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" },
{ url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" },
{ url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" },
{ url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" },
{ url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" },
]
[[package]]
name = "stapler"
version = "1.4.1"
version = "1.5.0"
source = { editable = "." }
dependencies = [
{ name = "requests" },