Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7daf6962d8 | ||
|
|
011e3840c6 | ||
|
|
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.2"
|
||||
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)
|
||||
|
||||
+2
-1
@@ -99,7 +99,8 @@ class DataDir:
|
||||
self.logger.debug("Deleted %s", target_path)
|
||||
|
||||
def empty(self, path: str) -> None:
|
||||
self.remove(path)
|
||||
if self.exists(path):
|
||||
self.remove(path)
|
||||
target_path = self.root_path / path
|
||||
target_path.mkdir()
|
||||
self.logger.debug("Created empty %s", target_path)
|
||||
|
||||
+76
-9
@@ -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:
|
||||
@@ -567,7 +596,7 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler):
|
||||
return ""
|
||||
if self.host != self.default_host:
|
||||
if (
|
||||
not (self.root_path / page.path / path).is_file()
|
||||
not (self.root_path / page.path / pathlib.Path(path[1:])).is_file()
|
||||
and path in self.AUTHORIZED_PATHS
|
||||
):
|
||||
return super().translate_path(path)
|
||||
@@ -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()
|
||||
self.send_redirect(f"https://{self.host}{self.path}")
|
||||
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(
|
||||
|
||||
+78
-14
@@ -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")
|
||||
with (
|
||||
self.expects_status_only(
|
||||
handler,
|
||||
http.HTTPStatus.MOVED_PERMANENTLY,
|
||||
headers={"Location": "https://localhost/file"},
|
||||
),
|
||||
self.patch(
|
||||
"http.server.SimpleHTTPRequestHandler.do_GET",
|
||||
count=0,
|
||||
),
|
||||
self.seal_mocks(),
|
||||
):
|
||||
handler.do_GET()
|
||||
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(
|
||||
f"http.server.SimpleHTTPRequestHandler.do_{method}", count=0
|
||||
),
|
||||
self.seal_mocks(),
|
||||
):
|
||||
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(
|
||||
|
||||
@@ -4,11 +4,11 @@ requires-python = ">=3.14"
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.5.20"
|
||||
version = "2026.6.17"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -63,50 +63,50 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.14.0"
|
||||
version = "7.14.3"
|
||||
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/b4/91/0a7c28934e50d8ac9a7b117712d176f2953c3170bccced5eaacfa3e96175/coverage-7.14.3.tar.gz", hash = "sha256:1a7563a443f3d53fdeb040ec8c9f7466aed7ca3dc5891aa09d3ca3625fa4387f", size = 924398, upload-time = "2026-06-22T23:10:25.584Z" }
|
||||
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/61/56/14e3b97facbfa1304dd19e676e26599ad359f04714bed32f7f1c5a88efdc/coverage-7.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:605ab2b566a22bd94834529d66d295c364aba84afd3e5498285c7a524017b1fc", size = 220741, upload-time = "2026-06-22T23:09:21.616Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/1d/db378b5cca433b90b893f26dab728b280ddd89f272a1fdfed4aeaa05c686/coverage-7.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3c2134809e80fac091bfed18a6991b5a5eb5df5ae32b17ac4f4f99864b73dd7", size = 221068, upload-time = "2026-06-22T23:09:23.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/f0/3f8421b20d9c4fcd39be9a8ca3c3fda8bc204b44efbd09fede153afd3e2f/coverage-7.14.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c02efd507227bde9969cab0db8f48890eb3b5dcad6afac57a4792df4133543ce", size = 252117, upload-time = "2026-06-22T23:09:25.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/ca/59ea35fb99743549ec8b37eff141ece4431fea590c89e536ed8032ef45cf/coverage-7.14.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1bb93c2aa61d2a5b38f1526546d95cf4132cb681e541a337bf8dfd092be816e5", size = 254622, upload-time = "2026-06-22T23:09:27.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/25/ec6de51ae7493b92a1cf74d1b763121c29636759167e2a593ba4db5881e4/coverage-7.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f502e948e03e866538048bba081c075caaa62e5bda6ea5b7432e45f587eb462a", size = 255968, upload-time = "2026-06-22T23:09:29.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/05/c8bfc77823f42b4664fb25842f13b567022f6f84a4c83c8ecbb16734b7cb/coverage-7.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9973ef2463f8e6cfb61a6324126bb3e17d67a85f22f58d856e583ea2e3ca6501", size = 258284, upload-time = "2026-06-22T23:09:31.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/15/1d1b242027124a32b26ef01f82018b8c4ef34ef174aa6aeba7b1eeef48e8/coverage-7.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9be4e7d4c5ca0427889f8f9d614bd630c2be741b1de7699bca3b2b6c0e41003e", size = 252143, upload-time = "2026-06-22T23:09:33.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/b6/d2a9842fd2a5d7d27f1ac851c043a734a494ad75402c5331db3da79ed691/coverage-7.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a574912f3bde4b0619f6e97d01aa590b70998859244793769eb3a6df78ee56d3", size = 253976, upload-time = "2026-06-22T23:09:35.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/30/e1600ddf7e226db5558bb5323d2186fff00f505c4b764643ec89ce5d8175/coverage-7.14.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e343fb086c9cd780b38622fea7c369acd64c1a0724312149b5d769c387a2b1f5", size = 251942, upload-time = "2026-06-22T23:09:37.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/2c/9159de64f9dd648e324328d588a44cfab1e331eb5259ce1141afe2a92dfb/coverage-7.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3c68df8e61f1e09633fefc7538297145623957a048534368c9d212782aa5e845", size = 256220, upload-time = "2026-06-22T23:09:39.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/67/b7f536cc2c124f48e91b22fbb741d2261f4e3d310faf6f76007f47566e5d/coverage-7.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3e5b550a128419373c2f6cec28a244207013ef15f5cbcff6a5ca09d1dfaaf027", size = 251756, upload-time = "2026-06-22T23:09:41.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/ec/f3718038e2d4860c715a55428377ca7f6c75872caf98cabd982e1d76967d/coverage-7.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2bfc4dd0a912329eccc7484a7d0b2a38032b38c40663b1e1ac595f10c457954b", size = 253413, upload-time = "2026-06-22T23:09:43.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/a5/91f11efeef89b3cc9b30461128db15b0511ef813ab889a7b7ab636b3a497/coverage-7.14.3-cp314-cp314-win32.whl", hash = "sha256:0423d64c013057a06e70f070f073cec4b0cbc7d2b27f3c7007292f2ff1d52965", size = 222946, upload-time = "2026-06-22T23:09:45.261Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/fd/98ac9f524d9ec378de831c034dbdeb544ca7ef7d2d9c9996daf232a037fd/coverage-7.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:92c22e19ce64ca3f2ad751f16f14df1468b4c231bd6af97185063a9c292a0cb3", size = 223436, upload-time = "2026-06-22T23:09:47.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/a0/7cd612d650a772a0ae80144443406bf61981c896c3d57c9e6e79fb2cdbd1/coverage-7.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:41de778bd41780586e2b04912079c73089ab5d839624e28db3bdb26de638da92", size = 222861, upload-time = "2026-06-22T23:09:49.384Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/57/017353fab573779c0d00448e47d102edd36c792f7b6f233a4d89a7a08384/coverage-7.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8427f370ca67db4c975d2a26acfc0e5783ca0b52444dbc50278ace0f35445949", size = 221474, upload-time = "2026-06-22T23:09:51.417Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/92/90cf1f1a5c468a9c1b7ba2716e0e205293ad9b02f5f573a6de4318b15ba1/coverage-7.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d8e88f335544a47e22ae2e45b344772925ec65166555c958720d5ed971880891", size = 221738, upload-time = "2026-06-22T23:09:53.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/c0/4df964fa539f8399fd7679c09c472d73744de334686fd3f01e3a2465ce4e/coverage-7.14.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:beaab199b9e5ceaf5a225e16a9d4df136f2a1eae0a5c20de1e277c8a5225f388", size = 263101, upload-time = "2026-06-22T23:09:55.895Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/76/e5d33b2576ae3bf2be2058cd1cae57774b61e400f2c3c58f3783dc2ffb4a/coverage-7.14.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3ff255799f5a1676c71c1c32ec01fd043aa09d57b3d95764b24992757184784", size = 265225, upload-time = "2026-06-22T23:09:57.904Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/d2/e52419afe391a39ba27fdefaf0737d8e34bf03faef6ab3b3006545bbd0d0/coverage-7.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:878832eaac515b62decfa76965aed558775f86bf1fc8cca76993c0c84ae31aed", size = 267643, upload-time = "2026-06-22T23:09:59.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/7a/f2625d8d5006b6b20fba5afaef00b24a763fe96476ea798a3076cbc1f84e/coverage-7.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:611e62cb9386096d81b63e0a05330750268617231e7bd598e1fe77482a2c58a5", size = 268762, upload-time = "2026-06-22T23:10:01.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/bf/908024006bba57127354d74e938954b9c3cd765cc2e0412dc9c37b415cda/coverage-7.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:02c41de2a88011b893050fc9830267d927a50a215f7ad5ec17349db7090ccf26", size = 262208, upload-time = "2026-06-22T23:10:03.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/a0/d4f9296441b909817442fdb26bd77a698f08272ec683a7394b00eb2e47a0/coverage-7.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:526ce9721116af23b1065089f0b75046fe521e7772ab94b641cd66b7a0421889", size = 265096, upload-time = "2026-06-22T23:10:05.936Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/da/4ae4f3f4e477b56a4ce1e5c48a35eff38a94b50130ce5bdc897024741cfc/coverage-7.14.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e4ed44705ca4bead6fc977a8b741f2145608289b33c8a9b42a95d0f15aedbf4d", size = 262699, upload-time = "2026-06-22T23:10:07.973Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/7a/6927148073ff32856d78baa77b4ddc07a9be7e90020f9db0661c4ca523a1/coverage-7.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2415902f385a23dcc4ccd26e0ba803249a169af6a930c003a4c715eeb9a5444e", size = 266433, upload-time = "2026-06-22T23:10:10.145Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/a7/774f658dbe9c4c3f5daa86a87e0459ac3832e4e3cc67affe078547f727b9/coverage-7.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b75ee850fc2d7c831e883220c445b035f2224de2ba6103f1e56dbd237ab913f7", size = 261547, upload-time = "2026-06-22T23:10:12.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/14/a0c18c0376c43cbf973f43ef6ca20019c950597180e6396232f7b6a27102/coverage-7.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dc9b4e35e7c3920e925ba7f14886fd5fbe481232754624e832ddba66c7535635", size = 263859, upload-time = "2026-06-22T23:10:14.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/ac/43a3d0f460af524b131a6191805bc5d18b806ab4e828fbf82e8c8c3af446/coverage-7.14.3-cp314-cp314t-win32.whl", hash = "sha256:7b27c822a8161afbe48e99f1adfb098d270ae7e0f7d7b0555ce110529bdb69cc", size = 223250, upload-time = "2026-06-22T23:10:16.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/5f/d5e5c56b0712e96ce8f69fe7dbf229ff938b437bc50862743c8a0d2cea84/coverage-7.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:39e1dbbb6ff2c338e0196a482558a792a1de3aa64261196f5cdb3da016ad9cda", size = 224082, upload-time = "2026-06-22T23:10:19.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/35/947cbd5be1d3bcbbdc43d6791de8a56c6501903311d42915ae06a82815f0/coverage-7.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:68520c90babfa2d560eca6d497921ed3a4f469623bd709733124491b2aa8ef3f", size = 223400, upload-time = "2026-06-22T23:10:21.24Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/e3/a0aa32bfa3a081951f60a23bc0e7b512891ef0eecda1153cf1d8ba36c6b1/coverage-7.14.3-py3-none-any.whl", hash = "sha256:fb7e18afb6e903c1a92401a2f0501ac277dca527bb9ca6fe1f691a8a0026a0e8", size = 212469, upload-time = "2026-06-22T23:10:23.405Z" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
@@ -156,7 +156,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.3"
|
||||
version = "9.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
@@ -165,9 +165,9 @@ dependencies = [
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -187,32 +187,32 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.14"
|
||||
version = "0.15.19"
|
||||
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/d5/e6/15800dfde183a1a106594016c912b4c12d050a301989d1aca6cb63759fe8/ruff-0.15.19.tar.gz", hash = "sha256:edc27f7172a93b32b102687009d6a588508815072141543ae603a8b9b0823063", size = 4772071, upload-time = "2026-06-24T01:10:46.942Z" }
|
||||
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/88/4c/9ded7626c39a0440c575bf69e2bf500d443388272c842662c59852ee7fcd/ruff-0.15.19-py3-none-linux_armv6l.whl", hash = "sha256:922d1eb283161564759bd49f507e91dc6112c15da8bd5b84ed714e086243cf86", size = 10950859, upload-time = "2026-06-24T01:10:38.491Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/ef/c211505ece1d00ef493d58e54e3b6383c946a21e9874774eb531f2512cf3/ruff-0.15.19-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4d190d8f62a0b94aba8f721116538a9ee29b1e74d26650846ba9b99f0ae21c40", size = 11294529, upload-time = "2026-06-24T01:10:36.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/93/78d462e7d39968e58094dc57be7d09ffb14ce37da5b68ed70338a35a1f21/ruff-0.15.19-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5a2c86ba6870dd415a9d9eb8be94d7924ebec6a26ffc7958ec7ca29d4bff967d", size = 10641416, upload-time = "2026-06-24T01:10:48.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/c4/5cb66cfd1f865d5cca908b86c93ac785e7f572193d3c7426079ca6643e24/ruff-0.15.19-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82b432bc087264aea70fd25ac198918b70bd9e2aa0db4297b0bb91bbfbbc63ce", size = 11015582, upload-time = "2026-06-24T01:10:30.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/9f/8ecfaec10cf5eecd28fbc00ff4fb867db90a1be54bf3d39ebf93f893cd52/ruff-0.15.19-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8530a09d03b3a8c994f8b559a7dcdabc690bcd3f78ef276c38c83166798ebf56", size = 10744059, upload-time = "2026-06-24T01:10:32.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/6b/983249d04562bc2d590edd75f32455cdb473affb3ba4bc8d883e939c697d/ruff-0.15.19-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87bf21fb3875fe69f0eacc825411657e2e85589cce633c35c0adf1113649c62b", size = 11568461, upload-time = "2026-06-24T01:10:17.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/39/bc7794f127b18f492a3b4ee82bba5a900c985ff13b72b46f46e3c171ba34/ruff-0.15.19-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f9b229cb3ef56ecc2c1c8ebeca64b7a7740ccaef40a9eb097e78dde5a8560b83", size = 12429690, upload-time = "2026-06-24T01:10:40.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/3b/0de6859e698ed11c8a49e765196c8d333599b6a546c0715df39b6ba1aa2e/ruff-0.15.19-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6c754515be7b76afe6e7e62df7776709571bcfc1631183828afcf3bafa869e3", size = 11693067, upload-time = "2026-06-24T01:10:25.681Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/3d/0b1f30f84bee9ae6ae8d349c2ba8b6f4b040966744efdd3acc804ae7c024/ruff-0.15.19-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a498f82e0f4d8904c4e0aea5139cdfac1f39d19a3c51d491292f63a36e83b2e", size = 11616911, upload-time = "2026-06-24T01:10:44.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/eb/c90bd3dfc12eed9032c2c1bfe05105b93a1b2c8bce555db6308315b853ce/ruff-0.15.19-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:d48caa34488fb521fd0ef4aea2b0e8fe758298df044138f0d67b687a6a0d07ed", size = 11649343, upload-time = "2026-06-24T01:10:23.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/91/01caa13602a2f12fae5edbe8caf78b3c1e6db1293132aee6959eecce095c/ruff-0.15.19-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4171b6613effa9363cd46dd4f75bd1827b6d1b946b5e278ed0c600d305379445", size = 10977610, upload-time = "2026-06-24T01:10:50.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/51/acb817922feab9ecbb3201377d4dbe7a25f1395e46545820061973f03468/ruff-0.15.19-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:27c15b2a241dd4d995557949a094fe78b8ad99122a38ccae1595849bcc947b3f", size = 10744900, upload-time = "2026-06-24T01:10:42.726Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/bc/5c8ca46b8a7a3f2b16cfbec88721d772b1c93912904e8f8c2e49470fea63/ruff-0.15.19-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ed03b7862d68f0a8771d50ee129980cbf1b113f96e250b73954bc292f689e0bb", size = 11293560, upload-time = "2026-06-24T01:10:21.262Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/e0/4a888cbe4d5523b3f77a2b1fa043f46cfeba1b32eac35dcfadee0578fa8a/ruff-0.15.19-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:08143f0685ae278b30727ea72e90c61e5bd9c31b91aac4f5bb989538f73d24b8", size = 11696533, upload-time = "2026-06-24T01:10:53.046Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/43/c34b2fcd79262a85161764a97aaca89c3e4f574340ab61430cefa2bdd2c1/ruff-0.15.19-py3-none-win32.whl", hash = "sha256:8f47f0f92952af2557212bb10cf3e695cd4cf28b2c6e42cdb18ec6c9ebfa19da", size = 10986299, upload-time = "2026-06-24T01:10:55.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/e8/15fd23e02b2442b56b2026b455977bc3057aa34b26e6323d1e99e8531a9f/ruff-0.15.19-py3-none-win_amd64.whl", hash = "sha256:efeca47ee3f9d4a7162655a3b8e6ee4a878646044233978d4d2c1ff8cdd914f0", size = 12123473, upload-time = "2026-06-24T01:10:27.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/66/9a73695e31eaee04f35d8475998bf8ab354465f9c638936d76111603dcc5/ruff-0.15.19-py3-none-win_arm64.whl", hash = "sha256:6c6b607466e47349332eb1d9be52fb1467423fc07c217341af41cd0f3f0573be", size = 11376779, upload-time = "2026-06-24T01:10:34.465Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stapler"
|
||||
version = "1.4.1"
|
||||
version = "1.5.2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "requests" },
|
||||
|
||||
Reference in New Issue
Block a user