Commit 3a8f8760 by 周田

实现可复用更新器与统一依赖配置

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
parent f38a1ccd
......@@ -2,9 +2,11 @@
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
# Local distribution artifacts and database
distribution-server/data/
[project]
name = "program-updater"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
version = "261028.0.0"
description = "Lightweight updater for Windows PyInstaller applications"
readme = "updater/README.md"
authors = [
{ name = "ZutJoe", email = "zhout50@163.com" }
]
requires-python = ">=3.12"
dependencies = []
[project.optional-dependencies]
server = [
"fastapi>=0.115.0,<1.0.0",
"python-multipart>=0.0.20",
"uvicorn>=0.34.0,<1.0.0",
]
test = [
"httpx2>=0.1.0",
]
[project.scripts]
program-updater = "program_updater:main"
program-updater = "updater:main"
program-updater-helper = "updater.platforms.windows.helper:main"
[build-system]
requires = ["uv_build>=0.12.13,<0.13.0"]
build-backend = "uv_build"
[tool.uv.build-backend]
module-name = "updater"
module-root = "."
def main() -> None:
print("Hello from program-updater!")
# program-updater
面向 Windows、Python 3.12 和 PyInstaller `--onefile` 应用的更新客户端。基础安装无第三方运行时依赖。局域网分发管理后台是同一仓库中的独立项目,位于 `../distribution-server/`。
## 包结构
```text
updater/
├─ __init__.py # 稳定的公共导入入口
├─ core/
│ ├─ client.py # 检查、下载、校验和应用更新流程
│ ├─ manifest.py # manifest 校验
│ ├─ models.py # 更新信息模型
│ ├─ errors.py # 更新异常
│ ├─ files.py # 文件哈希等共享工具
│ └─ health.py # 新版本启动确认
└─ platforms/
└─ windows/
├─ helper.py # Windows exe 替换和回滚
└─ helper_entry.py
```
应用使用的公开入口是 `from updater import UpdateClient, confirm_update_started`;平台相关实现与通用更新逻辑分离。包名由 `program_updater` 改为 `updater`,集成旧包名的应用需要同步更新 import。
## 接入步骤
以下示例假设目标应用在 `self-strategy/`,updater 仓库在同一父目录下,
目标环境使用 Windows、Python 3.12 和 PyInstaller。
### 1. 构建并安装 wheel
在 updater 仓库根目录构建 wheel:
```powershell
cd C:\path\to\program-updater
uv build --wheel
```
将 wheel 安装到**目标应用用于 PyInstaller 的同一个 Python 环境**。例如在
`self-strategy/` 根目录执行:
```powershell
cd C:\path\to\self-strategy
python -m pip install ..\program-updater\dist\program_updater-261028.0.0-py3-none-any.whl
python -c "from updater import UpdateClient, confirm_update_started; print('updater installed')"
```
wheel 的发行包名是 `program-updater`,Python 导入名是 `updater`。基础安装没有
FastAPI 等 Web 依赖。
### 2. 构建 Windows helper
在同一个 Python 3.12 环境中确保已安装 PyInstaller(它是构建工具,不是 updater
运行时依赖),然后从 `self-strategy/` 根目录构建 helper:
```powershell
python -m PyInstaller --onefile --paths ..\program-updater --name UpdaterHelper ..\program-updater\updater\platforms\windows\helper_entry.py
```
构建结果为 `self-strategy\dist\UpdaterHelper.exe`。helper 是一个独立的小型 exe,
负责等待旧程序退出、备份并替换主 exe、启动新版以及在启动失败时回滚。
### 3. 将 helper 加入目标程序的 PyInstaller 配置
在目标程序的 `.spec` 中定义 helper 二进制及其在 onefile 包内的目标目录:
```python
UPDATER_HELPER = os.path.join(ROOT, "dist", "UpdaterHelper.exe")
updater_binaries = [
(UPDATER_HELPER, "updater/platforms/windows/bin"),
]
```
把 `updater_binaries` 传给 `Analysis`:
```python
a = Analysis(
...,
pathex=[ROOT],
binaries=updater_binaries,
...
)
```
如果使用 PyInstaller 命令行而非 `.spec`,对应写法是:
```powershell
pyinstaller --onefile --add-binary "dist\UpdaterHelper.exe;updater\platforms\windows\bin" --name MyProgram path\to\your_app.py
```
目标程序静态导入 `updater` 后,PyInstaller 会自动收集所需的 Python 模块;helper
exe 则必须按上述方式额外嵌入。
### 4. 在应用启动时检查更新
分发端为每个程序使用独立的 `app_id`,并提供如下清单:
```json
{
"app_id": "margin-guard",
"version": "1.2.0",
"url": "http://192.168.1.20:8000/packages/margin-guard/1.2.0/app.exe",
"size": 12345678,
"sha256": "填入 exe 的 64 位 SHA-256"
}
```
目标程序在启动早期、创建主窗口和初始化业务逻辑之前调用更新器。下面是 PyQt
程序的示例:
```python
import logging
import sys
from pathlib import Path
from PyQt5.QtWidgets import QApplication, QMessageBox
from updater import UpdateClient, UpdaterError, confirm_update_started
APP_ID = "margin-guard"
APP_VERSION = "1.1.0"
MANIFEST_URL = "http://192.168.1.20:8000/updates/margin-guard/latest.json"
logger = logging.getLogger(__name__)
def main() -> int:
app = QApplication(sys.argv)
if getattr(sys, "frozen", False):
bundle_dir = Path(getattr(sys, "_MEIPASS", Path(sys.executable).parent))
updater = UpdateClient(
app_id=APP_ID,
current_version=APP_VERSION,
manifest_url=MANIFEST_URL,
executable_path=sys.executable,
helper_resource=(
bundle_dir
/ "updater"
/ "platforms"
/ "windows"
/ "bin"
/ "UpdaterHelper.exe"
),
)
def confirm_update(update) -> bool:
answer = QMessageBox.question(
None,
"发现新版本",
f"发现版本 {update.version},现在更新吗?选择“否”可稍后更新。",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.Yes,
)
return answer == QMessageBox.Yes
try:
handed_off = updater.update_before_startup(
confirm_update=confirm_update,
restart_args=sys.argv[1:],
)
except UpdaterError:
logger.exception("自动更新失败,将启动当前版本")
handed_off = False
if handed_off:
return 0
# 放入目标应用原有的窗口、host 和业务初始化代码。
window = create_main_window()
window.show()
# 在程序完成关键初始化后确认更新成功;helper 收到此信号后不再回滚。
confirm_update_started()
return app.exec_()
if __name__ == "__main__":
raise SystemExit(main())
```
`update_before_startup()` 会同步检查清单。选择“否”时本次继续启动旧版,下次启动会再次提醒;选择“是”时才下载和校验 exe、启动 helper,并返回 `True`。目标进程随后退出,helper 替换 exe 并自动启动新版。新版启动检查时发现已是最新版本,就会继续启动主窗口。LAN 服务不可用或更新失败时,上例记录错误并继续启动旧版;强制更新的程序可以改为提示错误并停止启动。
`confirm_update()` 是目标应用提供的回调,所以 updater 本身不依赖 PyQt。`confirm_update_started()` 应在新版完成必要初始化后调用。helper 默认等待确认 45 秒;若新版提前退出或没有确认,会尝试恢复旧 exe。数据库迁移前应备份数据,exe 回滚本身不能撤销数据库迁移。portable 安装目录必须允许当前用户写入,配置文件和数据库应放在 exe 之外。
## 限制
- helper 只支持 Windows;分发管理后台可在 Linux 上运行。
- 更新包是完整 exe,不做差分更新。
- 没有发布者签名验证;分发服务器必须可信。没有 Authenticode 证书时,Windows 可能提示未知发布者。
"""Public client-side updater API."""
from .core.client import UpdateClient
from .core.errors import (
DownloadError,
ManifestError,
UpdateApplyError,
UpdateCheckError,
UpdaterError,
)
from .core.health import confirm_update_started
from .core.models import UpdateInfo
__all__ = [
"DownloadError",
"ManifestError",
"UpdateApplyError",
"UpdateCheckError",
"UpdateClient",
"UpdateInfo",
"UpdaterError",
"confirm_update_started",
]
def main() -> int:
from .platforms.windows.helper import main as helper_main
return helper_main()
"""Platform-independent updater core."""
"""Platform-independent update checking, downloading, and verification."""
import hashlib
import json
import os
import shutil
import subprocess
import tempfile
import urllib.error
import urllib.request
from collections.abc import Callable, Sequence
from pathlib import Path
from uuid import uuid4
from .errors import DownloadError, ManifestError, UpdateApplyError, UpdateCheckError
from .files import sha256_file
from .manifest import parse_manifest, validate_app_id, validate_url, version_key
from .models import UpdateInfo
_CHUNK_SIZE = 64 * 1024
_MAX_MANIFEST_SIZE = 1024 * 1024
_DEFAULT_MAX_ARTIFACT_SIZE = 2 * 1024 * 1024 * 1024
def _is_windows() -> bool:
return os.name == "nt"
class UpdateClient:
def __init__(
self,
*,
app_id: str,
current_version: str,
manifest_url: str,
executable_path: str | Path,
helper_resource: str | Path,
update_dir: str | Path | None = None,
timeout: float = 15.0,
max_artifact_size: int = _DEFAULT_MAX_ARTIFACT_SIZE,
) -> None:
self.app_id = validate_app_id(app_id)
version_key(current_version)
self.current_version = current_version
self.manifest_url = validate_url(manifest_url, field_name="manifest_url")
self.executable_path = Path(executable_path).resolve()
self.helper_resource = Path(helper_resource).resolve()
self.update_dir = (
Path(update_dir).resolve()
if update_dir is not None
else self.executable_path.parent / ".updates"
)
if timeout <= 0:
raise ValueError("timeout must be positive.")
if max_artifact_size <= 0:
raise ValueError("max_artifact_size must be positive.")
self.timeout = timeout
self.max_artifact_size = max_artifact_size
def check_for_update(self) -> UpdateInfo | None:
request = urllib.request.Request(
self.manifest_url,
headers={"Accept": "application/json"},
)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
raw = response.read(_MAX_MANIFEST_SIZE + 1)
except (OSError, urllib.error.URLError, TimeoutError) as exc:
raise UpdateCheckError(f"could not download update manifest: {exc}") from exc
if len(raw) > _MAX_MANIFEST_SIZE:
raise UpdateCheckError("update manifest exceeds the 1 MiB size limit.")
update = parse_manifest(raw, expected_app_id=self.app_id)
if update.size > self.max_artifact_size:
raise ManifestError(
f"update artifact is larger than the configured "
f"{self.max_artifact_size}-byte limit."
)
if version_key(update.version) <= version_key(self.current_version):
return None
return update
def update_before_startup(
self,
*,
confirm_update: Callable[[UpdateInfo], bool] | None = None,
restart_args: Sequence[str] = (),
on_progress: Callable[[int, int], None] | None = None,
health_timeout: float = 45.0,
) -> bool:
"""Check for an update and optionally ask the application before applying it.
Returns ``True`` after the helper starts; the calling process must then
exit so the helper can replace and relaunch the executable.
If ``confirm_update`` returns ``False``, the current launch continues
without applying the update. The caller owns all user-interface behavior.
"""
update = self.check_for_update()
if update is None:
return False
if confirm_update is not None and not confirm_update(update):
return False
staged_executable = self.download(update, on_progress=on_progress)
self.launch_update_helper(
update,
staged_executable,
restart_args=restart_args,
health_timeout=health_timeout,
)
return True
def download(
self,
update: UpdateInfo,
*,
on_progress: Callable[[int, int], None] | None = None,
) -> Path:
self._validate_update_info(update)
staging_dir = self.update_dir / "staging"
staging_dir.mkdir(parents=True, exist_ok=True)
final_path = staging_dir / f"{self.app_id}-{update.version}.exe"
if final_path.is_file():
try:
self._verify_artifact(final_path, update)
except DownloadError:
final_path.unlink()
else:
return final_path
temporary_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(
mode="wb",
prefix=f".{self.app_id}-",
suffix=".part",
dir=staging_dir,
delete=False,
) as output:
temporary_path = Path(output.name)
request = urllib.request.Request(update.url)
with urllib.request.urlopen(request, timeout=self.timeout) as response:
downloaded = 0
digest = hashlib.sha256()
while chunk := response.read(_CHUNK_SIZE):
downloaded += len(chunk)
if downloaded > update.size:
raise DownloadError(
"downloaded artifact is larger than the manifest size."
)
if downloaded > self.max_artifact_size:
raise DownloadError(
"downloaded artifact exceeds the configured size limit."
)
output.write(chunk)
digest.update(chunk)
if on_progress is not None:
on_progress(downloaded, update.size)
if temporary_path.stat().st_size != update.size:
raise DownloadError(
f"downloaded {temporary_path.stat().st_size} bytes, "
f"expected {update.size}."
)
if digest.hexdigest() != update.sha256:
raise DownloadError("downloaded artifact SHA-256 does not match the manifest.")
os.replace(temporary_path, final_path)
temporary_path = None
return final_path
except (OSError, urllib.error.URLError, TimeoutError) as exc:
raise DownloadError(f"could not download update artifact: {exc}") from exc
finally:
if temporary_path is not None:
temporary_path.unlink(missing_ok=True)
def launch_update_helper(
self,
update: UpdateInfo,
staged_executable: str | Path,
*,
restart_args: Sequence[str] = (),
health_timeout: float = 45.0,
) -> subprocess.Popen[bytes]:
self._validate_update_info(update)
if not _is_windows():
raise UpdateApplyError("the update helper is supported only on Windows.")
if not self.executable_path.is_file():
raise UpdateApplyError(
f"installed executable does not exist: {self.executable_path}"
)
if health_timeout <= 0:
raise ValueError("health_timeout must be positive.")
staged_path = Path(staged_executable).resolve()
staging_root = (self.update_dir / "staging").resolve()
if not staged_path.is_relative_to(staging_root) or not staged_path.is_file():
raise UpdateApplyError("staged executable must be inside the updater staging directory.")
self._verify_artifact(staged_path, update)
helper_path = self._prepare_helper()
backup_dir = self.update_dir / "backup"
backup_dir.mkdir(parents=True, exist_ok=True)
backup_path = backup_dir / f"{self.app_id}-{update.version}-{uuid4().hex}.bak"
log_path = self.update_dir / "update-helper.log"
arguments = [
str(helper_path),
"--parent-pid",
str(os.getpid()),
"--target",
str(self.executable_path),
"--staged",
str(staged_path),
"--backup",
str(backup_path),
"--sha256",
update.sha256,
"--size",
str(update.size),
"--health-timeout",
str(health_timeout),
"--restart-args",
json.dumps(list(restart_args)),
]
creation_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
try:
with log_path.open("ab") as log_file:
return subprocess.Popen(
arguments,
cwd=self.executable_path.parent,
stdin=subprocess.DEVNULL,
stdout=log_file,
stderr=subprocess.STDOUT,
close_fds=True,
creationflags=creation_flags,
)
except OSError as exc:
raise UpdateApplyError(f"could not start update helper: {exc}") from exc
def _prepare_helper(self) -> Path:
if not self.helper_resource.is_file():
raise UpdateApplyError(f"update helper resource was not found: {self.helper_resource}")
helper_dir = self.update_dir / "helpers"
helper_dir.mkdir(parents=True, exist_ok=True)
source_digest = sha256_file(self.helper_resource)
helper_path = helper_dir / f"updater-helper-{source_digest[:16]}.exe"
if helper_path.is_file() and sha256_file(helper_path) == source_digest:
return helper_path
temporary_path = helper_dir / f".{uuid4().hex}.tmp"
try:
shutil.copyfile(self.helper_resource, temporary_path)
if sha256_file(temporary_path) != source_digest:
raise UpdateApplyError("copied update helper does not match its source.")
os.replace(temporary_path, helper_path)
return helper_path
except OSError as exc:
raise UpdateApplyError(f"could not prepare update helper: {exc}") from exc
finally:
temporary_path.unlink(missing_ok=True)
def _validate_update_info(self, update: UpdateInfo) -> None:
if update.app_id != self.app_id:
raise ManifestError(
f"update app_id {update.app_id!r} does not match {self.app_id!r}."
)
version_key(update.version)
validate_url(update.url)
if type(update.size) is not int or not 0 < update.size <= self.max_artifact_size:
raise ManifestError("update size is outside the configured limit.")
if not isinstance(update.sha256, str) or len(update.sha256) != 64 or any(
character not in "0123456789abcdefABCDEF" for character in update.sha256
):
raise ManifestError("update sha256 must contain 64 hexadecimal characters.")
@staticmethod
def _verify_artifact(path: Path, update: UpdateInfo) -> None:
try:
actual_size = path.stat().st_size
actual_digest = sha256_file(path)
except OSError as exc:
raise DownloadError(f"could not read staged update {path}: {exc}") from exc
if actual_size != update.size:
raise DownloadError(
f"staged update has {actual_size} bytes, expected {update.size}."
)
if actual_digest != update.sha256.lower():
raise DownloadError("staged update SHA-256 does not match the manifest.")
"""Exception types raised by the updater core."""
class UpdaterError(Exception):
"""Base exception for updater failures."""
class ManifestError(UpdaterError):
"""The update manifest is invalid or does not match this application."""
class UpdateCheckError(UpdaterError):
"""The update manifest could not be retrieved."""
class DownloadError(UpdaterError):
"""The update artifact could not be downloaded or verified."""
class UpdateApplyError(UpdaterError):
"""The update helper could not be launched."""
"""File utilities shared by updater workflows."""
import hashlib
from pathlib import Path
_CHUNK_SIZE = 64 * 1024
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as file:
while chunk := file.read(_CHUNK_SIZE):
digest.update(chunk)
return digest.hexdigest()
"""Startup confirmation shared with the update helper's rollback check."""
import os
import sys
from pathlib import Path
from uuid import uuid4
from .errors import UpdateApplyError
_HEALTH_FILE_ENV = "PROGRAM_UPDATER_HEALTH_FILE"
_HEALTH_TOKEN_ENV = "PROGRAM_UPDATER_HEALTH_TOKEN"
def confirm_update_started() -> bool:
marker_value = os.environ.get(_HEALTH_FILE_ENV)
token = os.environ.get(_HEALTH_TOKEN_ENV)
if marker_value is None and token is None:
return False
if marker_value is None or token is None:
raise UpdateApplyError("incomplete update health-check environment.")
if len(token) != 32 or any(character not in "0123456789abcdef" for character in token):
raise UpdateApplyError("invalid update health-check token.")
marker_path = Path(marker_value).resolve()
install_root = Path(sys.executable).resolve().parent
health_root = (install_root / ".updates" / "health").resolve()
if not marker_path.is_relative_to(health_root):
raise UpdateApplyError("update health-check marker is outside the update directory.")
marker_path.parent.mkdir(parents=True, exist_ok=True)
temporary_path = marker_path.with_name(f".{marker_path.name}.{uuid4().hex}.tmp")
try:
temporary_path.write_text(token, encoding="ascii")
os.replace(temporary_path, marker_path)
finally:
temporary_path.unlink(missing_ok=True)
return True
"""Validation for manifests shared with the distribution service."""
import json
import re
from urllib.parse import urlsplit
from .errors import ManifestError
from .models import UpdateInfo
_APP_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
_VERSION_PATTERN = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$")
_SHA256_PATTERN = re.compile(r"^[A-Fa-f0-9]{64}$")
def validate_app_id(app_id: str) -> str:
if not isinstance(app_id, str) or not _APP_ID_PATTERN.fullmatch(app_id):
raise ManifestError("app_id must contain only letters, digits, '.', '_' or '-'.")
return app_id
def version_key(version: str) -> tuple[int, int, int]:
if not isinstance(version, str):
raise ManifestError("version must be a string in MAJOR.MINOR.PATCH format.")
match = _VERSION_PATTERN.fullmatch(version)
if match is None:
raise ManifestError(
f"unsupported version {version!r}; use MAJOR.MINOR.PATCH, for example 1.2.3."
)
return int(match.group(1)), int(match.group(2)), int(match.group(3))
def validate_url(url: str, *, field_name: str = "url") -> str:
if not isinstance(url, str):
raise ManifestError(f"{field_name} must be a string.")
parsed = urlsplit(url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ManifestError(f"{field_name} must be an absolute HTTP or HTTPS URL.")
if parsed.username is not None or parsed.password is not None:
raise ManifestError(f"{field_name} must not contain embedded credentials.")
return url
def parse_manifest(raw: bytes, *, expected_app_id: str) -> UpdateInfo:
try:
manifest = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ManifestError("manifest is not valid UTF-8 JSON.") from exc
if not isinstance(manifest, dict):
raise ManifestError("manifest must be a JSON object.")
app_id = validate_app_id(manifest.get("app_id"))
if app_id != expected_app_id:
raise ManifestError(
f"manifest app_id {app_id!r} does not match {expected_app_id!r}."
)
version = manifest.get("version")
version_key(version)
url = validate_url(manifest.get("url"))
size = manifest.get("size")
if type(size) is not int or size <= 0:
raise ManifestError("size must be a positive integer.")
sha256 = manifest.get("sha256")
if not isinstance(sha256, str) or not _SHA256_PATTERN.fullmatch(sha256):
raise ManifestError("sha256 must contain exactly 64 hexadecimal characters.")
return UpdateInfo(
app_id=app_id,
version=version,
url=url,
size=size,
sha256=sha256.lower(),
)
"""Data models used by the updater core."""
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class UpdateInfo:
app_id: str
version: str
url: str
size: int
sha256: str
"""Platform-specific updater implementations."""
"""Windows-specific updater implementation."""
"""Windows-only executable replacement and rollback helper."""
from __future__ import annotations
import argparse
import ctypes
import json
import logging
import os
import subprocess
import time
from collections.abc import Sequence
from ctypes import wintypes
from pathlib import Path
from uuid import uuid4
from ...core.errors import UpdaterError
from ...core.files import sha256_file
from ...core.health import _HEALTH_FILE_ENV, _HEALTH_TOKEN_ENV
_PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
_SYNCHRONIZE = 0x00100000
_WAIT_OBJECT_0 = 0
_WAIT_TIMEOUT = 0x00000102
_WAIT_FAILED = 0xFFFFFFFF
_ERROR_INVALID_PARAMETER = 87
_PARENT_WAIT_TIMEOUT_SECONDS = 120
class HelperError(UpdaterError):
"""An update helper operation failed."""
def _is_windows() -> bool:
return os.name == "nt"
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Apply a staged program update.")
parser.add_argument("--parent-pid", type=int, required=True)
parser.add_argument("--target", type=Path, required=True)
parser.add_argument("--staged", type=Path, required=True)
parser.add_argument("--backup", type=Path, required=True)
parser.add_argument("--sha256", required=True)
parser.add_argument("--size", type=int, required=True)
parser.add_argument("--health-timeout", type=float, required=True)
parser.add_argument("--restart-args", required=True)
args = parser.parse_args(argv)
root = args.target.resolve().parent
update_dir = root / ".updates"
update_dir.mkdir(parents=True, exist_ok=True)
logging.basicConfig(
filename=update_dir / "update-helper.log",
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
try:
restart_args = json.loads(args.restart_args)
if not isinstance(restart_args, list) or not all(
isinstance(argument, str) for argument in restart_args
):
raise HelperError("restart-args must be a JSON array of strings.")
_apply_update(
parent_pid=args.parent_pid,
target=args.target.resolve(),
staged=args.staged.resolve(),
backup=args.backup.resolve(),
expected_sha256=args.sha256,
expected_size=args.size,
health_timeout=args.health_timeout,
restart_args=restart_args,
)
return 0
except (HelperError, OSError, ValueError, json.JSONDecodeError) as exc:
logging.exception("update failed")
_show_error(str(exc))
return 1
def _apply_update(
*,
parent_pid: int,
target: Path,
staged: Path,
backup: Path,
expected_sha256: str,
expected_size: int,
health_timeout: float,
restart_args: list[str],
) -> None:
if not _is_windows():
raise HelperError("the update helper is supported only on Windows.")
if parent_pid <= 0 or health_timeout <= 0 or expected_size <= 0:
raise HelperError("invalid update helper arguments.")
if len(expected_sha256) != 64 or any(
character not in "0123456789abcdefABCDEF" for character in expected_sha256
):
raise HelperError("sha256 must contain 64 hexadecimal characters.")
root = target.parent
staging_root = (root / ".updates" / "staging").resolve()
backup_root = (root / ".updates" / "backup").resolve()
if not target.is_file() or not staged.is_file():
raise HelperError("the installed or staged executable does not exist.")
if not staged.is_relative_to(staging_root):
raise HelperError("staged executable is outside the updater staging directory.")
if not backup.is_relative_to(backup_root):
raise HelperError("backup executable is outside the updater backup directory.")
if backup.exists():
raise HelperError(f"backup path already exists: {backup}")
actual_size = staged.stat().st_size
actual_sha256 = sha256_file(staged)
if actual_size != expected_size or actual_sha256 != expected_sha256.lower():
raise HelperError("staged executable failed size or SHA-256 verification.")
backup.parent.mkdir(parents=True, exist_ok=True)
_wait_for_parent_exit(parent_pid, target)
_replace_file(target, staged, backup)
logging.info("installed staged executable at %s", target)
token = uuid4().hex
health_dir = root / ".updates" / "health"
health_dir.mkdir(parents=True, exist_ok=True)
marker = health_dir / f"{token}.ready"
environment = os.environ.copy()
environment.pop(_HEALTH_FILE_ENV, None)
environment.pop(_HEALTH_TOKEN_ENV, None)
environment[_HEALTH_FILE_ENV] = str(marker)
environment[_HEALTH_TOKEN_ENV] = token
child: subprocess.Popen[bytes] | None = None
try:
child = subprocess.Popen(
[str(target), *restart_args],
cwd=root,
env=environment,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
close_fds=True,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
_wait_for_health(child, marker, token, health_timeout)
except (OSError, UnicodeError, HelperError) as exc:
if child is not None:
_stop_process(child)
failed_path = backup.parent / f"{target.name}.{uuid4().hex}.failed"
try:
_replace_file(target, backup, failed_path)
except OSError as rollback_error:
raise HelperError(
f"new version failed to start ({exc}); rollback also failed: "
f"{rollback_error}. Previous executable is at {backup}."
) from rollback_error
raise HelperError(
f"updated application failed to start; the previous version was restored: {exc}"
) from exc
finally:
try:
marker.unlink(missing_ok=True)
except OSError:
logging.warning("could not remove health marker %s", marker, exc_info=True)
logging.info("new version confirmed; previous executable retained at %s", backup)
_remove_old_backups(backup.parent, backup)
def _wait_for_parent_exit(parent_pid: int, target: Path) -> None:
kernel32 = _kernel32()
handle = kernel32.OpenProcess(
_SYNCHRONIZE | _PROCESS_QUERY_LIMITED_INFORMATION,
False,
parent_pid,
)
if not handle:
error_code = ctypes.get_last_error()
if error_code == _ERROR_INVALID_PARAMETER:
return
raise OSError(error_code, "could not open the parent application process.")
try:
process_path = _process_image_path(kernel32, handle)
if os.path.normcase(os.path.abspath(process_path)) != os.path.normcase(
os.path.abspath(target)
):
raise HelperError("parent process does not match the target executable.")
wait_result = kernel32.WaitForSingleObject(
handle,
_PARENT_WAIT_TIMEOUT_SECONDS * 1000,
)
if wait_result == _WAIT_TIMEOUT:
raise HelperError("timed out waiting for the application to exit.")
if wait_result == _WAIT_FAILED:
raise ctypes.WinError(ctypes.get_last_error())
if wait_result != _WAIT_OBJECT_0:
raise HelperError(f"unexpected process wait result: {wait_result}.")
finally:
kernel32.CloseHandle(handle)
def _process_image_path(kernel32: ctypes.WinDLL, handle: int) -> str:
buffer = ctypes.create_unicode_buffer(32768)
length = wintypes.DWORD(len(buffer))
if not kernel32.QueryFullProcessImageNameW(handle, 0, buffer, ctypes.byref(length)):
raise ctypes.WinError(ctypes.get_last_error())
return buffer.value
def _replace_file(target: Path, replacement: Path, backup: Path) -> None:
kernel32 = _kernel32()
replace_file = kernel32.ReplaceFileW
if not replace_file(
str(target),
str(replacement),
str(backup),
0,
None,
None,
):
raise ctypes.WinError(ctypes.get_last_error())
def _wait_for_health(
child: subprocess.Popen[bytes],
marker: Path,
token: str,
timeout: float,
) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if marker.is_file():
try:
marker_token = marker.read_text(encoding="ascii")
except UnicodeError as exc:
raise HelperError("application wrote a malformed health-check marker.") from exc
if marker_token == token:
if child.poll() is not None:
raise HelperError("updated application exited immediately after startup.")
return
raise HelperError("application wrote an invalid health-check token.")
return_code = child.poll()
if return_code is not None:
raise HelperError(
f"updated application exited before health confirmation "
f"(exit code {return_code})."
)
time.sleep(0.1)
raise HelperError(f"updated application did not confirm startup within {timeout:g} seconds.")
def _stop_process(child: subprocess.Popen[bytes]) -> None:
if child.poll() is not None:
return
child.terminate()
try:
child.wait(timeout=5)
except subprocess.TimeoutExpired:
child.kill()
child.wait(timeout=5)
def _remove_old_backups(backup_dir: Path, current_backup: Path) -> None:
for path in backup_dir.glob("*.bak"):
if path == current_backup:
continue
try:
path.unlink()
except OSError:
logging.warning("could not remove older backup %s", path, exc_info=True)
def _kernel32() -> ctypes.WinDLL:
if not _is_windows():
raise HelperError("the update helper is supported only on Windows.")
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
kernel32.OpenProcess.restype = wintypes.HANDLE
kernel32.QueryFullProcessImageNameW.argtypes = [
wintypes.HANDLE,
wintypes.DWORD,
wintypes.LPWSTR,
ctypes.POINTER(wintypes.DWORD),
]
kernel32.QueryFullProcessImageNameW.restype = wintypes.BOOL
kernel32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
kernel32.WaitForSingleObject.restype = wintypes.DWORD
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
kernel32.CloseHandle.restype = wintypes.BOOL
kernel32.ReplaceFileW.argtypes = [
wintypes.LPCWSTR,
wintypes.LPCWSTR,
wintypes.LPCWSTR,
wintypes.DWORD,
wintypes.LPVOID,
wintypes.LPVOID,
]
kernel32.ReplaceFileW.restype = wintypes.BOOL
return kernel32
def _show_error(message: str) -> None:
if not _is_windows():
logging.error("%s", message)
return
user32 = ctypes.WinDLL("user32", use_last_error=True)
user32.MessageBoxW.argtypes = [
wintypes.HWND,
wintypes.LPCWSTR,
wintypes.LPCWSTR,
wintypes.UINT,
]
user32.MessageBoxW.restype = ctypes.c_int
user32.MessageBoxW(None, message, "Program update failed", 0x10)
"""PyInstaller entry point for the standalone Windows update helper."""
from updater.platforms.windows.helper import main
if __name__ == "__main__":
raise SystemExit(main())
"""Tests for the client-side updater."""
import hashlib
import io
import json
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
from updater import UpdateClient, UpdateInfo, confirm_update_started
from updater.core.errors import DownloadError, UpdateCheckError
class FakeResponse(io.BytesIO):
def __enter__(self) -> "FakeResponse":
return self
def __exit__(self, *_: object) -> None:
self.close()
class UpdateClientTests(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.root = Path(self.temp_dir.name)
self.executable = self.root / "MyProgram.exe"
self.executable.write_bytes(b"old executable")
self.helper = self.root / "UpdaterHelper.exe"
self.helper.write_bytes(b"helper")
def tearDown(self) -> None:
self.temp_dir.cleanup()
def client(self, *, current_version: str = "1.0.0") -> UpdateClient:
return UpdateClient(
app_id="my-program",
current_version=current_version,
manifest_url="http://127.0.0.1:8000/latest.json",
executable_path=self.executable,
helper_resource=self.helper,
)
def manifest(self, artifact: bytes, *, version: str = "1.1.0") -> bytes:
return json.dumps(
{
"app_id": "my-program",
"version": version,
"url": "http://127.0.0.1:8000/my-program.exe",
"size": len(artifact),
"sha256": hashlib.sha256(artifact).hexdigest(),
}
).encode("utf-8")
def test_check_and_download_update(self) -> None:
artifact = b"new executable"
responses = [
FakeResponse(self.manifest(artifact)),
FakeResponse(artifact),
]
progress: list[tuple[int, int]] = []
with patch(
"updater.core.client.urllib.request.urlopen",
side_effect=responses,
):
update = self.client().check_for_update()
self.assertIsNotNone(update)
assert update is not None
staged = self.client().download(
update,
on_progress=lambda downloaded, total: progress.append((downloaded, total)),
)
self.assertEqual(staged.read_bytes(), artifact)
self.assertEqual(progress[-1], (len(artifact), len(artifact)))
def test_check_returns_none_for_current_version(self) -> None:
artifact = b"same version"
with patch(
"updater.core.client.urllib.request.urlopen",
return_value=FakeResponse(self.manifest(artifact, version="1.0.0")),
):
self.assertIsNone(self.client().check_for_update())
def test_update_before_startup_returns_false_when_current(self) -> None:
client = self.client()
with (
patch.object(client, "check_for_update", return_value=None),
patch.object(client, "download") as download,
patch.object(client, "launch_update_helper") as launch_helper,
):
self.assertFalse(client.update_before_startup())
download.assert_not_called()
launch_helper.assert_not_called()
def test_update_before_startup_returns_false_when_user_defers(self) -> None:
client = self.client()
update = UpdateInfo(
app_id="my-program",
version="1.1.0",
url="http://127.0.0.1:8000/my-program.exe",
size=1,
sha256=hashlib.sha256(b"x").hexdigest(),
)
confirm_update = Mock(return_value=False)
with (
patch.object(client, "check_for_update", return_value=update),
patch.object(client, "download") as download,
patch.object(client, "launch_update_helper") as launch_helper,
):
self.assertFalse(
client.update_before_startup(confirm_update=confirm_update)
)
confirm_update.assert_called_once_with(update)
download.assert_not_called()
launch_helper.assert_not_called()
def test_update_before_startup_hands_off_to_helper(self) -> None:
client = self.client()
update = UpdateInfo(
app_id="my-program",
version="1.1.0",
url="http://127.0.0.1:8000/my-program.exe",
size=1,
sha256=hashlib.sha256(b"x").hexdigest(),
)
staged_executable = self.root / ".updates" / "staging" / "update.exe"
progress = Mock()
confirm_update = Mock(return_value=True)
restart_args = ("--safe-mode",)
with (
patch.object(client, "check_for_update", return_value=update),
patch.object(client, "download", return_value=staged_executable) as download,
patch.object(client, "launch_update_helper") as launch_helper,
):
self.assertTrue(
client.update_before_startup(
confirm_update=confirm_update,
restart_args=restart_args,
on_progress=progress,
health_timeout=60.0,
)
)
confirm_update.assert_called_once_with(update)
download.assert_called_once_with(update, on_progress=progress)
launch_helper.assert_called_once_with(
update,
staged_executable,
restart_args=restart_args,
health_timeout=60.0,
)
def test_update_before_startup_surfaces_check_errors(self) -> None:
client = self.client()
with patch.object(
client,
"check_for_update",
side_effect=UpdateCheckError("server unavailable"),
):
with self.assertRaisesRegex(UpdateCheckError, "server unavailable"):
client.update_before_startup()
def test_download_rejects_hash_mismatch_and_removes_partial_file(self) -> None:
expected = b"valid"
downloaded = b"wrong"
with patch(
"updater.core.client.urllib.request.urlopen",
return_value=FakeResponse(downloaded),
):
with self.assertRaises(DownloadError):
self.client().download(
UpdateInfo(
app_id="my-program",
version="1.1.0",
url="http://127.0.0.1:8000/my-program.exe",
size=len(expected),
sha256=hashlib.sha256(expected).hexdigest(),
)
)
staging_dir = self.root / ".updates" / "staging"
self.assertEqual(list(staging_dir.iterdir()), [])
def test_health_confirmation_is_noop_outside_an_update_restart(self) -> None:
with patch.dict(os.environ, {}, clear=True):
self.assertFalse(confirm_update_started())
def test_health_confirmation_writes_expected_token(self) -> None:
token = "a" * 32
marker = self.root / ".updates" / "health" / f"{token}.ready"
with (
patch("sys.executable", str(self.executable)),
patch.dict(
os.environ,
{
"PROGRAM_UPDATER_HEALTH_FILE": str(marker),
"PROGRAM_UPDATER_HEALTH_TOKEN": token,
},
clear=True,
),
):
self.assertTrue(confirm_update_started())
self.assertEqual(marker.read_text(encoding="ascii"), token)
if __name__ == "__main__":
unittest.main()
"""Tests for the updater manifest contract."""
import hashlib
import json
import unittest
from updater.core.errors import ManifestError
from updater.core.manifest import parse_manifest, version_key
class ManifestTests(unittest.TestCase):
def test_parse_valid_manifest_normalizes_sha256(self) -> None:
manifest = {
"app_id": "my-program",
"version": "1.2.3",
"url": "http://192.168.1.20/releases/app.exe",
"size": 42,
"sha256": "A" * 64,
}
update = parse_manifest(
json.dumps(manifest).encode("utf-8"),
expected_app_id="my-program",
)
self.assertEqual(update.app_id, "my-program")
self.assertEqual(update.version, "1.2.3")
self.assertEqual(update.sha256, "a" * 64)
self.assertEqual(update.size, 42)
def test_rejects_manifest_for_another_application(self) -> None:
manifest = {
"app_id": "other-app",
"version": "1.2.3",
"url": "http://192.168.1.20/app.exe",
"size": 42,
"sha256": hashlib.sha256(b"app").hexdigest(),
}
with self.assertRaises(ManifestError):
parse_manifest(
json.dumps(manifest).encode("utf-8"),
expected_app_id="my-program",
)
def test_rejects_non_http_urls(self) -> None:
manifest = {
"app_id": "my-program",
"version": "1.2.3",
"url": "file:///tmp/app.exe",
"size": 42,
"sha256": hashlib.sha256(b"app").hexdigest(),
}
with self.assertRaises(ManifestError):
parse_manifest(
json.dumps(manifest).encode("utf-8"),
expected_app_id="my-program",
)
def test_rejects_boolean_size(self) -> None:
manifest = {
"app_id": "my-program",
"version": "1.2.3",
"url": "http://192.168.1.20/app.exe",
"size": True,
"sha256": hashlib.sha256(b"app").hexdigest(),
}
with self.assertRaises(ManifestError):
parse_manifest(
json.dumps(manifest).encode("utf-8"),
expected_app_id="my-program",
)
def test_versions_compare_as_numeric_components(self) -> None:
self.assertGreater(version_key("1.10.0"), version_key("1.9.9"))
def test_rejects_prerelease_version_syntax(self) -> None:
with self.assertRaises(ManifestError):
version_key("1.2.3-rc.1")
if __name__ == "__main__":
unittest.main()
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "annotated-doc"
version = "0.0.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" },
]
[[package]]
name = "annotated-types"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
]
[[package]]
name = "anyio"
version = "4.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.15'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
]
[[package]]
name = "click"
version = "8.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
]
[[package]]
name = "fastapi"
version = "0.162.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "opentelemetry-api" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e9/eb/2cf8c80abf09b2800aef0a83b41066fbfda8ee5495b7a08b9d1af40f7046/fastapi-0.162.0.tar.gz", hash = "sha256:6a22c1e2e6e32e0633ee7e170f00009a29b8228b16feb219ee42f32fd8d88a8f", size = 527867, upload-time = "2026-10-23T18:47:33.264Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b4/4f/3f2c25c9b43970b5e9a08beb5e04776247edea684ddc0930f4f02f0bb6cb/fastapi-0.162.0-py3-none-any.whl", hash = "sha256:3785963de305dd09912426cb81f2d230fce98ab3521cdfe97265c8d14983128c", size = 148912, upload-time = "2026-10-23T18:47:31.734Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore2"
version = "2.16.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "h11" },
{ name = "truststore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/61/afa9d6b7452f9eca73f73d45324eb0bb52f098fd5e6049bf6b8263be9b4e/httpcore2-2.16.0.tar.gz", hash = "sha256:c72cd0413563b17e1cd8354677dff887466f8ec2551679759e93126534428643", size = 69886, upload-time = "2026-10-21T07:09:45.31Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/af/c2/ce417c397afa5181f0e3c6ff01a4d01639414e1adef08730d97571297151/httpcore2-2.16.0-py3-none-any.whl", hash = "sha256:5c4358319f2602d984ad93182c9fee5160f22e9da7babdda870a5e7c4e2dc34f", size = 85070, upload-time = "2026-10-21T07:09:42.802Z" },
]
[[package]]
name = "httpx2"
version = "2.16.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio", marker = "sys_platform != 'emscripten'" },
{ name = "httpcore2", marker = "sys_platform != 'emscripten'" },
{ name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" },
{ name = "idna" },
{ name = "truststore", marker = "sys_platform != 'emscripten'" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/73/5ca2cedf5e4e50423d1c258877865a6b62793633b561c58499f8aeb33b7e/httpx2-2.16.0.tar.gz", hash = "sha256:0d54c98128e8f2ff7fbbde0fca8c359e5f032edeaaaac443cdb7f31f685bcd7f", size = 103116, upload-time = "2026-10-21T07:09:46.348Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/3d/2e4bc4fd1ef9770871669adb348d612a6e4786b303a23f9935b81c48dd60/httpx2-2.16.0-py3-none-any.whl", hash = "sha256:fd2c5d7909e8d86fe92d53020c310ca0e4e431b838c207c5754b9bbf0308d49a", size = 97880, upload-time = "2026-10-21T07:09:44.01Z" },
]
[[package]]
name = "httpx2-jsfetch"
version = "1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" },
]
[[package]]
name = "idna"
version = "3.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
]
[[package]]
name = "opentelemetry-api"
version = "1.45.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" },
]
[[package]]
name = "program-updater"
version = "261028.0.0"
source = { editable = "." }
[package.optional-dependencies]
server = [
{ name = "fastapi" },
{ name = "python-multipart" },
{ name = "uvicorn" },
]
test = [
{ name = "httpx2" },
]
[package.metadata]
requires-dist = [
{ name = "fastapi", marker = "extra == 'server'", specifier = ">=0.115.0,<1.0.0" },
{ name = "httpx2", marker = "extra == 'test'", specifier = ">=0.1.0" },
{ name = "python-multipart", marker = "extra == 'server'", specifier = ">=0.0.20" },
{ name = "uvicorn", marker = "extra == 'server'", specifier = ">=0.34.0,<1.0.0" },
]
provides-extras = ["server", "test"]
[[package]]
name = "pydantic"
version = "2.14.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7c/0b/8e10b2e693af8ec54346a14caf36221334775975a747c9ceaa3f8371d96d/pydantic-2.14.1.tar.gz", hash = "sha256:94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26", size = 900362, upload-time = "2026-10-11T18:37:55.396Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl", hash = "sha256:9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454", size = 488106, upload-time = "2026-10-11T18:37:53.437Z" },
]
[[package]]
name = "pydantic-core"
version = "2.50.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a6/24/af4ec4be49fbc810f35b0bdcacc3d433b56bbfa469fd3bfd4ae116cd9bf1/pydantic_core-2.50.1.tar.gz", hash = "sha256:e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09", size = 513515, upload-time = "2026-10-11T18:35:44.82Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/94/101b49a6c63f99586755690696eae14532772587d9e39e93f7b8d28a49da/pydantic_core-2.50.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:704075d10b74f2f3c6e15407c696d88701df35fc8953f434a431add0d0074db0", size = 2222428, upload-time = "2026-10-11T18:32:20.228Z" },
{ url = "https://files.pythonhosted.org/packages/db/a3/f8b1b39349c33037c73d7a1b794c9c29d8f07d5bd2b74399c1375a529cc6/pydantic_core-2.50.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e8e1d6ce820aa23317e8209a86bd65a540973c12dc7552b48a4f6c8e9926815e", size = 2025779, upload-time = "2026-10-11T18:32:21.723Z" },
{ url = "https://files.pythonhosted.org/packages/d2/2d/862a7d115305cc621358e51ba13f296e0ffdf4fbd984be9f63bc7862730b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c18db21573bd2c6489f9a544b7499f0df2853958c568e5e783536ee1f690af41", size = 2048429, upload-time = "2026-10-11T18:32:23.3Z" },
{ url = "https://files.pythonhosted.org/packages/c0/7d/fed95d18bf2b5abe2d5ab2bbec4184bfc22b6805994a33ccdae8edffd25a/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb57f304525a5e3c13333b772bf9a473f36326e9c821b2e8e1b2fd36f80ae2c3", size = 2173274, upload-time = "2026-10-11T18:32:24.697Z" },
{ url = "https://files.pythonhosted.org/packages/93/1e/c6f22ceac65dc3d291ff79bea0eb74cbd1de8b128f01fee48af03bcea1e1/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a27c09d86600f1bf2fe3f37e1ae697faf3143931c09322cd799da94deee923b5", size = 2360403, upload-time = "2026-10-11T18:32:26.281Z" },
{ url = "https://files.pythonhosted.org/packages/2c/97/190b5b6bfe4c72f265feb657913eb30749193927a5ad7b83cfa7866a25a2/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:46b3301d3b5c886f77de7546e47274a5842c622ea2020b8c6524c6b66913b4a6", size = 2439799, upload-time = "2026-10-11T18:32:27.844Z" },
{ url = "https://files.pythonhosted.org/packages/90/24/48cd98388e4ac2c08d0800af36d15413db15e20387f1038123a2c86cb2f6/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93ba4e9d8210d941c200431a56b2c0400b131865947903937ed3ec5404307d2e", size = 2160157, upload-time = "2026-10-11T18:32:29.553Z" },
{ url = "https://files.pythonhosted.org/packages/00/86/d909133d0b9979ed14c20f110f21124a0e346caf071bd66ac5af3009062b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:e5faeaee74a57d32b3ab3aebad2e348f06d3ba946fc5d28c1728455f00a3d13a", size = 2229523, upload-time = "2026-10-11T18:32:31.027Z" },
{ url = "https://files.pythonhosted.org/packages/90/31/4b43f08131b2098e73a852fd932063385f3103b33839fd9d1abdb89e673d/pydantic_core-2.50.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3cda0e538208e5d722bbf3698b24f19c0a7d05bc8d5f8a7f9b121ea7fa243d9", size = 2242700, upload-time = "2026-10-11T18:32:32.622Z" },
{ url = "https://files.pythonhosted.org/packages/88/77/de39658569dc7a4c293d110a89629f61145e7538f00d1e4280c28debefd3/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:57f51b31ff826e2859120cf4737c5a758a48d96f3e97da40ccee1796d58078ff", size = 2278452, upload-time = "2026-10-11T18:32:34.165Z" },
{ url = "https://files.pythonhosted.org/packages/46/bd/1eeb9e4e791e81210ef9096f58080b32ba15b5b13c108d50aaa3f8ce9f73/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8daa7ee75245d43ad7d747e5c9ecc1b1d06552f72b14887e9276f787d57375f4", size = 2450098, upload-time = "2026-10-11T18:32:35.547Z" },
{ url = "https://files.pythonhosted.org/packages/1c/e6/b2accfdff17e5a88d6358536702146de00f9339adaa4d5bbe1ee93cb4832/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:acbf31f37c53a5ac0c34706c80b4f5107ba20b05fdd3816124bf236ef0c57dd2", size = 2476839, upload-time = "2026-10-11T18:32:37.119Z" },
{ url = "https://files.pythonhosted.org/packages/3b/7e/c8a83eb5d2cd42c2e13507150fb777b1edea5a018adc9cbf2858a1d08285/pydantic_core-2.50.1-cp312-cp312-win32.whl", hash = "sha256:45b11cac094aa25725581d9304eee93c9028516b9ea80dd9e175e13a5a2c840e", size = 2101425, upload-time = "2026-10-11T18:32:38.593Z" },
{ url = "https://files.pythonhosted.org/packages/c5/17/8bbd530b8659e9d963f5b16f6cb8e159f4cd174b4d6304e48969ab12dba8/pydantic_core-2.50.1-cp312-cp312-win_amd64.whl", hash = "sha256:132529c83901437ff642f585216831bf5fd7a91df66829907e155192ead62498", size = 2172522, upload-time = "2026-10-11T18:32:40.112Z" },
{ url = "https://files.pythonhosted.org/packages/af/9f/a79d690b127e4e3cff143d031538ab9aa715f6b2fbeecbd07cd6f2bf9fb7/pydantic_core-2.50.1-cp312-cp312-win_arm64.whl", hash = "sha256:4e834f6a8e4ff772dcc34f58ef5504147a3ea5b0f4eeb13b0f8eb2ca75ac57f1", size = 2161366, upload-time = "2026-10-11T18:32:41.606Z" },
{ url = "https://files.pythonhosted.org/packages/ae/57/0e237d7091d2cd44a35d243b7344227f90440166860469cd036afc23a04d/pydantic_core-2.50.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d5e062c01286d861fd6a1c4ff6e063547b3e713067f2df033c0ff97ac2ca006b", size = 2222774, upload-time = "2026-10-11T18:32:43.103Z" },
{ url = "https://files.pythonhosted.org/packages/f7/b9/c720e56858d4e1539503297ed37063e0c08e0f3541c41b777f6a800f75fa/pydantic_core-2.50.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c003c3b7f49debb893d2d85ae099ac5959c9839e2f330fadb1fcdf7a6594482", size = 2024738, upload-time = "2026-10-11T18:32:44.587Z" },
{ url = "https://files.pythonhosted.org/packages/4d/b8/fbfc25875219cc060e613170ff10e850c6d8924beb4910da57c2ee3ba1d2/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:409e0ea40ec30d9158f33574fd758e689f6045a0f2596701828c27816ca9687d", size = 2047049, upload-time = "2026-10-11T18:32:46.164Z" },
{ url = "https://files.pythonhosted.org/packages/d7/43/34210124d504c553688f2f04b48500b131237528ac545b445e0d6d30e0d5/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:131059670f1d2444269b8585cb888963994871932447c08b39ac6a51fcfef658", size = 2173415, upload-time = "2026-10-11T18:32:47.722Z" },
{ url = "https://files.pythonhosted.org/packages/65/cf/6e178e8fdc11da5965bef980983bf46326a42f436871dd51ba0a57f39df1/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dbcbee53bf17196a7f745aa9bf5a9603953a1e365b1f020be3207c676a3e7c4", size = 2361719, upload-time = "2026-10-11T18:32:49.217Z" },
{ url = "https://files.pythonhosted.org/packages/11/14/bd5169d356aa91bf777e28ba0b281c192d286d03c2812c9c9db023a2f00e/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:325c23f3e35cfbf0fe3486fa5f7260d1e45885173002d30a28ca019994124255", size = 2439803, upload-time = "2026-10-11T18:32:51.025Z" },
{ url = "https://files.pythonhosted.org/packages/1c/bc/d79d000e5203ebef39af839f2ce77a777fcad6e08ad26af9a2fcd114ffc8/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17e722e156d0444ecaefbe640bdb60928752bf2013e2b7a11cdb099aaae19bec", size = 2158402, upload-time = "2026-10-11T18:32:52.714Z" },
{ url = "https://files.pythonhosted.org/packages/1b/a5/4902cb5fd599422c130bd3124ab31ee8b771199bd56662702eed07d3fec7/pydantic_core-2.50.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:aa8224f10880d9bf1b5993988ba153d42a8b4f3f4f511f93b1f09c93ff613c72", size = 2229755, upload-time = "2026-10-11T18:32:54.126Z" },
{ url = "https://files.pythonhosted.org/packages/1a/f5/c1481f8669f6060d89110c9b1374173fc8767ca276b84f4870bd8acd5e3f/pydantic_core-2.50.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:41bc8237121bd8dc8d888dfd6279fc166ffc88c1f1bf3a8bf00869680533ca4c", size = 2242303, upload-time = "2026-10-11T18:32:55.641Z" },
{ url = "https://files.pythonhosted.org/packages/04/f9/77fc3c7653ba9b6e42049e17e96b25388187d4a7c274ab1cb07f58ac8419/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:45c6266d071c241f2a168d45bf8c54344f0effce35e7e6b73afdec11f3687568", size = 2278345, upload-time = "2026-10-11T18:32:57.38Z" },
{ url = "https://files.pythonhosted.org/packages/95/9b/0579c5d12e7f2b16b27e6782427987341fcce07b0725e02ddb0b74add0c4/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:1deeacb112d14d3f4fcb16b165f7dbaf76c70ba6e82f37ba042bdab51970a0b8", size = 2449947, upload-time = "2026-10-11T18:32:58.896Z" },
{ url = "https://files.pythonhosted.org/packages/a8/ac/1b677db91eba54cc5922f4de6edc46ba45c2bd712dfdc0130382d158e214/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1c96fd793b73d1b92e65570132505498fe7b21eaef73cdf74e67e5dfba7ac9e4", size = 2476889, upload-time = "2026-10-11T18:33:00.665Z" },
{ url = "https://files.pythonhosted.org/packages/4d/2a/3a9f6624ee3ea9ccba5249dde11418bb4c35780a7a92608f8768bd3fea39/pydantic_core-2.50.1-cp313-cp313-win32.whl", hash = "sha256:06ead20d39ffd6f2f6f2a8f8a6de67ff8bb1b4f14a8a30e058502514ee2ac685", size = 2101118, upload-time = "2026-10-11T18:33:02.329Z" },
{ url = "https://files.pythonhosted.org/packages/2d/1f/323f78ddd9d9938aac420c1abb4e8ba799fc8bdab0acc67c0593b837c979/pydantic_core-2.50.1-cp313-cp313-win_amd64.whl", hash = "sha256:7816e98acc08119dc0f340ab167048ecc54126316330c1f0caf7c6756c88e28f", size = 2170840, upload-time = "2026-10-11T18:33:03.919Z" },
{ url = "https://files.pythonhosted.org/packages/cb/09/8497c52a739ae425c3ac2f7f56414cbc711c67d374346174f40fe2062644/pydantic_core-2.50.1-cp313-cp313-win_arm64.whl", hash = "sha256:c17799a62c142d61b8a3c51752a7cbc87fe2ad4ccfab10e628a77b405075c662", size = 2161633, upload-time = "2026-10-11T18:33:05.518Z" },
{ url = "https://files.pythonhosted.org/packages/49/33/28b96e81677153715e3eafb9f26663a80841e859fde282a380359b0d3fa1/pydantic_core-2.50.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1cf41f1ae3fa155cf167a72689ad044bcc1e3c97e064123677149bdfb5dafc4a", size = 2224428, upload-time = "2026-10-11T18:33:07.153Z" },
{ url = "https://files.pythonhosted.org/packages/94/40/15c06410c9b7b8da5805d27b64e09bd3f900e986728106db2689dbc51513/pydantic_core-2.50.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4df197990c15b5a37c5a277d131d9f2c67de6133f2e5dafd80d9bba4b99f46f9", size = 2023740, upload-time = "2026-10-11T18:33:08.763Z" },
{ url = "https://files.pythonhosted.org/packages/a1/4e/5eb629f6efc2a27e421d789dd4bfacbb5f6d09c80c28b13d1e87d73163d5/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0036473f5583e6a60e50b8b21651511564277a3f05cc5dab8cf579f552cd5f6c", size = 2049435, upload-time = "2026-10-11T18:33:10.366Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8e/f195aebec49ad12318876ac2368c197a7939f5d52f8e156d2238ef8a4588/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:992c3514ec891fa7858099183e4d64e6bd5a5d4ff452fae29df22faa77a006bb", size = 2172975, upload-time = "2026-10-11T18:33:12.368Z" },
{ url = "https://files.pythonhosted.org/packages/e0/f5/7ad9fb83010cd5ea0948409db105676ed779c4e709e2d3d89b9fe2558794/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:739dc730e6be3bd5ec2f4ab5cfc7eb047cc45fc1497b3bafec74ff2ed07df597", size = 2364197, upload-time = "2026-10-11T18:33:14.244Z" },
{ url = "https://files.pythonhosted.org/packages/ca/fb/bf0aab3e78301d202b82a0322ec968cd703b11fc0623fde9a28708936f62/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32fad3a91e51b6d2039c572db04a5a873260b399f6bd62c3552671fa7a4a2899", size = 2444259, upload-time = "2026-10-11T18:33:15.79Z" },
{ url = "https://files.pythonhosted.org/packages/45/35/38f6d6564fae57d9b12e5676dfa947e0e7d5c46dbeaa7eb3352261d6d299/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42b54c2c90ad348b5e3a85e03e715d572c1fde357ef104cdfe3b03b697a404ea", size = 2159857, upload-time = "2026-10-11T18:33:17.51Z" },
{ url = "https://files.pythonhosted.org/packages/65/a0/fb0a3ca10f139dcf765b2d312d10cf0f65c57c59993229d00ad12b14ceff/pydantic_core-2.50.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:2df1ff41884de2bc4b307bafd7c40a691094fad2ff8e767e5b45a319257bcf4e", size = 2233954, upload-time = "2026-10-11T18:33:19.591Z" },
{ url = "https://files.pythonhosted.org/packages/8c/6a/e63842252702aa4ec6e6b7178ca85e541a77459076592c23ebe2d9840b33/pydantic_core-2.50.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe90228920fd8ff2be62622b6bb8a2b11acd65046d50c6b130614b5879605a20", size = 2243876, upload-time = "2026-10-11T18:33:21.393Z" },
{ url = "https://files.pythonhosted.org/packages/39/25/5991cf8318b37e0dfab47b87541619a1df8501a793cba0d978846cba37a7/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:844b869f118e22a41a091bdcedda8a71bc1b0f62c38d1a0c3211cece47e1d8fc", size = 2277908, upload-time = "2026-10-11T18:33:23.324Z" },
{ url = "https://files.pythonhosted.org/packages/a7/3e/3ee8baaa6cc25a6961c69168cf9ff0f002d56f4e0d541ad6724c18fb61f3/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:2eb75304506894a281d346220a4f7481a1b8729577c5ed2a05395991966a8396", size = 2449285, upload-time = "2026-10-11T18:33:24.942Z" },
{ url = "https://files.pythonhosted.org/packages/c0/c7/acbec6deac13fe697a80c275a9b6661db62c4d323bac8a47347b1f39c7cd/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6b20a4bffabdad0db2927ac034ae3b8a681b1f7a0182f3e60b479ad2fde21ebb", size = 2479013, upload-time = "2026-10-11T18:33:26.925Z" },
{ url = "https://files.pythonhosted.org/packages/10/08/21a3f237b264389f6219d053ee78fc1b5c2fd402c1b1cb2b6cb8b85f9834/pydantic_core-2.50.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:99ba9bc2b8062ea0c326a990f7f00e6530c23579de66dd246e72c4cafef950a5", size = 1484400, upload-time = "2026-10-11T18:33:28.693Z" },
{ url = "https://files.pythonhosted.org/packages/09/ce/077a6d262d12ef09108377ac0717f029f420d773ace63cafd6143af75568/pydantic_core-2.50.1-cp314-cp314-win32.whl", hash = "sha256:cf356f70551d40374eaffb1aa63f1eb6d2006681cbd7a9faea173ce0f4dd7cd2", size = 2103222, upload-time = "2026-10-11T18:33:30.326Z" },
{ url = "https://files.pythonhosted.org/packages/14/4c/350a2415209c43d670eb71d3c040f332c04a30583d39e311b05c7ac15762/pydantic_core-2.50.1-cp314-cp314-win_amd64.whl", hash = "sha256:d32f3acc081cc3923386d88f422cde8892335e95f034e0104bb4cf9310d9915f", size = 2171586, upload-time = "2026-10-11T18:33:32.139Z" },
{ url = "https://files.pythonhosted.org/packages/bf/92/9bea6ca96580a0902fed366f064f0829e404f41889b34543279a1162b888/pydantic_core-2.50.1-cp314-cp314-win_arm64.whl", hash = "sha256:bed5163e03b98bc1fa2eb05d74c63d9c5c95d8ed6254985481640fbf5e237dea", size = 2155773, upload-time = "2026-10-11T18:33:33.896Z" },
{ url = "https://files.pythonhosted.org/packages/86/8c/f121f073cf32bdd5cba7e6230b1ce0ac845b0cf43e44dd597d95af272db2/pydantic_core-2.50.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:9572c1369e9c9da2d64a7b7992c786d90ff295abc93964cfe3125e4290768070", size = 2234017, upload-time = "2026-10-11T18:33:35.588Z" },
{ url = "https://files.pythonhosted.org/packages/a4/69/2bb2bcd6146cffe98d760a46c42ae71efd5151d9b2f9c9bf6619a3b32083/pydantic_core-2.50.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2005207aafe1231315718bf6ed5d064a7300fb4772754af35ee72fc68159492e", size = 2001016, upload-time = "2026-10-11T18:33:37.57Z" },
{ url = "https://files.pythonhosted.org/packages/20/b3/fbf854c7d07ec114260c26e9e2071a4381740f9ae09641dbfcbdf2a18c45/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64f6047f62a6c5ae08d0a6afb035667aa2d97c3d20d69762e034c5ea144d92a5", size = 2023015, upload-time = "2026-10-11T18:33:39.433Z" },
{ url = "https://files.pythonhosted.org/packages/65/20/6de55b2f92cdb614b745c6e9ced639fc4fd7e1e77604825a88bdece6fcbd/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ef800dd7d85bcdadf4c3076e4c94e43939493558a3b69a1ea830c706d4617bb", size = 2192543, upload-time = "2026-10-11T18:33:41.381Z" },
{ url = "https://files.pythonhosted.org/packages/0c/d9/19e91c94bd5c405945ce2f15526808aea37e162c253160de8ed7bf70b406/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0135bcdcaa0f23573f286e4cb5e0fd2962700964ed13df085b85f2b97aeab9e", size = 2388704, upload-time = "2026-10-11T18:33:43.167Z" },
{ url = "https://files.pythonhosted.org/packages/b6/bc/2e24c8415eae1a25ee5a5484946a917123bad2e9d01689a759236928175a/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b3a6f334c6a2345ca15318ff894502a90012536404b37c844a976c76c846e0b", size = 2464118, upload-time = "2026-10-11T18:33:44.856Z" },
{ url = "https://files.pythonhosted.org/packages/bd/1f/945b8053cb061c64e102bcaf7bfb9ed740c0bd4349f9bb978a7d4ddff4ab/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06e01fbbfdb9be777b316a71b6c49efaf4a08b615d0a98d678cda3023f79d019", size = 2150142, upload-time = "2026-10-11T18:33:46.661Z" },
{ url = "https://files.pythonhosted.org/packages/2b/78/96a3e50bf0d64aaae781107eb9335b7529d05a85793ed6e7241c4cd1d931/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a29a061fec0b4e2d714f277e70a3a18125ecff803f2fea6eade2f2e53711d112", size = 2245577, upload-time = "2026-10-11T18:33:48.574Z" },
{ url = "https://files.pythonhosted.org/packages/7a/5d/a6038a0322232758a6ebfa709f4ca14a60bf5b93940cd0b345056a557da4/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f5187624823423e1d1b82b1072ac41dc837389e18d3d0572cc19bbee46cd550a", size = 2263728, upload-time = "2026-10-11T18:33:50.527Z" },
{ url = "https://files.pythonhosted.org/packages/0d/4b/76ded3333a457a9344c4f2d63f2d82d0101654b05178fa4ddfe7d3627674/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3e46a9eb0a0901dd6275e6b06ac3a464885ef350ec4121fe486869de8053e4bb", size = 2293630, upload-time = "2026-10-11T18:33:52.362Z" },
{ url = "https://files.pythonhosted.org/packages/a9/00/9eca378335c9c1b72bc779bf6e4c2a82ce784f14ec48a0e87102c9870e05/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:756d669f04e62ec4148ecfe22be6a4484d9b1181a6ef32e205ebfd200540858b", size = 2468048, upload-time = "2026-10-11T18:33:54.118Z" },
{ url = "https://files.pythonhosted.org/packages/35/ca/e3832e9cf93651251de43c8c5c029ed680a3c1a0a1b17ee26c81bed08cbd/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:c516cc5367ca3448995d42cb994bf3f4c9002d2a7c22eac9622551269ad1b807", size = 2512159, upload-time = "2026-10-11T18:33:55.99Z" },
{ url = "https://files.pythonhosted.org/packages/4b/f2/773469b5a10a39116a2cb17edaf6d722f017dd8da07161b371465311c542/pydantic_core-2.50.1-cp314-cp314t-win32.whl", hash = "sha256:9d1bed94af6a63835461f3cf7502058eb166c58c4778e11d0f433cfb1bd69e19", size = 2098406, upload-time = "2026-10-11T18:33:58.043Z" },
{ url = "https://files.pythonhosted.org/packages/ee/42/0bb74f8f25204b259b11ab7c12dc7f180893b6bd706118b385147fb6efd5/pydantic_core-2.50.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c8dce1f1e0e5358b682a6ad3fa5e31b31d4560997b8e61417e9217c8d60f8a0c", size = 2148481, upload-time = "2026-10-11T18:33:59.913Z" },
{ url = "https://files.pythonhosted.org/packages/47/0d/d801646c9679a4e630e15cf521d4108b93854b42a6e6e02391bd4c6b1095/pydantic_core-2.50.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ceff0acc940be2715bd6ad17b24c0e5304abf44f6efd0f81ee8499e640f9dc86", size = 2174972, upload-time = "2026-10-11T18:34:01.875Z" },
{ url = "https://files.pythonhosted.org/packages/b2/84/23984b763d8862a02a13d27a44b6e8169428fd85ecfed88f54c29108604d/pydantic_core-2.50.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:8a6791afa2245e6c6b180122d105941644f5bd410bb18623b408808cc41a3102", size = 2224599, upload-time = "2026-10-11T18:34:04.016Z" },
{ url = "https://files.pythonhosted.org/packages/4d/90/a63cf8586abc1d1a3f6d9b18f0224789ebf003851f092ebda3c7c863fd32/pydantic_core-2.50.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:84f34323a61a365b4e9295de6028474754829aaddd59c7bf1a040e7487ef8f3c", size = 2024191, upload-time = "2026-10-11T18:34:05.901Z" },
{ url = "https://files.pythonhosted.org/packages/a6/6a/f34bff9808ffb4907fbf5f5040457d253cacf2da7fce9efbc99ca1b1a44d/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23edad659e8dbd8ca7e4e877fe6c81573abbdf215bd25a68b53e1272f58b80c7", size = 2048252, upload-time = "2026-10-11T18:34:07.757Z" },
{ url = "https://files.pythonhosted.org/packages/b2/54/13f419bf1eb59852003818e25935aaf175687d978f4c4bca70e08fe40a3b/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a5fce22f1e87d181e924e12da7d81cfe031fb3881a5ddf26ad28f141756ca43", size = 2173376, upload-time = "2026-10-11T18:34:09.592Z" },
{ url = "https://files.pythonhosted.org/packages/6d/6c/b5a34d24cd0c81669d8f8339d74e6815abcf2f8fb48ab4b49b84c09be1d5/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c73622ef819328873b53109ee4f77ceb598bffedd02daf916102be3228866b78", size = 2364559, upload-time = "2026-10-11T18:34:11.534Z" },
{ url = "https://files.pythonhosted.org/packages/eb/8d/d64d6216a8df365082665927ff923f183056f9049fee08e9777c9ac05296/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce8c25ca38cc0e3d7753ba180808de2c0c8cb24eae0df64491e40921454e9831", size = 2444697, upload-time = "2026-10-11T18:34:13.535Z" },
{ url = "https://files.pythonhosted.org/packages/b8/0d/1b1149f60a00ea21ba5f70e28acbd40feb4598af414f80f53c921fac07c9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7689580e72a642ab5ec64d5f55b2e33636fa43b4ebe63c0c2c965ef307c7d1aa", size = 2159961, upload-time = "2026-10-11T18:34:15.56Z" },
{ url = "https://files.pythonhosted.org/packages/1e/35/f236549299dcc78e71e945495d7ec67e78d20844d0999c60601685003031/pydantic_core-2.50.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:d5c0e32fdbce7f1e8ef4d11f655694bf5f4175c757a9f1dc2be09b8864e5bcf5", size = 2234581, upload-time = "2026-10-11T18:34:17.502Z" },
{ url = "https://files.pythonhosted.org/packages/6a/85/26901a490522b7f75ef9bb9a7afb73e5bb550f0e1cb5b99a0069183e2eb9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:40f523349960fa30f3ea51404308ff50f9997a90df639590f47a057c1f32b415", size = 2244090, upload-time = "2026-10-11T18:34:19.402Z" },
{ url = "https://files.pythonhosted.org/packages/ca/2c/481bcfc70ceeb77a778ca6e5b705fe592cb64735c108157f81b7dd9c280e/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:d4193206b6587047437f6f11d7e776df23e1c1e23af2a54d9347275614791e10", size = 2277948, upload-time = "2026-10-11T18:34:21.317Z" },
{ url = "https://files.pythonhosted.org/packages/24/eb/f1e09333faa7ba447cde967310f758430cc7c5e987bdab5228816c70030d/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:84bc765b282a9d5b7fe0348b8648904f25a6a04b2139da52b1dd30c8ac3a2c8f", size = 2449688, upload-time = "2026-10-11T18:34:23.321Z" },
{ url = "https://files.pythonhosted.org/packages/d1/b3/036bde636db8f76d92996e81aefc75678ab5cec4a07eea1ad0c72a893fc3/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:ed1e728b39a383c81035b2459cfcb35d99dfb01f7d6ebe3a913bc1cc5b81e459", size = 2479286, upload-time = "2026-10-11T18:34:25.214Z" },
{ url = "https://files.pythonhosted.org/packages/d0/a3/07f018294ee18d144afeb6df1a47d9e92960f014be45c5ed13519fa5af95/pydantic_core-2.50.1-cp315-cp315-win32.whl", hash = "sha256:bc94f474417604bd383d2cd445d071b07dd55fedceed3ce33407bf1fcc107290", size = 2103683, upload-time = "2026-10-11T18:34:27.42Z" },
{ url = "https://files.pythonhosted.org/packages/5f/98/f9bd7e1f9b6709f155acb9ef826d9c3884fe925f811fd8e55b9b52280bad/pydantic_core-2.50.1-cp315-cp315-win_amd64.whl", hash = "sha256:983a662de2571cb2502fc8ff47b6770b03d025d2eb314c92f77b3f07c74720ed", size = 2171681, upload-time = "2026-10-11T18:34:29.508Z" },
{ url = "https://files.pythonhosted.org/packages/10/87/4bb3e1e7f385c076ab5af4d6dd0571b22042cd8207eb810d5b9fef15ae31/pydantic_core-2.50.1-cp315-cp315-win_arm64.whl", hash = "sha256:94845ff54dc5193f228cab81b2662a04bfbb892e95bdc15edf7399000ce57d54", size = 2156251, upload-time = "2026-10-11T18:34:31.455Z" },
{ url = "https://files.pythonhosted.org/packages/47/47/83643225b08f2aef6c8cc4bbe6e3f79c5e139c4364a6e450d0f399d87774/pydantic_core-2.50.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:4a53d13cdfbedbfa87f08b83c1a0a5efcc767d785a4b41934fa9cb672670493a", size = 2234363, upload-time = "2026-10-11T18:34:33.65Z" },
{ url = "https://files.pythonhosted.org/packages/5e/66/127ca649ba2f2462039dc1e694c6c01e00a3689f023a617364d3507e6d97/pydantic_core-2.50.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:efbecf43d321f7b9281441f1f213f7c21c66988b0e06c2730ba13ed47a46bb08", size = 2000972, upload-time = "2026-10-11T18:34:36.037Z" },
{ url = "https://files.pythonhosted.org/packages/5e/4f/e421e0a5d653b1203b090b2e48745976988d7338a647940704b5b9c2b399/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc1f08f68dac9f9e83845a8039880aba2ab553eb9b2259c3243a313182c253fe", size = 2022925, upload-time = "2026-10-11T18:34:37.972Z" },
{ url = "https://files.pythonhosted.org/packages/d5/4e/ea5568e2491e1a71100f15ae8c2d01ef52db42a184a2d4e716bc79e5eb8f/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5dfe41f232befddb9c4377f6cfc702b51595e2d78ed082672adf8758d2c4619f", size = 2192867, upload-time = "2026-10-11T18:34:39.921Z" },
{ url = "https://files.pythonhosted.org/packages/ae/5e/3b8c3a35acbe219909ada5defad5d7d9fed845fb2b37bd5ec518f453c119/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adc06d218a1cadfd2ec4628424d7d79ce4eba69c2965e7e7b55106f0da5208c8", size = 2388687, upload-time = "2026-10-11T18:34:42.184Z" },
{ url = "https://files.pythonhosted.org/packages/b6/aa/7889b4e515f91a2e8c0ae6b5081fec0feb30c4398d1434e14793c60f173a/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2cf91809d0721ab81592ba67bea7694821679c10b1a2e3c3460082b286c1918a", size = 2464415, upload-time = "2026-10-11T18:34:44.384Z" },
{ url = "https://files.pythonhosted.org/packages/08/78/93449e628eb8a6fdcce3eff9043081179d1bc7ce6f1bff32dc5006f41e00/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23923ab9292c40da026330b1ecf4dc2618c8e86e0422e5d1fbf50d94d64ca4f8", size = 2150823, upload-time = "2026-10-11T18:34:46.392Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f1/72c5bc129fceb0d00f05dc1e67f518c1728de1928c55f81fc13d7690de39/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:f3377c8c2b3ce898423c5e5dd94c7982e30aa7717a7e6ab2470b9de364963709", size = 2245806, upload-time = "2026-10-11T18:34:48.805Z" },
{ url = "https://files.pythonhosted.org/packages/28/2a/922a0e78f3aa6ab837b59f88190233fb8dad546c17995fde9bb3ed3b9b49/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:455a773617b5913bf5c20d0692e5787b119e52c4d40ea644ca31f5758fd31be2", size = 2264160, upload-time = "2026-10-11T18:34:50.876Z" },
{ url = "https://files.pythonhosted.org/packages/52/a8/0f1449e3e1b20941c9372faa18e7b6a092e30cdfa841902473f7989532ac/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:1a9006395dece0e32e704c315eff8a00bede494f6108546cfc5539c89fef4f9a", size = 2294031, upload-time = "2026-10-11T18:34:52.876Z" },
{ url = "https://files.pythonhosted.org/packages/d1/d0/1031f492857de70355fb16524bbb03efce5fd34c93ed4a1ec60be07e0d4b/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d2d82aa62521c55ddfb000ae70f88cdd8de974078f6024e821dfe5addd0c818f", size = 2468423, upload-time = "2026-10-11T18:34:54.995Z" },
{ url = "https://files.pythonhosted.org/packages/46/52/269ffffa645b8e47906395a39cf9db1151ae9fa4bcd7f47b960bc31baf37/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:009634b83993777ddcd69cad0ffcace43dabde692109528e35f0fde91e386a8b", size = 2512598, upload-time = "2026-10-11T18:34:57.149Z" },
{ url = "https://files.pythonhosted.org/packages/31/5c/e47e28281f20326ff6f3c31d626f0a83e615d2d94ba41bb0ad6ec237184a/pydantic_core-2.50.1-cp315-cp315t-win32.whl", hash = "sha256:3fde4fdc6487a58d944ca87cf5adc95d5f266e872c19599f5f4c0a8a1b1f9f9f", size = 2098535, upload-time = "2026-10-11T18:34:59.313Z" },
{ url = "https://files.pythonhosted.org/packages/03/ad/759e181e69c1b472c60b2049e5f61d5da1deefdd5ce1df4bb2ebcf771f25/pydantic_core-2.50.1-cp315-cp315t-win_amd64.whl", hash = "sha256:1c8632d4ac04e6f91128fca584b3a8a507d81604c24eeaaad00d4be42765c32b", size = 2148645, upload-time = "2026-10-11T18:35:01.571Z" },
{ url = "https://files.pythonhosted.org/packages/6d/56/8a702c27e5be9f47e5f19d8669227424290e4c024e7c370279cbaf244b4e/pydantic_core-2.50.1-cp315-cp315t-win_arm64.whl", hash = "sha256:c3ede305158e75510be50869b319550ab072008c13d64d4ab1e094fb286b6f44", size = 2175297, upload-time = "2026-10-11T18:35:04.079Z" },
{ url = "https://files.pythonhosted.org/packages/a5/09/1bcf160f3cd6333e2a76982fae5963e5032de0bcaaba38464223dc266bfe/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:76e2e83fa6ec8cdc972d438dafc2522b3a47bee4ec0ae668b29cfb1977ab5242", size = 2217070, upload-time = "2026-10-11T18:35:15.688Z" },
{ url = "https://files.pythonhosted.org/packages/3d/81/f02de95eec7794dfc7a5bc69a834f0e834b31b3009014ce6de903855f775/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:a51eee75939cf811ac09b278745a6cee7dc873ccfbc8b9af3cc88fe4b7ce25b5", size = 2039027, upload-time = "2026-10-11T18:35:18.07Z" },
{ url = "https://files.pythonhosted.org/packages/4d/16/227746ed3771d9bf2304568b55134849ece34e13b38b1d76de763015c77f/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae28183297fb0d2b8dc46a1f01d51f5e45825fc5afe76a835a6cb7fb34821295", size = 2109009, upload-time = "2026-10-11T18:35:20.218Z" },
{ url = "https://files.pythonhosted.org/packages/e5/b9/7664d1592a0e5a74903f357cd5b28ceb7336f3473256c5a4e8c8432f1881/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88e492e8b9d0312e7dc13667c30222abf284dc3b79b5302b3607b41a5784ce61", size = 2269883, upload-time = "2026-10-11T18:35:22.48Z" },
]
[[package]]
name = "python-multipart"
version = "0.0.32"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
]
[[package]]
name = "starlette"
version = "1.10.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/48/bb/3d8757f21e9a53d9035c9eb039a777047139a16bfdbedfc7a58241c3d914/starlette-1.10.0.tar.gz", hash = "sha256:b8399098d4c1e3522734f7235dc79b1fc967f96cf139c81e0fa30479e0685f9b", size = 2734356, upload-time = "2026-10-24T15:04:12.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/2e/947cfe02def9371c07cd5bf5b6b61c829e9aecb9904db02a66cfafbc1eec/starlette-1.10.0-py3-none-any.whl", hash = "sha256:478c0ab3b4ced6e82719849c118fa406f0278a3499c1a898cd15cd31bdc38ce4", size = 80571, upload-time = "2026-10-24T15:04:10.371Z" },
]
[[package]]
name = "truststore"
version = "0.10.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/9f/c5201d42a484c061e528825fc8e2d565f5abd50a4ced6fb7d29c4ec99b2b/truststore-0.10.5.tar.gz", hash = "sha256:30d36967ccaded5cbb38d602c433f53600036c79d502f4533a49b60a03bbefcd", size = 28091, upload-time = "2026-10-12T22:27:31.808Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/e9/3a7820be2bb0fe53b6bc9c3be26d3d1158004e4c3ab953aa6840b955b1e9/truststore-0.10.5-py3-none-any.whl", hash = "sha256:9aaaedaefaf06d8b206278cf8b5012bc897f485a874503501e12d776df78951c", size = 19017, upload-time = "2026-10-12T22:27:30.377Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
]
[[package]]
name = "uvicorn"
version = "0.54.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" },
]
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment