Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bc30a3f4d | ||
|
|
fb08fe30b4 | ||
|
|
ccd1ac9ffb | ||
|
|
a02a1ad53b | ||
|
|
9ce68c320d | ||
|
|
e9be3c86ae | ||
|
|
0b39313f7e | ||
|
|
a2e0f9afb9 | ||
|
|
95514f16cb | ||
|
|
6db1b561f0 | ||
|
|
4256398cca |
@@ -1,26 +1,26 @@
|
||||
name: Docker CI
|
||||
on: [push]
|
||||
|
||||
concurrency:
|
||||
group: docker-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on: [push, workflow_dispatch]
|
||||
|
||||
env:
|
||||
BUILDKIT_VERSION: 'v0.29.0'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: docker/setup-buildx-action@v1
|
||||
- uses: actions/cache@v4
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- name: Set up buildkit cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildx-
|
||||
- uses: docker/build-push-action@v2
|
||||
path: /var/lib/buildkit
|
||||
key: ${{ runner.os }}-buildkit-${{ env.BUILDKIT_VERSION }}
|
||||
- name: Test docker build
|
||||
uses: klemek/dockerfile-test-build@v1
|
||||
with:
|
||||
context: ./
|
||||
file: ./Dockerfile
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
push: false
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache-new
|
||||
- run: |
|
||||
rm -rf /tmp/.buildx-cache
|
||||
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
|
||||
- run: echo ${{ steps.docker_build.outputs.digest }}
|
||||
buildkit_version: ${{ env.BUILDKIT_VERSION }}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
name: Python CI
|
||||
|
||||
concurrency:
|
||||
group: python-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on: [push, workflow_dispatch]
|
||||
|
||||
jobs:
|
||||
ruff:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
- name: Run Ruff check
|
||||
uses: https://github.com/astral-sh/ruff-action@v3
|
||||
with:
|
||||
args: "check"
|
||||
|
||||
ruff-format-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
- name: Run Ruff format check
|
||||
uses: https://github.com/astral-sh/ruff-action@v3
|
||||
with:
|
||||
args: "format --check --diff"
|
||||
|
||||
ty:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install UV
|
||||
run: python3 -m pip install uv --break-system-packages
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
- name: Sync Python dependencies
|
||||
run: python3 -m uv sync
|
||||
- name: Run ty check
|
||||
run: python3 -m uv run ty check --output-format github
|
||||
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install UV
|
||||
run: python3 -m pip install uv --break-system-packages
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
- name: Sync Python dependencies
|
||||
run: python3 -m uv sync
|
||||
- name: Run tests with coverage
|
||||
run: python3 -m uv run coverage run -m unittest -v
|
||||
- name: Generate coverage XML report
|
||||
run: python3 -m uv run coverage xml
|
||||
- name: Create coverage summary
|
||||
uses: https://github.com/irongut/CodeCoverageSummary@v1.3.0
|
||||
with:
|
||||
filename: coverage.xml
|
||||
@@ -1,37 +0,0 @@
|
||||
name: Python CI
|
||||
on: [push]
|
||||
jobs:
|
||||
ruff:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: astral-sh/ruff-action@v3
|
||||
|
||||
ruff-format-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: astral-sh/ruff-action@v3
|
||||
with:
|
||||
args: "format --check --diff"
|
||||
|
||||
ty:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: python3 -m pip install uv
|
||||
- uses: actions/checkout@v5
|
||||
- run: python3 -m uv sync
|
||||
- run: python3 -m uv run ty check --output-format github
|
||||
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: python3 -m pip install uv
|
||||
- uses: actions/checkout@v5
|
||||
- run: python3 -m uv sync
|
||||
- run: python3 -m uv run coverage run -m unittest -v
|
||||
- run: python3 -m uv run coverage xml
|
||||
- uses: irongut/CodeCoverageSummary@v1.3.0
|
||||
with:
|
||||
filename: coverage.xml
|
||||
|
||||
@@ -12,3 +12,4 @@ crontab
|
||||
*.egg-info
|
||||
build
|
||||
dist
|
||||
.pytest_cache
|
||||
|
||||
@@ -15,6 +15,7 @@ COVERAGE ?= $(UV) run --active coverage
|
||||
DOCKER ?= docker
|
||||
DOCKER_TAG ?= localhost/stapler:latest
|
||||
PORT ?= 8080
|
||||
OPEN ?= xdg-open
|
||||
|
||||
# DOCS
|
||||
|
||||
@@ -116,6 +117,7 @@ coverage-report: .venv ## coverage report
|
||||
.PHONY: coverage-html
|
||||
coverage-html: .venv ## coverage html
|
||||
@$(COVERAGE) html
|
||||
@$(OPEN) htmlcov/index.html || true
|
||||
|
||||
.PHONY: coverage-xml
|
||||
coverage-xml: .venv ## coverage xml
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
[](https://git.klemek.fr/klemek/stapler/actions?workflow=lint.yml) [](https://git.klemek.fr/klemek/stapler/actions?workflow=docker.yml)
|
||||
|
||||
# Stapler <!-- omit in toc -->
|
||||
|
||||
*Static pages as simple as a gzip file*
|
||||
@@ -112,33 +114,33 @@ PUT /{page}/
|
||||
```bash
|
||||
# create archive from 'dist' dir and upload it to /my-project/
|
||||
tar -czC dist -f dist.tar.gz .
|
||||
curl -v -X PUT \
|
||||
curl -X PUT \
|
||||
-H 'X-Token: <TOKEN>' \
|
||||
--data-binary "@dist.tar.gz" \
|
||||
https://stapler-host/my-project/
|
||||
|
||||
# same thing but one-liner
|
||||
tar -czC dist . | curl -v -X PUT \
|
||||
tar -czC dist . | curl -X PUT \
|
||||
-H 'X-Token: <TOKEN>' \
|
||||
--data-binary @- \
|
||||
https://stapler-host/my-project/
|
||||
|
||||
# make stapler server identify myproject.example.com and /my-project/
|
||||
tar -czC dist . | curl -v -X PUT \
|
||||
tar -czC dist . | curl -X PUT \
|
||||
--data-binary @- \
|
||||
-H 'X-Token: <TOKEN>' \
|
||||
-H 'X-Host: myproject.example.com' \
|
||||
https://stapler-host/my-project/
|
||||
|
||||
# make stapler server identifiers myproject.example.com only
|
||||
tar -czC dist . | curl -v -X PUT \
|
||||
tar -czC dist . | curl -X PUT \
|
||||
--data-binary @- \
|
||||
-H 'X-Token: <TOKEN>' \
|
||||
-H 'X-Host-Only: myproject.example.com' \
|
||||
https://stapler-host/my-project/
|
||||
|
||||
# make a SPA site at /my-project/index.html
|
||||
tar -czC dist . | curl -v -X PUT \
|
||||
tar -czC dist . | curl -X PUT \
|
||||
--data-binary @- \
|
||||
-H 'X-Token: <TOKEN>' \
|
||||
-H 'X-SPA: index.html' \
|
||||
@@ -160,13 +162,13 @@ PUT /{page}/
|
||||
|
||||
```bash
|
||||
# create /my-project/ that redirects to https://github.com/my-project
|
||||
curl -v -X PUT \
|
||||
curl -X PUT \
|
||||
-H 'X-Token: <TOKEN>' \
|
||||
-H 'X-Redirect: https://github.com/my-project' \
|
||||
https://stapler-host/my-project/
|
||||
|
||||
# simple redirect from root host to www
|
||||
curl -v -X PUT \
|
||||
curl -X PUT \
|
||||
-H 'X-Token: <TOKEN>' \
|
||||
-H 'X-Proxy: https://www.my-website.com' \
|
||||
-H 'X-Host: my-website.com' \
|
||||
@@ -185,7 +187,7 @@ PUT /{page}/
|
||||
|
||||
```bash
|
||||
# create /my-website/ that proxies to http://host.containers.internal:8000
|
||||
curl -v -X PUT \
|
||||
curl -X PUT \
|
||||
-H 'X-Token: <TOKEN>' \
|
||||
-H 'X-Proxy: http://host.containers.internal:8000' \
|
||||
https://stapler-host/my-project/
|
||||
@@ -200,7 +202,7 @@ DELETE /{page}/
|
||||
|
||||
```bash
|
||||
# delete /my-project/
|
||||
curl -v -X DELETE \
|
||||
curl -X DELETE \
|
||||
-H 'X-Token: <TOKEN>' \
|
||||
https://stapler-host/my-project/
|
||||
```
|
||||
@@ -226,7 +228,11 @@ curl -v -X DELETE \
|
||||
- name: Create archive
|
||||
run: tar -czC dist -f dist.tar.gz .
|
||||
- name: Deploy to Stapler server
|
||||
run: curl -v -X PUT -H 'X-Token: ${{ secrets.STAPLER_TOKEN }}' -H 'X-Host: ${{ vars.TARGET_HOST }}' --data-binary "@dist.tar.gz" https://stapler-host/my-project/
|
||||
run: |
|
||||
curl -X PUT --data-binary "@dist.tar.gz" \
|
||||
-H 'X-Token: ${{ secrets.STAPLER_TOKEN }}' \
|
||||
-H 'X-Host: ${{ vars.TARGET_HOST }}' \
|
||||
${{ vars.STAPLER_URL }}
|
||||
```
|
||||
|
||||
### Redirecting hosts with DNS
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "stapler"
|
||||
version = "1.2.0"
|
||||
version = "1.2.2"
|
||||
description = "Static pages as simple as a gzip file"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
@@ -21,6 +21,7 @@ module-name = "stapler"
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"coverage>=7.13.5",
|
||||
"pytest>=9.0.3",
|
||||
"ruff>=0.15.10",
|
||||
"ty>=0.0.29",
|
||||
]
|
||||
|
||||
@@ -122,7 +122,9 @@ class CertManager:
|
||||
)
|
||||
self.logger.info("Created self-signed certificate for %s", host)
|
||||
except CertManagerError:
|
||||
self.logger.exception("Could not create certbot certificate for %s\n%s")
|
||||
self.logger.exception(
|
||||
"Could not create self-signed certificate for %s", host
|
||||
)
|
||||
return False
|
||||
except subprocess.CalledProcessError as e:
|
||||
self.logger.exception(
|
||||
@@ -172,7 +174,7 @@ class CertManager:
|
||||
)
|
||||
self.logger.info("Created certbot certificate for %s", host)
|
||||
except CertManagerError:
|
||||
self.logger.exception("Could not create certbot certificate for %s\n%s")
|
||||
self.logger.exception("Could not create certbot certificate for %s", host)
|
||||
return False
|
||||
except subprocess.CalledProcessError as e:
|
||||
self.logger.exception(
|
||||
|
||||
+93
-61
@@ -1,4 +1,5 @@
|
||||
import abc
|
||||
import contextlib
|
||||
import http
|
||||
import http.cookiejar
|
||||
import http.server
|
||||
@@ -45,13 +46,25 @@ class BaseHandler(abc.ABC, http.server.BaseHTTPRequestHandler):
|
||||
code: int,
|
||||
message: str | None = None,
|
||||
explain: str | None = None,
|
||||
) -> None:
|
||||
self.send_status(code, message, explain)
|
||||
|
||||
def send_status(
|
||||
self,
|
||||
code: int,
|
||||
message: str | None = None,
|
||||
explain: str | None = None,
|
||||
) -> None:
|
||||
shortmsg, longmsg = self.responses[code]
|
||||
if message is None:
|
||||
message = shortmsg
|
||||
if explain is None:
|
||||
explain = longmsg
|
||||
if "text/" in self._get_header("Accept"):
|
||||
if (
|
||||
not self._has_header("Accept")
|
||||
or self._get_header("Accept").startswith("*/")
|
||||
or self._get_header("Accept").startswith("text/")
|
||||
):
|
||||
self.send_basic_body(
|
||||
f"{code} {message}\n{explain}\n\n{self.server_signature()}",
|
||||
code=code,
|
||||
@@ -153,7 +166,12 @@ class BaseHandler(abc.ABC, http.server.BaseHTTPRequestHandler):
|
||||
http.HTTPStatus.BAD_GATEWAY, f"Could not reach {url}", explain=str(e)
|
||||
)
|
||||
return
|
||||
self.send_response(response.status_code, response.reason)
|
||||
self.send_response(
|
||||
response.status_code
|
||||
if type(response.status_code) is int
|
||||
else http.HTTPStatus.BAD_GATEWAY,
|
||||
response.reason,
|
||||
)
|
||||
for header, value in response.headers.items():
|
||||
if header.lower() not in [
|
||||
"content-length",
|
||||
@@ -211,6 +229,14 @@ class BaseHandler(abc.ABC, http.server.BaseHTTPRequestHandler):
|
||||
def server_signature(self) -> str:
|
||||
return self.server_version + "\n\n" + STAPLER_ASCII + "\n"
|
||||
|
||||
@contextlib.contextmanager
|
||||
def handle_errors(self) -> typing.Iterator[None]:
|
||||
try:
|
||||
yield
|
||||
except Exception as e:
|
||||
self.send_error(http.HTTPStatus.INTERNAL_SERVER_ERROR, str(e))
|
||||
self.logger.exception("Internal Server Error")
|
||||
|
||||
|
||||
class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
@@ -323,12 +349,14 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler):
|
||||
|
||||
@typing.override
|
||||
def do_HEAD(self) -> None:
|
||||
with self.handle_errors():
|
||||
self._pre_log_request()
|
||||
if not self._proxy_or_redirect():
|
||||
super().do_HEAD()
|
||||
|
||||
@typing.override
|
||||
def do_GET(self) -> None:
|
||||
with self.handle_errors():
|
||||
self._pre_log_request()
|
||||
if self._proxy_or_redirect():
|
||||
return None
|
||||
@@ -337,30 +365,29 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler):
|
||||
return super().do_GET()
|
||||
|
||||
def do_PUT(self) -> None:
|
||||
with self.handle_errors():
|
||||
self._pre_log_request()
|
||||
if self._proxy_or_redirect():
|
||||
return None
|
||||
if (path := self.__check_update_request()) is None:
|
||||
return None
|
||||
if not self.__check_put_headers():
|
||||
return None
|
||||
if (
|
||||
self.has_target_host
|
||||
and (page := self.registry.get_from_host(self.target_host)) is not None
|
||||
and page.path != path
|
||||
):
|
||||
return self.send_error(http.HTTPStatus.FORBIDDEN, "Host already taken")
|
||||
return
|
||||
if (path := self.__check_put_request()) is None:
|
||||
return
|
||||
if self.has_target_redirect:
|
||||
self._update_redirect(path)
|
||||
if not self._update_redirect(path):
|
||||
return
|
||||
elif self.has_target_proxy:
|
||||
self._update_proxy(path)
|
||||
else:
|
||||
self._update_extract(path)
|
||||
if not self._update_proxy(path):
|
||||
return
|
||||
elif not self._update_extract(path):
|
||||
return
|
||||
if self.has_request_host:
|
||||
self.registry.set_host(path, self.target_host)
|
||||
if self.has_request_host_only:
|
||||
self.registry.set_host_only(path, self.target_host)
|
||||
return None
|
||||
self.send_status(
|
||||
http.HTTPStatus.CREATED,
|
||||
"Resource updated",
|
||||
str(self.registry.get_from_path(path)),
|
||||
)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
self.do_PUT() # be gentle on them
|
||||
@@ -369,95 +396,88 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler):
|
||||
self.do_PUT() # be gentle on them
|
||||
|
||||
def do_DELETE(self) -> None:
|
||||
with self.handle_errors():
|
||||
self._pre_log_request()
|
||||
if self._proxy_or_redirect():
|
||||
return None
|
||||
return
|
||||
if (path := self.__check_update_request()) is None:
|
||||
return None
|
||||
return self._update_remove(path)
|
||||
return
|
||||
if self._update_remove(path):
|
||||
self.send_status(
|
||||
http.HTTPStatus.OK,
|
||||
f"Resource /{path}/ removed",
|
||||
)
|
||||
return
|
||||
|
||||
def do_CONNECT(self) -> None:
|
||||
with self.handle_errors():
|
||||
self._pre_log_request()
|
||||
if not self._proxy_or_redirect():
|
||||
self.send_error(http.HTTPStatus.METHOD_NOT_ALLOWED)
|
||||
|
||||
def do_OPTIONS(self) -> None:
|
||||
with self.handle_errors():
|
||||
self._pre_log_request()
|
||||
if not self._proxy_or_redirect():
|
||||
self.send_error(http.HTTPStatus.METHOD_NOT_ALLOWED)
|
||||
|
||||
def do_TRACE(self) -> None:
|
||||
with self.handle_errors():
|
||||
self._pre_log_request()
|
||||
if not self._proxy_or_redirect():
|
||||
self.send_error(http.HTTPStatus.METHOD_NOT_ALLOWED)
|
||||
|
||||
def _update_extract(self, path: str) -> None:
|
||||
def _update_extract(self, path: str) -> bool:
|
||||
if self.in_size == 0:
|
||||
return self.send_error(http.HTTPStatus.LENGTH_REQUIRED, "No body found")
|
||||
self.send_error(http.HTTPStatus.LENGTH_REQUIRED, "No body found")
|
||||
return False
|
||||
if self.in_size > self.max_size_bytes:
|
||||
return self.send_error(
|
||||
self.send_error(
|
||||
http.HTTPStatus.CONTENT_TOO_LARGE,
|
||||
"Archive too large",
|
||||
)
|
||||
return False
|
||||
try:
|
||||
file_bytes = io.BytesIO(self.rfile.read(self.in_size))
|
||||
self.data_dir.extract_tar_bytes(path, file_bytes)
|
||||
except tarfile.TarError:
|
||||
return self.send_error(http.HTTPStatus.BAD_REQUEST, "Invalid tar archive")
|
||||
except Exception as e:
|
||||
return self.send_error(http.HTTPStatus.INTERNAL_SERVER_ERROR, str(e))
|
||||
self.send_error(http.HTTPStatus.BAD_REQUEST, "Invalid tar archive")
|
||||
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.send_status_only(
|
||||
http.HTTPStatus.CREATED,
|
||||
f"Resource /{path}/ updated",
|
||||
)
|
||||
return None
|
||||
return True
|
||||
|
||||
def _update_redirect(self, path: str) -> None:
|
||||
def _update_redirect(self, path: str) -> bool:
|
||||
if self.in_size > 0:
|
||||
return self.send_error(
|
||||
self.send_error(
|
||||
http.HTTPStatus.BAD_REQUEST,
|
||||
f"No content must be sent with {self.REDIRECT_HEADER}",
|
||||
)
|
||||
return False
|
||||
self.registry.set_redirect(path, self.target_redirect)
|
||||
self.token_manager.set_token(path, self.token)
|
||||
self.send_status_only(
|
||||
http.HTTPStatus.CREATED,
|
||||
f"Resource /{path}/ updated",
|
||||
)
|
||||
return None
|
||||
return True
|
||||
|
||||
def _update_proxy(self, path: str) -> None:
|
||||
def _update_proxy(self, path: str) -> bool:
|
||||
if self.in_size > 0:
|
||||
return self.send_error(
|
||||
self.send_error(
|
||||
http.HTTPStatus.BAD_REQUEST,
|
||||
f"No content must be sent with {self.PROXY_HEADER}",
|
||||
)
|
||||
return False
|
||||
self.registry.set_proxy(path, self.target_proxy)
|
||||
self.token_manager.set_token(path, self.token)
|
||||
self.send_status_only(
|
||||
http.HTTPStatus.CREATED,
|
||||
f"Resource /{path}/ updated",
|
||||
)
|
||||
return None
|
||||
return True
|
||||
|
||||
def _update_remove(self, path: str) -> None:
|
||||
def _update_remove(self, path: str) -> bool:
|
||||
if not self.data_dir.exists(path):
|
||||
self.send_error(http.HTTPStatus.NOT_FOUND, "Not found")
|
||||
return None
|
||||
try:
|
||||
return False
|
||||
self.data_dir.remove(path)
|
||||
except Exception as e:
|
||||
return self.send_error(http.HTTPStatus.INTERNAL_SERVER_ERROR, str(e))
|
||||
self.send_status_only(
|
||||
http.HTTPStatus.NO_CONTENT,
|
||||
f"Resource /{path}/ removed",
|
||||
)
|
||||
self.registry.remove(path)
|
||||
return None
|
||||
return True
|
||||
|
||||
def _proxy_or_redirect(self) -> bool:
|
||||
if self.has_token or self.path.startswith(self.CERTBOT_CHALLENGE_PATH):
|
||||
@@ -496,6 +516,7 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler):
|
||||
if (
|
||||
page.spa is not None
|
||||
and not (self.root_path / pathlib.Path(path[1:])).is_file()
|
||||
and not (self.root_path / pathlib.Path(path[1:]) / "index.html").is_file()
|
||||
):
|
||||
path = f"/{page.path}/{page.spa}"
|
||||
return super().translate_path(path)
|
||||
@@ -517,23 +538,32 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler):
|
||||
return None
|
||||
return sub_path
|
||||
|
||||
def __check_put_headers(self) -> bool:
|
||||
def __check_put_request(self) -> str | None:
|
||||
if (path := self.__check_update_request()) is None:
|
||||
return None
|
||||
if self.has_request_host and self.has_request_host_only:
|
||||
self.send_error(
|
||||
http.HTTPStatus.BAD_REQUEST,
|
||||
f"Cannot use {self.HOST_ONLY_HEADER} with {self.HOST_HEADER}",
|
||||
)
|
||||
return False
|
||||
return None
|
||||
if self.has_target_host and not self.__valid_host(self.target_host):
|
||||
self.send_error(http.HTTPStatus.BAD_REQUEST, "Invalid requested host")
|
||||
return False
|
||||
return None
|
||||
if self.has_target_proxy and self.has_target_redirect:
|
||||
self.send_error(
|
||||
http.HTTPStatus.BAD_REQUEST,
|
||||
f"Cannot use {self.PROXY_HEADER} with {self.REDIRECT_HEADER}",
|
||||
)
|
||||
return False
|
||||
return True
|
||||
return None
|
||||
if (
|
||||
self.has_target_host
|
||||
and (page := self.registry.get_from_host(self.target_host)) is not None
|
||||
and page.path != path
|
||||
):
|
||||
self.send_error(http.HTTPStatus.FORBIDDEN, "Host already taken")
|
||||
return None
|
||||
return path
|
||||
|
||||
def __get_path(self, path: str, regex: re.Pattern) -> str | None:
|
||||
if (match := regex.match(path.lower())) is not None:
|
||||
@@ -562,10 +592,12 @@ class UpgradeHandler(RequestHandler):
|
||||
server_version = "StaplerUpgradeServer/" + PKG_VERSION
|
||||
|
||||
def do_HEAD(self) -> None:
|
||||
with self.handle_errors():
|
||||
self._pre_log_request()
|
||||
self.send_redirect(f"https://{self.host}{self.path}")
|
||||
|
||||
def do_GET(self) -> None:
|
||||
with self.handle_errors():
|
||||
if self.path.startswith(self.CERTBOT_CHALLENGE_PATH):
|
||||
super().do_GET()
|
||||
else:
|
||||
|
||||
+25
-11
@@ -136,6 +136,8 @@ class TestRequestHandler(BaseHandlerTestCase):
|
||||
) -> RequestHandler:
|
||||
if headers is None:
|
||||
headers = {}
|
||||
if "Accept" not in headers:
|
||||
headers["Accept"] = "nothing"
|
||||
with self.patch("http.server.BaseHTTPRequestHandler.__init__"):
|
||||
handler = RequestHandler(
|
||||
unittest.mock.MagicMock(),
|
||||
@@ -433,8 +435,9 @@ class TestRequestHandler(BaseHandlerTestCase):
|
||||
self.mock_call_unchecked(self.data_dir.extract_tar_bytes),
|
||||
self.mock_call(self.registry.add, ["path"]),
|
||||
self.mock_call(self.token_manager.set_token, ["path", "secret"]),
|
||||
self.mock_call(self.registry.get_from_path, ["path"]),
|
||||
self.expects_status_only(
|
||||
handler, http.HTTPStatus.CREATED, "Resource /path/ updated"
|
||||
handler, http.HTTPStatus.CREATED, "Resource updated"
|
||||
),
|
||||
self.seal_mocks(),
|
||||
):
|
||||
@@ -458,8 +461,9 @@ class TestRequestHandler(BaseHandlerTestCase):
|
||||
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.get_from_path, ["path"]),
|
||||
self.expects_status_only(
|
||||
handler, http.HTTPStatus.CREATED, "Resource /path/ updated"
|
||||
handler, http.HTTPStatus.CREATED, "Resource updated"
|
||||
),
|
||||
self.seal_mocks(),
|
||||
):
|
||||
@@ -481,8 +485,9 @@ class TestRequestHandler(BaseHandlerTestCase):
|
||||
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.get_from_path, ["path"]),
|
||||
self.expects_status_only(
|
||||
handler, http.HTTPStatus.CREATED, "Resource /path/ updated"
|
||||
handler, http.HTTPStatus.CREATED, "Resource updated"
|
||||
),
|
||||
self.seal_mocks(),
|
||||
):
|
||||
@@ -530,8 +535,9 @@ 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.get_from_path, ["path"]),
|
||||
self.expects_status_only(
|
||||
handler, http.HTTPStatus.CREATED, "Resource /path/ updated"
|
||||
handler, http.HTTPStatus.CREATED, "Resource updated"
|
||||
),
|
||||
self.seal_mocks(),
|
||||
):
|
||||
@@ -557,8 +563,9 @@ 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.get_from_path, ["path"]),
|
||||
self.expects_status_only(
|
||||
handler, http.HTTPStatus.CREATED, "Resource /path/ updated"
|
||||
handler, http.HTTPStatus.CREATED, "Resource updated"
|
||||
),
|
||||
self.seal_mocks(),
|
||||
):
|
||||
@@ -584,8 +591,9 @@ 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.get_from_path, ["path"]),
|
||||
self.expects_status_only(
|
||||
handler, http.HTTPStatus.CREATED, "Resource /path/ updated"
|
||||
handler, http.HTTPStatus.CREATED, "Resource updated"
|
||||
),
|
||||
self.seal_mocks(),
|
||||
):
|
||||
@@ -633,8 +641,9 @@ 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.get_from_path, ["path"]),
|
||||
self.expects_status_only(
|
||||
handler, http.HTTPStatus.CREATED, "Resource /path/ updated"
|
||||
handler, http.HTTPStatus.CREATED, "Resource updated"
|
||||
),
|
||||
self.seal_mocks(),
|
||||
):
|
||||
@@ -660,8 +669,9 @@ 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.get_from_path, ["path"]),
|
||||
self.expects_status_only(
|
||||
handler, http.HTTPStatus.CREATED, "Resource /path/ updated"
|
||||
handler, http.HTTPStatus.CREATED, "Resource updated"
|
||||
),
|
||||
self.seal_mocks(),
|
||||
):
|
||||
@@ -783,9 +793,7 @@ class TestRequestHandler(BaseHandlerTestCase):
|
||||
self.mock_call(self.data_dir.exists, ["path"], True), # noqa: FBT003
|
||||
self.mock_call(self.data_dir.remove, ["path"]),
|
||||
self.mock_call(self.registry.remove, ["path"]),
|
||||
self.expects_error(
|
||||
handler, http.HTTPStatus.NO_CONTENT, "Resource /path/ removed"
|
||||
),
|
||||
self.expects_error(handler, http.HTTPStatus.OK, "Resource /path/ removed"),
|
||||
self.seal_mocks(),
|
||||
):
|
||||
handler.do_DELETE()
|
||||
@@ -813,6 +821,7 @@ class TestRequestHandler(BaseHandlerTestCase):
|
||||
"data": None,
|
||||
"headers": {
|
||||
"Host": "example.com",
|
||||
"Accept": "nothing",
|
||||
"X-Real-IP": "127.0.0.1",
|
||||
"X-Forwarded-Host": "localhost",
|
||||
"X-Forwarded-For": "127.0.0.1",
|
||||
@@ -855,6 +864,7 @@ class TestRequestHandler(BaseHandlerTestCase):
|
||||
"data": b"hello",
|
||||
"headers": {
|
||||
"Host": "example.com",
|
||||
"Accept": "nothing",
|
||||
"X-Real-IP": "127.0.0.1",
|
||||
"X-Forwarded-Host": "localhost",
|
||||
"X-Forwarded-For": "127.0.0.1",
|
||||
@@ -897,6 +907,7 @@ class TestRequestHandler(BaseHandlerTestCase):
|
||||
"data": None,
|
||||
"headers": {
|
||||
"Host": "example.com",
|
||||
"Accept": "nothing",
|
||||
"X-Real-IP": "127.0.0.1",
|
||||
"X-Forwarded-Host": "localhost",
|
||||
"X-Forwarded-For": "127.0.0.1",
|
||||
@@ -930,6 +941,7 @@ class TestRequestHandler(BaseHandlerTestCase):
|
||||
"data": None,
|
||||
"headers": {
|
||||
"Host": "example.com",
|
||||
"Accept": "nothing",
|
||||
"X-Real-IP": "127.0.0.1",
|
||||
"X-Forwarded-Host": "localhost",
|
||||
"X-Forwarded-For": "127.0.0.1",
|
||||
@@ -972,6 +984,7 @@ class TestRequestHandler(BaseHandlerTestCase):
|
||||
"data": None,
|
||||
"headers": {
|
||||
"Host": "example.com",
|
||||
"Accept": "nothing",
|
||||
"X-Real-IP": "127.0.0.1",
|
||||
"X-Forwarded-Host": "localhost",
|
||||
"X-Forwarded-For": "127.0.0.1",
|
||||
@@ -1011,6 +1024,7 @@ class TestRequestHandler(BaseHandlerTestCase):
|
||||
"data": None,
|
||||
"headers": {
|
||||
"Host": "example.com",
|
||||
"Accept": "nothing",
|
||||
"X-Real-IP": "127.0.0.1",
|
||||
"X-Forwarded-Host": "host",
|
||||
"X-Forwarded-For": "127.0.0.1",
|
||||
|
||||
@@ -52,6 +52,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.13.5"
|
||||
@@ -100,6 +109,58 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ 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" }
|
||||
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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.33.1"
|
||||
@@ -142,7 +203,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "stapler"
|
||||
version = "1.2.0"
|
||||
version = "1.2.2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "requests" },
|
||||
@@ -151,6 +212,7 @@ dependencies = [
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "coverage" },
|
||||
{ name = "pytest" },
|
||||
{ name = "ruff" },
|
||||
{ name = "ty" },
|
||||
]
|
||||
@@ -161,32 +223,33 @@ requires-dist = [{ name = "requests", specifier = ">=2.33.1" }]
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "coverage", specifier = ">=7.13.5" },
|
||||
{ name = "pytest", specifier = ">=9.0.3" },
|
||||
{ name = "ruff", specifier = ">=0.15.10" },
|
||||
{ name = "ty", specifier = ">=0.0.29" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ty"
|
||||
version = "0.0.32"
|
||||
version = "0.0.33"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/85/7e/2aa791c9ae7b8cd5024cd4122e92267f664ca954cea3def3211919fa3c1f/ty-0.0.32.tar.gz", hash = "sha256:8743174c5f920f6700a4a0c9de140109189192ba16226884cd50095b43b8a45c", size = 5522294, upload-time = "2026-04-20T19:29:01.626Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/84/44/9478c50c266826c1bf30d1692e589755bffa8f1c0a3eb7af8a346c255991/ty-0.0.33.tar.gz", hash = "sha256:46d63bda07403322cb6c28ccfdd5536be916e13df725c29f7ccd0a21f06bd9e8", size = 5559373, upload-time = "2026-04-28T10:45:13.18Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/62/eb/1075dc6a49d7acbe2584ae4d5b410c41b1f177a5adcc567e09eca4c69000/ty-0.0.32-py3-none-linux_armv6l.whl", hash = "sha256:dacbc2f6cd698d488ae7436838ff929570455bf94bfa4d9fe57a630c552aff83", size = 10902959, upload-time = "2026-04-20T19:28:31.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/d2/c35fc8bc66e98d1ee9b0f8ed319bf743e450e1f1e997574b178fab75670f/ty-0.0.32-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:914bbc4f605ce2a9e2a78982e28fae1d3359a169d141f9dc3b4c7749cd5eca81", size = 10726172, upload-time = "2026-04-20T19:28:44.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/32/c827da3ca480456fb02d8cea68a2609273b6c220fea0be9a4c8d8470b86e/ty-0.0.32-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4787ac9fe1f86b1f3133f5c6732adbe2df5668b50c679ac6e2d98cd284da812f", size = 10163701, upload-time = "2026-04-20T19:28:27.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/9e/2734478fbdb90c160cb2813a3916a16a2af5c1e231f87d635f6131d781fb/ty-0.0.32-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8ea0a728af99fe40dd744cba6441a2404f80b7f4bde17aa6da393810af5ea57", size = 10656220, upload-time = "2026-04-20T19:29:03.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/9f/0007da2d35e424debe7e9f86ffbc1ab7f60983cfbc5f0411324ab2de5292/ty-0.0.32-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2850561f9b018ae33d7e5bbfa0ac414d3c518513edcffe43877dc9801446b9c5", size = 10696086, upload-time = "2026-04-20T19:28:46.829Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/5e/ce5fd4ec803222ae3e69a76d2a2db2eed55e19f5b131702b9789ef45f93d/ty-0.0.32-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b5fa2fb3c614349ee211d36476b49d88c5ef79a687cdb91b2872ad023b94d2f8", size = 11184800, upload-time = "2026-04-20T19:28:42.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/46/ebcf67a5999421331214aac51a7464db42de2be15bbe929c612a3ed0b039/ty-0.0.32-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b89969307ab2417d41c9be8059dd79feea577234e1e10d35132f5495e0d42c6", size = 11718718, upload-time = "2026-04-20T19:28:36.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/2c/2141c86ed0ce0962b45cefb658a95e734f59759d47f20afdcd9c732910a1/ty-0.0.32-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b59868ede9b1d69a088f0d695df52a0061f95fa7baa1d5e0dc6fc9cf06e1334", size = 11346369, upload-time = "2026-04-20T19:28:48.967Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/da/ed6f772339cf29bd9a46def9d6db5084689eb574ee4d150ff704224c1ed8/ty-0.0.32-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8300caf35345498e9b9b03e550bba03cee8f5f5f8ab4c83c3b1ff1b7403b7d3a", size = 11280714, upload-time = "2026-04-20T19:28:51.516Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/9b/c6813987edf4816a40e0c8e408b555f97d3f267c7b3a1688c8bbdf65609c/ty-0.0.32-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:583c7094f4574b02f724db924f98b804d1387a0bd9405ecb5e078cc0f47fbcfb", size = 10638806, upload-time = "2026-04-20T19:28:29.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/d4/0cefcbd2ad0f3d51762ccf58e652ec7da146eb6ae34f87228f6254bbb8be/ty-0.0.32-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e44ebe1bb4143a5628bc4db67ac0dfebe14594af671e4ee66f6f2e983da56501", size = 10726106, upload-time = "2026-04-20T19:29:06.3Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/ad/2c8a97f91f06311f4367400f7d13534bbda2522c73c99a3e4c0757dff9b8/ty-0.0.32-py3-none-musllinux_1_2_i686.whl", hash = "sha256:06f17ada3e069cba6148342ef88e9929156beca8473e8d4f101b68f66c75643e", size = 10872951, upload-time = "2026-04-20T19:28:34.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/68/42293f9248106dd51875120971a5cc6ea315c2c4dcfb8e59aa063aa0af26/ty-0.0.32-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e96e60fa556cec04f15d7ea62d2ceee5982bd389233e961ab9fd42304e278175", size = 11363334, upload-time = "2026-04-20T19:28:54.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/92/be9abf4d3e589ad5023e2ea965b93e204ec856420d46adf73c5c36c04678/ty-0.0.32-py3-none-win32.whl", hash = "sha256:2ff2ebb4986b24aebcf1444db7db5ca41b36086040e95eea9f8fb851c11e805c", size = 10260689, upload-time = "2026-04-20T19:28:56.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/61/dc86acea899349d2579cb8419aecedd83dc504d7d6a10df65eef546c8300/ty-0.0.32-py3-none-win_amd64.whl", hash = "sha256:ba7284a4a954b598c1b31500352b3ec1f89bff533825592b5958848226fdc7ee", size = 11255371, upload-time = "2026-04-20T19:28:39.917Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/01/beffec56d71ca25b343ede63adb076456b5b3e211f1c066452a44cd120b3/ty-0.0.32-py3-none-win_arm64.whl", hash = "sha256:7e10aadbdbda989a7d567ee6a37f8b98d4d542e31e3b190a2879fd581f75d658", size = 10658087, upload-time = "2026-04-20T19:28:59.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/24/e287388c63a19191be26b32ff4dbd06029834068150ebe2532939bc4c851/ty-0.0.33-py3-none-linux_armv6l.whl", hash = "sha256:94d0a9d2234261a8911396d59e506b5923fe0971dbda43b9dcea287936887fcc", size = 11021308, upload-time = "2026-04-28T10:45:43.34Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/ca/ba1eed819895bd239fba8ee35dfcd5fcb266c203b0914a17a59579096bb5/ty-0.0.33-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e4a2b5ba078f90de342f56b5f7979bb77c9b9b1d8625a041352ffc6ee93c4073", size = 10777272, upload-time = "2026-04-28T10:45:32.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/a8/c3131d37b44b3fea1d6654a1c929a0cd0873822f77a90482b8ec28f6fbbd/ty-0.0.33-py3-none-macosx_11_0_arm64.whl", hash = "sha256:84ff5707825e9af9668d2bcf66975f93e520a63b524ab494e3a8265735be2563", size = 10201078, upload-time = "2026-04-28T10:45:23.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/db/d8e37ff0045810cc65e1ff36aa0da0a2253c05659787ac987df8a16c7897/ty-0.0.33-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e375285736f57886868e7af0b11c7b0ec5b6543fa15e7ad2a714fed9f077d4e0", size = 10732347, upload-time = "2026-04-28T10:45:21.444Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/1a/20e83a412506a918e4684fc67b567cf7cc13b105470b3428cb23c3d5aa13/ty-0.0.33-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5680f6350c3b4e46b8bff6d7bb132366ea239463d6cad4892725d06046e65464", size = 10808238, upload-time = "2026-04-28T10:45:38.565Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/4b/d0a39f4464dc6cb4cc2c159473ce216bd1846bfb684c0323a3cb36dce5c6/ty-0.0.33-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5535538bad8d0f7e62bcdff02197cdb30e41451d80b35d27e17d128f2e1dc5d", size = 11288348, upload-time = "2026-04-28T10:45:08.419Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/7e/f1745e0f9583363d7a83d9a4990fc244f76ecc30840ddad83dc16a33c52d/ty-0.0.33-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:da196c42bbbc069e1e21e3e52107c061aa9660352dae57a41930690b56e2c02d", size = 11789907, upload-time = "2026-04-28T10:45:19.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/71/25f39f46a12d662859d45bc648555d0661044eb43db6b5648c9947487da9/ty-0.0.33-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9281672921ef6d4460e03146b5e6c18cb1a3e3a3b8a1a88f6f33226d05a469b7", size = 11500774, upload-time = "2026-04-28T10:45:48.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/ec/136959ecbb7c71cb90537f5aea441c73f4ab24612868a6ecdc9d7444d32d/ty-0.0.33-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82c1b8f303f82da64e878108e764be3ecbcd7c9903ac0a7f7031614ed00b97ab", size = 11360314, upload-time = "2026-04-28T10:45:05.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/95/32809575c222f00beed498cb728e9290a0f5009f930025381bb7253b2206/ty-0.0.33-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:efe3af412c9ff67bce5fa37d0a2b0d8555c24072b145a5bac6c79637f1c83abe", size = 10707785, upload-time = "2026-04-28T10:45:10.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/89/c8e9531f7aa4a093359e15fa32c8e1277fbbe90d16894d7c6032d29f4b34/ty-0.0.33-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:aeec29c91ea768601747da546c3efc20b72c2fb1bd52bcc786a5c6eeff51d27b", size = 10834987, upload-time = "2026-04-28T10:45:40.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/16/9835fbcf5338af1a1917bd28fdb8a7193c210b83f243aa286fa9f79cb3ad/ty-0.0.33-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a535977c52bbb5f7e96b8b70a6ad375ad077f4a9ff2492508ea3816a2b403819", size = 10968968, upload-time = "2026-04-28T10:45:30.26Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/69/64c76aabc1bc70c7f24b686cd93c3407f8ea430905e395f59bf9603ef571/ty-0.0.33-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1d732facf39fcb221ba279d469c5040d37883e964f123b1563888efd34818180", size = 11458077, upload-time = "2026-04-28T10:45:45.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/84/fae27b0c4718776a298690d31ca4cc1995f2e3e1c63a7b59e84c41498e9a/ty-0.0.33-py3-none-win32.whl", hash = "sha256:d90960b574428dc252f85e8598ec5fcb7f619794196b2fc95a90da075ed4681c", size = 10345364, upload-time = "2026-04-28T10:45:16.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/a0/a2938b23ae3e1a09a2d7c189e2ac5f7113676bae4e0e23948b568e18e5f8/ty-0.0.33-py3-none-win_amd64.whl", hash = "sha256:c1c3aec62c44de610c6e95f0a4e97ac3dbc07934bfdbf1fd90d758c9ff72f48e", size = 11342470, upload-time = "2026-04-28T10:45:26.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/62/7fb948aace38d2f6329261bb33c035a8484549c74f1db28649c7a4c6fed9/ty-0.0.33-py3-none-win_arm64.whl", hash = "sha256:0d44f99ba1b441e55e2aa301b2ac0a21112784931b46a5f66f4ea9efe5620d97", size = 10742673, upload-time = "2026-04-28T10:45:35.555Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user