Compare commits
21 Commits
release/1.4.1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 225b13a7e3 | |||
| c186668208 | |||
| 5f95afd0a3 | |||
| 132cbe9b20 | |||
| ada69f773e | |||
| c5f3e53d88 | |||
| 24b29f5716 | |||
| fa0fab0f15 | |||
| cc3bd48ddf | |||
| 48b52f9fea | |||
| 2040b709d3 | |||
| e0c8eb1724 | |||
| d84c5911b0 | |||
| 2bc2593bc9 | |||
| 81b3007efd | |||
| c3131acc88 | |||
| 60b6b0e592 | |||
| 247dd7dda3 | |||
| d2a656a839 | |||
| b234504b49 | |||
| 66f7879c0f |
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "stapler"
|
||||
version = "1.4.1"
|
||||
version = "1.5.1"
|
||||
description = "Static pages as simple as a gzip file"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
|
||||
+20
-3
@@ -5,6 +5,8 @@ import ssl
|
||||
import subprocess
|
||||
import typing
|
||||
|
||||
import requests
|
||||
|
||||
from stapler.strings import valid_host
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
@@ -49,6 +51,18 @@ class CertManager:
|
||||
def exists(self, host: str) -> bool:
|
||||
return self.__exists_certbot(host) or self.__exists_self_signed(host)
|
||||
|
||||
def valid_host(self, host: str) -> bool:
|
||||
try:
|
||||
response = requests.head(
|
||||
url=f"http://{host}/.well-known/stapler",
|
||||
allow_redirects=True,
|
||||
timeout=5,
|
||||
stream=False,
|
||||
)
|
||||
return type(response.status_code) is int and response.status_code < 400
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def init_cert(self, host: str) -> bool:
|
||||
if not self.exists(host):
|
||||
return self.__create_self_signed(host)
|
||||
@@ -106,7 +120,7 @@ class CertManager:
|
||||
"req",
|
||||
"-new",
|
||||
"-newkey",
|
||||
"rsa:4096",
|
||||
"rsa:2048",
|
||||
"-days",
|
||||
str(self.SELF_SIGNED_DAYS),
|
||||
"-nodes",
|
||||
@@ -161,6 +175,7 @@ class CertManager:
|
||||
"certonly",
|
||||
"--non-interactive",
|
||||
"--agree-tos",
|
||||
"--register-unsafely-without-email",
|
||||
"--webroot",
|
||||
"--webroot-path",
|
||||
self.certbot_www,
|
||||
@@ -192,10 +207,12 @@ 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):
|
||||
if not self.exists(host) and (
|
||||
not self.valid_host(host) or not self.create_or_update(host)
|
||||
):
|
||||
return None
|
||||
cert_file = self.get_cert(host)
|
||||
key_file = self.get_key(host)
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -4,6 +4,8 @@ import subprocess
|
||||
import typing
|
||||
import unittest.mock
|
||||
|
||||
import requests
|
||||
|
||||
from stapler.cert_manager import CertManager, CertManagerError
|
||||
from stapler.params import Parameters
|
||||
|
||||
@@ -170,10 +172,24 @@ class TestRegistry(BaseTestCase):
|
||||
self.socket_mock, None, self.context_mock
|
||||
)
|
||||
|
||||
def test_servername_callback_fail(self) -> None:
|
||||
def test_servername_callback_fail_no_valid_host(self) -> None:
|
||||
self._make_self_signed("example.com")
|
||||
with (
|
||||
self.patch("requests.head") as request_mock,
|
||||
self.patch("ssl.create_default_context", count=0),
|
||||
):
|
||||
request_mock.side_effect = Exception()
|
||||
self.cert_manager.servername_callback(
|
||||
self.socket_mock, "example.fr", self.context_mock
|
||||
)
|
||||
|
||||
def test_servername_callback_fail_no_binaries(self) -> None:
|
||||
self._make_self_signed("example.com")
|
||||
response = requests.Response()
|
||||
response.status_code = 200
|
||||
with (
|
||||
self.patch("shutil.which", count=3),
|
||||
self.patch("requests.head", response),
|
||||
self.patch("ssl.create_default_context", count=0),
|
||||
):
|
||||
self.cert_manager.servername_callback(
|
||||
|
||||
+68
-4
@@ -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
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "requests" },
|
||||
|
||||
Reference in New Issue
Block a user