feat: cli support for server ota (#286)

* feat: ota release

* chore: clean-up old binaries

* fix: ota cli sub-commands, path fixes

* chore: browseros server binary update

* fix: add sparkle sign_update path as ENV

* fix: CLOUDFLARE_API_TOKEN to env

* fix: use same upload r2 module

* feat: upload appcast is separate

* feat: write sparkle sign in python

* fix: handle appcast update

* fix: add missing sparkle.py file

* fix: remove redudant cli options in ota

* chore: 0.0.37 macos signed release

* chore: linux browseros server ota

* fix: copy binaries to temp file and then sign
This commit is contained in:
Nikhil
2026-01-06 13:51:18 -08:00
committed by GitHub
parent 3c99963eed
commit 90512e58df
14 changed files with 1400 additions and 130 deletions

View File

@@ -16,6 +16,7 @@ PROD_MACOS_NOTARIZATION_PWD=
R2_ACCOUNT_ID=
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
CLOUDFLARE_API_TOKEN=
R2_BUCKET=browseros
R2_CDN_BASE_URL=http://cdn.browseros.com
@@ -24,6 +25,8 @@ SLACK_WEBHOOK_URL=
# Sparkle Signing (macOS only)
SPARKLE_PRIVATE_KEY=
# Path to sign_update tool
SPARKLE_SIGN_UPDATE_PATH=
# Optional
# CHROMIUM_SRC=C:/src/chromium/src

View File

@@ -40,6 +40,10 @@ app.add_typer(dev.app, name="dev", help="Dev patch management")
from .cli import release
app.add_typer(release.app, name="release", help="Release automation")
# OTA update commands
from .cli import ota
app.add_typer(ota.app, name="ota", help="OTA update automation")
if __name__ == "__main__":
app()

View File

@@ -0,0 +1,248 @@
#!/usr/bin/env python3
"""OTA CLI - Over-The-Air update automation for BrowserOS"""
from pathlib import Path
from typing import Optional
import typer
from ..common.context import Context
from ..common.env import EnvConfig
from ..common.module import ValidationError
from ..common.sparkle import sparkle_sign_file
from ..common.utils import log_info, log_error, log_success
from ..modules.ota import ServerOTAModule
from ..modules.ota.common import (
get_appcast_path,
SERVER_PLATFORMS,
)
from ..modules.upload import get_r2_client, upload_file_to_r2
app = typer.Typer(
help="OTA (Over-The-Air) update automation",
pretty_exceptions_enable=False,
pretty_exceptions_show_locals=False,
)
server_app = typer.Typer(
help="BrowserOS Server OTA commands",
pretty_exceptions_enable=False,
pretty_exceptions_show_locals=False,
)
app.add_typer(server_app, name="server")
def create_ota_context() -> Context:
"""Create Context for OTA operations"""
return Context(
chromium_src=Path(),
architecture="",
build_type="release",
)
def execute_module(ctx: Context, module) -> None:
"""Execute a single module with validation"""
try:
module.validate(ctx)
module.execute(ctx)
except ValidationError as e:
log_error(f"Validation failed: {e}")
raise typer.Exit(1)
except Exception as e:
log_error(f"Module failed: {e}")
raise typer.Exit(1)
@server_app.command("release")
def server_release(
version: str = typer.Option(
..., "--version", "-v", help="Version to release (e.g., 0.0.36)"
),
channel: str = typer.Option(
"alpha", "--channel", "-c", help="Release channel: alpha or prod"
),
binaries: Optional[Path] = typer.Option(
None, "--binaries", "-b", help="Directory containing server binaries"
),
platform: Optional[str] = typer.Option(
None, "--platform", "-p",
help="Platform(s) to process, comma-separated (darwin_arm64, darwin_x64, linux_arm64, linux_x64, windows_x64)"
),
):
"""Release BrowserOS Server OTA update
\b
Full Release (all platforms):
browseros ota server release --version 0.0.36 --channel alpha
\b
Single Platform:
browseros ota server release --version 0.0.36 --platform darwin_arm64
\b
Multiple Platforms:
browseros ota server release --version 0.0.36 --platform darwin_arm64,darwin_x64
"""
log_info(f"🚀 BrowserOS Server OTA v{version}")
log_info("=" * 70)
ctx = create_ota_context()
module = ServerOTAModule(
version=version,
channel=channel,
binaries_dir=binaries,
platform_filter=platform,
)
execute_module(ctx, module)
@server_app.command("release-appcast")
def server_release_appcast(
channel: str = typer.Option(
"alpha", "--channel", "-c", help="Release channel: alpha or prod"
),
appcast_file: Optional[Path] = typer.Option(
None, "--file", "-f", help="Custom appcast file to upload"
),
):
"""Publish appcast XML to make the release live
This is the final step after 'release' uploads artifacts.
Publishing the appcast makes the update available to clients.
\b
Release alpha appcast:
browseros ota server release-appcast --channel alpha
\b
Release production appcast:
browseros ota server release-appcast --channel prod
\b
Release custom appcast file:
browseros ota server release-appcast --file /path/to/appcast.xml
"""
if appcast_file:
if not appcast_file.exists():
log_error(f"Appcast file not found: {appcast_file}")
raise typer.Exit(1)
source_path = appcast_file
else:
source_path = get_appcast_path(channel)
if not source_path.exists():
log_error(f"Appcast file not found: {source_path}")
log_error("Run 'browseros ota server release' first to generate the appcast")
raise typer.Exit(1)
if channel == "alpha":
r2_key = "appcast-server.alpha.xml"
else:
r2_key = "appcast-server.xml"
log_info(f"📤 Uploading {source_path.name} to {r2_key}...")
env = EnvConfig()
if not env.has_r2_config():
log_error("R2 configuration not set. Required env vars: R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY")
raise typer.Exit(1)
r2_client = get_r2_client(env)
if not r2_client:
log_error("Failed to create R2 client")
raise typer.Exit(1)
if upload_file_to_r2(r2_client, source_path, r2_key, env.r2_bucket):
cdn_url = f"https://cdn.browseros.com/{r2_key}"
log_success(f"✅ Published: {cdn_url}")
else:
log_error("Upload failed")
raise typer.Exit(1)
@server_app.command("list-platforms")
def server_list_platforms():
"""List available server platforms"""
log_info("\n📦 Available Server Platforms:")
log_info("-" * 50)
for p in SERVER_PLATFORMS:
log_info(f" {p['name']:<15} {p['os']:<10} {p['arch']}")
log_info("-" * 50)
@app.command("test-signing")
def test_signing(
file_path: Path = typer.Argument(..., help="File to sign for testing"),
):
"""Test Sparkle Ed25519 signing on a file
\b
Example:
browseros ota test-signing /path/to/file.zip
"""
if not file_path.exists():
log_error(f"File not found: {file_path}")
raise typer.Exit(1)
env = EnvConfig()
if not env.has_sparkle_key():
log_error("SPARKLE_PRIVATE_KEY not set")
raise typer.Exit(1)
log_info(f"\n🔐 Testing Sparkle Ed25519 signing")
log_info(f"File: {file_path}")
log_info("-" * 60)
sig, length = sparkle_sign_file(file_path, env)
if not sig:
log_error("Signing failed")
raise typer.Exit(1)
log_success(f"✅ Signed successfully")
log_info(f" Signature: {sig[:50]}...")
log_info(f" Length: {length}")
@server_app.callback(invoke_without_command=True)
def server_main(ctx: typer.Context):
"""BrowserOS Server OTA commands
\b
Release (upload artifacts):
browseros ota server release --version 0.0.36
\b
Release Appcast (make live):
browseros ota server release-appcast --channel alpha
\b
List Platforms:
browseros ota server list-platforms
"""
if ctx.invoked_subcommand is None:
typer.echo("Use --help for usage information")
typer.echo("Available commands: release, release-appcast, list-platforms")
raise typer.Exit(0)
@app.callback(invoke_without_command=True)
def main(ctx: typer.Context):
"""OTA update automation for BrowserOS
\b
Server OTA:
browseros ota server release --version 0.0.36
browseros ota server release-appcast --channel alpha
browseros ota server list-platforms
"""
if ctx.invoked_subcommand is None:
typer.echo("Use --help for usage information")
typer.echo("Available subcommands: server")
raise typer.Exit(0)
if __name__ == "__main__":
app()

View File

@@ -165,6 +165,11 @@ class EnvConfig:
"""Base64-encoded Sparkle Ed25519 private key for macOS auto-update signing"""
return os.environ.get("SPARKLE_PRIVATE_KEY")
@property
def sparkle_sign_update_path(self) -> Optional[str]:
"""Path to Sparkle sign_update tool (overrides auto-detection)"""
return os.environ.get("SPARKLE_SIGN_UPDATE_PATH")
# === Notifications ===
@property

View File

@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Sparkle Ed25519 signing for auto-update
Cross-platform Ed25519 signing compatible with Sparkle framework.
Uses Python cryptography library - works on macOS, Windows, and Linux.
"""
import base64
from pathlib import Path
from typing import Optional, Tuple
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from .env import EnvConfig
from .utils import log_error
def _parse_sparkle_private_key(key_data: str) -> Optional[Ed25519PrivateKey]:
"""Parse Sparkle Ed25519 private key from various formats
Sparkle key formats:
- Raw 64-byte key (32-byte seed + 32-byte public key)
- Raw 32-byte seed
- Base64 encoded versions of above
Returns:
Ed25519PrivateKey or None on failure
"""
try:
# Try base64 decode first (env var might be base64 encoded)
try:
key_bytes = base64.b64decode(key_data)
except Exception:
# Not base64, try as raw bytes
key_bytes = key_data.encode("latin-1")
# Sparkle uses 64-byte format: 32-byte seed + 32-byte public key
if len(key_bytes) == 64:
seed = key_bytes[:32]
return Ed25519PrivateKey.from_private_bytes(seed)
elif len(key_bytes) == 32:
return Ed25519PrivateKey.from_private_bytes(key_bytes)
else:
log_error(f"Invalid Sparkle key length: {len(key_bytes)} bytes (expected 32 or 64)")
return None
except Exception as e:
log_error(f"Failed to parse Sparkle private key: {e}")
return None
def sparkle_sign_file(
file_path: Path,
env: Optional[EnvConfig] = None,
) -> Tuple[Optional[str], int]:
"""Sign a file with Sparkle Ed25519 key
Args:
file_path: Path to file to sign (typically a zip or dmg)
env: Environment config with Sparkle key
Returns:
(signature, length) tuple, or (None, 0) on failure
"""
if env is None:
env = EnvConfig()
if not env.has_sparkle_key():
log_error("SPARKLE_PRIVATE_KEY not set")
return None, 0
key_data = env.sparkle_private_key
if not key_data:
log_error("SPARKLE_PRIVATE_KEY is empty")
return None, 0
private_key = _parse_sparkle_private_key(key_data)
if not private_key:
return None, 0
try:
file_data = file_path.read_bytes()
file_length = len(file_data)
signature_bytes = private_key.sign(file_data)
signature_b64 = base64.b64encode(signature_bytes).decode("ascii")
return signature_b64, file_length
except Exception as e:
log_error(f"Error signing {file_path.name}: {e}")
return None, 0

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>BrowserOS Server (Alpha)</title>
<link>https://cdn.browseros.com/appcast-server.alpha.xml</link>
<description>BrowserOS Server binary updates</description>
<language>en</language>
<item>
<sparkle:version>0.0.37</sparkle:version>
<pubDate>Tue, 06 Jan 2026 20:01:05 +0000</pubDate>
<!-- macOS arm64 -->
<enclosure
url="https://cdn.browseros.com/server/browseros_server_0.0.37_darwin_arm64.zip"
sparkle:os="macos"
sparkle:arch="arm64"
sparkle:edSignature="ScvV9MfdEBmmATKvqYqREiTV9kfy7fta3sBCd8D/KqI2+k09IzwyLUGIQcSsY4RYvF804/TBCBjnYn6Z24dCAQ=="
length="33618179"
type="application/zip"/>
<!-- macOS x86_64 -->
<enclosure
url="https://cdn.browseros.com/server/browseros_server_0.0.37_darwin_x64.zip"
sparkle:os="macos"
sparkle:arch="x86_64"
sparkle:edSignature="/S3D8e027flwh/8DyrPqrK4axiFQDQbZkDpeGuJvHE6M/TG641WF1mXC5mH160SHXIGLcIVz/bZ8iUeK4mUDDw=="
length="36160742"
type="application/zip"/>
<!-- Linux arm64 -->
<enclosure
url="https://cdn.browseros.com/server/browseros_server_0.0.37_linux_arm64.zip"
sparkle:os="linux"
sparkle:arch="arm64"
sparkle:edSignature="tU10l1RWSwZ3hz/WaoP/pVxWS7y+CegSEEGutyOTZqmD/yqiLHk2W+lZBow/fnGMfPf2YURyIINahh46j6rGDQ=="
length="49091705"
type="application/zip"/>
<!-- Linux x86_64 -->
<enclosure
url="https://cdn.browseros.com/server/browseros_server_0.0.37_linux_x64.zip"
sparkle:os="linux"
sparkle:arch="x86_64"
sparkle:edSignature="1t8Cegfpp1V6aykTOVrZvbROCfb4CtOXLEoUsrgZaTFHYXazSXF/DHB0Ra0+8GRzU05uLAWtfb10/FzVQQeyDg=="
length="50567580"
type="application/zip"/>
</item>
</channel>
</rss>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>BrowserOS Server</title>
<link>https://cdn.browseros.com/appcast-server.xml</link>
<description>BrowserOS Server binary updates</description>
<language>en</language>
</channel>
</rss>

View File

@@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""OTA (Over-The-Air) update modules for BrowserOS Server and Browser"""
from .common import (
sparkle_sign_file,
generate_server_appcast,
parse_existing_appcast,
ExistingAppcast,
SignedArtifact,
SERVER_PLATFORMS,
APPCAST_TEMPLATE,
)
from .sign_binary import (
sign_macos_binary,
notarize_macos_binary,
sign_windows_binary,
)
from .server import ServerOTAModule
AVAILABLE_MODULES = {
"server_ota": ServerOTAModule,
}
__all__ = [
"AVAILABLE_MODULES",
"ServerOTAModule",
"sparkle_sign_file",
"generate_server_appcast",
"parse_existing_appcast",
"ExistingAppcast",
"SignedArtifact",
"sign_macos_binary",
"notarize_macos_binary",
"sign_windows_binary",
"SERVER_PLATFORMS",
"APPCAST_TEMPLATE",
]

View File

@@ -0,0 +1,275 @@
#!/usr/bin/env python3
"""Common utilities for OTA update modules"""
import os
import re
import shutil
import zipfile
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Optional
from dataclasses import dataclass
from ...common.utils import log_error, log_info, log_success
# Re-export sparkle_sign_file from common module
from ...common.sparkle import sparkle_sign_file
# Sparkle XML namespace
SPARKLE_NS = "http://www.andymatuschak.org/xml-namespaces/sparkle"
ET.register_namespace("sparkle", SPARKLE_NS)
SERVER_PLATFORMS = [
{"name": "darwin_arm64", "binary": "browseros-server-darwin-arm64", "os": "macos", "arch": "arm64"},
{"name": "darwin_x64", "binary": "browseros-server-darwin-x64", "os": "macos", "arch": "x86_64"},
{"name": "linux_arm64", "binary": "browseros-server-linux-arm64", "os": "linux", "arch": "arm64"},
{"name": "linux_x64", "binary": "browseros-server-linux-x64", "os": "linux", "arch": "x86_64"},
{"name": "windows_x64", "binary": "browseros-server-windows-x64.exe", "os": "windows", "arch": "x86_64"},
]
APPCAST_TEMPLATE = """<?xml version="1.0" encoding="utf-8"?>
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>{title}</title>
<link>{appcast_url}</link>
<description>BrowserOS Server binary updates</description>
<language>en</language>
<item>
<sparkle:version>{version}</sparkle:version>
<pubDate>{pub_date}</pubDate>
{enclosures}
</item>
</channel>
</rss>
"""
ENCLOSURE_TEMPLATE = """ <!-- {comment} -->
<enclosure
url="{url}"
sparkle:os="{os}"
sparkle:arch="{arch}"
sparkle:edSignature="{signature}"
length="{length}"
type="application/zip"/>"""
@dataclass
class SignedArtifact:
"""Represents a signed artifact with Sparkle signature"""
platform: str
zip_path: Path
signature: str
length: int
os: str
arch: str
@dataclass
class ExistingAppcast:
"""Parsed data from an existing appcast file"""
version: str
pub_date: str
artifacts: Dict[str, SignedArtifact]
def parse_existing_appcast(appcast_path: Path) -> Optional[ExistingAppcast]:
"""Parse existing appcast XML file.
Args:
appcast_path: Path to existing appcast XML file
Returns:
ExistingAppcast with version, pubDate, and artifacts, or None if parsing fails
"""
if not appcast_path.exists():
return None
try:
tree = ET.parse(appcast_path)
root = tree.getroot()
# Find the item element (we only support single-item appcasts)
channel = root.find("channel")
if channel is None:
return None
item = channel.find("item")
if item is None:
return None
# Extract version
version_elem = item.find(f"{{{SPARKLE_NS}}}version")
if version_elem is None or version_elem.text is None:
return None
version = version_elem.text
# Extract pubDate
pub_date_elem = item.find("pubDate")
pub_date = pub_date_elem.text if pub_date_elem is not None and pub_date_elem.text else ""
# Extract enclosures
artifacts: Dict[str, SignedArtifact] = {}
for enclosure in item.findall("enclosure"):
url = enclosure.get("url", "")
os_type = enclosure.get(f"{{{SPARKLE_NS}}}os", "")
arch = enclosure.get(f"{{{SPARKLE_NS}}}arch", "")
signature = enclosure.get(f"{{{SPARKLE_NS}}}edSignature", "")
length_str = enclosure.get("length", "0")
if not all([url, os_type, arch, signature]):
continue
# Extract platform from URL (e.g., browseros_server_0.0.37_darwin_arm64.zip)
filename = url.split("/")[-1]
# Match pattern like _darwin_arm64.zip or _windows_x64.zip
platform_match = re.search(r"_([a-z]+_[a-z0-9]+)\.zip$", filename)
if not platform_match:
continue
platform = platform_match.group(1)
artifacts[platform] = SignedArtifact(
platform=platform,
zip_path=Path(filename),
signature=signature,
length=int(length_str),
os=os_type,
arch=arch,
)
return ExistingAppcast(version=version, pub_date=pub_date, artifacts=artifacts)
except ET.ParseError as e:
log_error(f"Malformed appcast XML: {e}")
return None
except Exception as e:
log_error(f"Failed to parse existing appcast: {e}")
return None
def generate_server_appcast(
version: str,
artifacts: List[SignedArtifact],
channel: str = "alpha",
existing: Optional[ExistingAppcast] = None,
) -> str:
"""Generate appcast XML for server OTA, merging with existing if same version.
Args:
version: Version string (e.g., "0.0.36")
artifacts: List of new SignedArtifact with signature info
channel: "alpha" or "prod"
existing: Previously parsed appcast to merge with (if same version)
Returns:
Complete appcast XML string
Merge behavior:
- If existing has same version: merge platforms, keep original pubDate
- If existing has different version or is None: use only new artifacts
"""
if channel == "alpha":
title = "BrowserOS Server (Alpha)"
appcast_url = "https://cdn.browseros.com/appcast-server.alpha.xml"
else:
title = "BrowserOS Server"
appcast_url = "https://cdn.browseros.com/appcast-server.xml"
# Determine pubDate and merged artifacts
if existing is not None and existing.version == version:
# Same version: merge artifacts, keep original pubDate
pub_date = existing.pub_date
merged_artifacts = dict(existing.artifacts) # Copy existing
for artifact in artifacts:
merged_artifacts[artifact.platform] = artifact # New overrides existing
final_artifacts = list(merged_artifacts.values())
log_info(f"Merging with existing appcast (kept {len(existing.artifacts)} existing, added/updated {len(artifacts)} platforms)")
else:
# Different version or no existing: start fresh
pub_date = datetime.now(timezone.utc).strftime("%a, %d %b %Y %H:%M:%S +0000")
final_artifacts = artifacts
if existing is not None:
log_info(f"Version changed ({existing.version} -> {version}), replacing appcast")
# Sort artifacts by platform name for consistent output
final_artifacts = sorted(final_artifacts, key=lambda a: a.platform)
enclosures = []
for artifact in final_artifacts:
comment = f"{artifact.os.capitalize()} {artifact.arch}"
if artifact.os == "macos":
comment = f"macOS {artifact.arch}"
zip_filename = f"browseros_server_{version}_{artifact.platform}.zip"
url = f"https://cdn.browseros.com/server/{zip_filename}"
enclosure = ENCLOSURE_TEMPLATE.format(
comment=comment,
url=url,
os=artifact.os,
arch=artifact.arch,
signature=artifact.signature,
length=artifact.length,
)
enclosures.append(enclosure)
return APPCAST_TEMPLATE.format(
title=title,
appcast_url=appcast_url,
version=version,
pub_date=pub_date,
enclosures="\n\n".join(enclosures),
)
def create_server_zip(
binary_path: Path,
output_zip: Path,
is_windows: bool = False,
) -> bool:
"""Create zip with proper structure: resources/bin/browseros_server
Args:
binary_path: Path to the binary to package
output_zip: Path for output zip file
is_windows: Whether this is Windows binary (affects target name)
Returns:
True on success, False on failure
"""
staging_dir = output_zip.parent / f"staging_{output_zip.stem}"
try:
staging_dir.mkdir(parents=True, exist_ok=True)
bin_dir = staging_dir / "resources" / "bin"
bin_dir.mkdir(parents=True, exist_ok=True)
target_name = "browseros_server.exe" if is_windows else "browseros_server"
shutil.copy2(binary_path, bin_dir / target_name)
with zipfile.ZipFile(output_zip, 'w', zipfile.ZIP_DEFLATED) as zf:
for root, _, files in os.walk(staging_dir):
for file in files:
file_path = Path(root) / file
arcname = file_path.relative_to(staging_dir)
zf.write(file_path, arcname)
log_success(f"Created {output_zip.name}")
return True
except Exception as e:
log_error(f"Failed to create zip: {e}")
return False
finally:
if staging_dir.exists():
shutil.rmtree(staging_dir)
def get_appcast_path(channel: str = "alpha") -> Path:
"""Get path to appcast file in config/appcast directory"""
appcast_dir = Path(__file__).parent.parent.parent / "config" / "appcast"
if channel == "alpha":
return appcast_dir / "appcast-server.alpha.xml"
return appcast_dir / "appcast-server.xml"

View File

@@ -0,0 +1,238 @@
#!/usr/bin/env python3
"""Server OTA module for BrowserOS Server binary updates"""
import shutil
import tempfile
from pathlib import Path
from typing import List, Optional
from ...common.module import CommandModule, ValidationError
from ...common.context import Context
from ...common.utils import (
log_info,
log_error,
log_success,
log_warning,
IS_MACOS,
IS_WINDOWS,
)
from .common import (
SERVER_PLATFORMS,
SignedArtifact,
sparkle_sign_file,
generate_server_appcast,
parse_existing_appcast,
create_server_zip,
get_appcast_path,
)
from .sign_binary import (
sign_macos_binary,
notarize_macos_binary,
sign_windows_binary,
get_entitlements_path,
)
from ..upload import get_r2_client, upload_file_to_r2
class ServerOTAModule(CommandModule):
"""OTA update module for BrowserOS Server binaries
This module handles the full OTA workflow:
1. Sign individual binaries (codesign for macOS, CodeSignTool for Windows)
2. Create zip packages with proper structure
3. Sign zips with Sparkle Ed25519
4. Upload to R2
5. Generate and upload appcast XML
"""
produces = ["server_ota_artifacts", "server_appcast"]
requires = []
description = "Create and upload BrowserOS Server OTA update"
def __init__(
self,
version: str = "",
channel: str = "alpha",
binaries_dir: Optional[Path] = None,
platform_filter: Optional[str] = None,
):
"""
Args:
version: Version string (e.g., "0.0.36")
channel: Release channel ("alpha" or "prod")
binaries_dir: Directory containing server binaries
platform_filter: Platform(s) to process, comma-separated (e.g., "darwin_arm64,darwin_x64")
"""
self.version = version
self.channel = channel
self.binaries_dir = binaries_dir
self.platform_filter = platform_filter
def validate(self, context: Context) -> None:
ctx = context
if not self.version:
raise ValidationError("Version is required")
if self.channel not in ["alpha", "prod"]:
raise ValidationError("Channel must be 'alpha' or 'prod'")
if self.binaries_dir:
if not self.binaries_dir.exists():
raise ValidationError(f"Binaries directory not found: {self.binaries_dir}")
else:
default_dir = ctx.root_dir / "resources" / "binaries" / "browseros_server"
if not default_dir.exists():
raise ValidationError(f"Default binaries directory not found: {default_dir}")
self.binaries_dir = default_dir
platforms = self._get_platforms()
for p in platforms:
binary_name = p["binary"]
binary_path = self.binaries_dir / binary_name
if not binary_path.exists():
raise ValidationError(f"Binary not found: {binary_path}")
if IS_MACOS():
if not ctx.env.macos_certificate_name:
raise ValidationError("MACOS_CERTIFICATE_NAME required for signing")
elif IS_WINDOWS():
if not ctx.env.code_sign_tool_path:
raise ValidationError("CODE_SIGN_TOOL_PATH required for signing")
if not ctx.env.has_r2_config():
raise ValidationError(
"R2 configuration not set. Required env vars: "
"R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY"
)
def _get_platforms(self) -> List[dict]:
"""Get platforms to process based on filter (supports comma-separated)"""
if self.platform_filter:
requested = [p.strip() for p in self.platform_filter.split(",")]
return [p for p in SERVER_PLATFORMS if p["name"] in requested]
return SERVER_PLATFORMS
def execute(self, context: Context) -> None:
ctx = context
log_info(f"\n🚀 BrowserOS Server OTA v{self.version} ({self.channel})")
log_info("=" * 70)
platforms = self._get_platforms()
temp_dir = Path(tempfile.mkdtemp())
log_info(f"Temp directory: {temp_dir}")
signed_artifacts: List[SignedArtifact] = []
for platform in platforms:
log_info(f"\n📦 Processing {platform['name']}...")
binary_name = platform["binary"]
source_binary = self.binaries_dir / binary_name
# Copy binary to temp to preserve original
temp_binary = temp_dir / binary_name
shutil.copy2(source_binary, temp_binary)
if not self._sign_binary(temp_binary, platform, ctx):
log_warning(f"Skipping {platform['name']} due to signing failure")
continue
zip_name = f"browseros_server_{self.version}_{platform['name']}.zip"
zip_path = temp_dir / zip_name
is_windows = platform["os"] == "windows"
if not create_server_zip(temp_binary, zip_path, is_windows):
log_error(f"Failed to create zip for {platform['name']}")
continue
log_info(f"Signing {zip_name} with Sparkle...")
signature, length = sparkle_sign_file(zip_path, ctx.env)
if not signature:
log_error(f"Failed to sign zip for {platform['name']}")
continue
log_success(f" {platform['name']}: {length} bytes")
artifact = SignedArtifact(
platform=platform["name"],
zip_path=zip_path,
signature=signature,
length=length,
os=platform["os"],
arch=platform["arch"],
)
signed_artifacts.append(artifact)
if not signed_artifacts:
log_error("No artifacts were processed successfully")
raise RuntimeError("OTA failed - no artifacts")
log_info("\n📝 Generating appcast...")
appcast_path = get_appcast_path(self.channel)
existing_appcast = parse_existing_appcast(appcast_path)
appcast_content = generate_server_appcast(
self.version,
signed_artifacts,
self.channel,
existing=existing_appcast,
)
appcast_path.parent.mkdir(parents=True, exist_ok=True)
appcast_path.write_text(appcast_content)
log_success(f"Appcast saved to: {appcast_path}")
log_info("\n📤 Uploading artifacts to R2...")
r2_client = get_r2_client(ctx.env)
if not r2_client:
raise RuntimeError("Failed to create R2 client")
bucket = ctx.env.r2_bucket
for artifact in signed_artifacts:
r2_key = f"server/{artifact.zip_path.name}"
if not upload_file_to_r2(r2_client, artifact.zip_path, r2_key, bucket):
raise RuntimeError(f"Failed to upload {artifact.zip_path.name}")
ctx.artifacts["server_ota_artifacts"] = signed_artifacts
ctx.artifacts["server_appcast"] = appcast_path
log_info("\n" + "=" * 70)
log_success(f"✅ Server OTA v{self.version} ({self.channel}) artifacts ready!")
log_info("=" * 70)
log_info("\nArtifact URLs:")
for artifact in signed_artifacts:
log_info(f" https://cdn.browseros.com/server/{artifact.zip_path.name}")
log_info(f"\nAppcast saved to: {appcast_path}")
log_info("\n📋 Next step: Run 'browseros ota server release-appcast' to make the release live")
def _sign_binary(self, binary_path: Path, platform: dict, ctx: Context) -> bool:
"""Sign binary based on platform"""
os_type = platform["os"]
if os_type == "macos":
if not IS_MACOS():
log_warning(f"macOS signing requires macOS - skipping {platform['name']}")
return True
entitlements = get_entitlements_path(ctx.root_dir)
if not sign_macos_binary(binary_path, ctx.env, entitlements):
return False
log_info("Notarizing...")
return notarize_macos_binary(binary_path, ctx.env)
elif os_type == "windows":
if not IS_WINDOWS():
log_warning(f"Windows signing requires Windows - skipping {platform['name']}")
return True
return sign_windows_binary(binary_path, ctx.env)
elif os_type == "linux":
log_info(f"No code signing for Linux binaries")
return True
return True

View File

@@ -0,0 +1,304 @@
#!/usr/bin/env python3
"""Platform-specific binary signing for OTA binaries"""
import subprocess
import tempfile
from pathlib import Path
from typing import Optional
from ...common.env import EnvConfig
from ...common.utils import (
log_info,
log_error,
log_success,
log_warning,
IS_MACOS,
IS_WINDOWS,
)
def sign_macos_binary(
binary_path: Path,
env: Optional[EnvConfig] = None,
entitlements_path: Optional[Path] = None,
) -> bool:
"""Sign a macOS binary with codesign
Args:
binary_path: Path to binary to sign
env: Environment config with certificate name
entitlements_path: Optional path to entitlements plist
Returns:
True on success, False on failure
"""
if not IS_MACOS():
log_error("macOS signing requires macOS")
return False
if env is None:
env = EnvConfig()
certificate_name = env.macos_certificate_name
if not certificate_name:
log_error("MACOS_CERTIFICATE_NAME not set")
return False
log_info(f"Signing {binary_path.name}...")
cmd = [
"codesign",
"--sign", certificate_name,
"--force",
"--timestamp",
"--identifier", f"com.browseros.{binary_path.stem}",
"--options", "runtime",
]
if entitlements_path and entitlements_path.exists():
cmd.extend(["--entitlements", str(entitlements_path)])
cmd.append(str(binary_path))
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
if result.returncode != 0:
log_error(f"codesign failed: {result.stderr}")
return False
log_success(f"Signed {binary_path.name}")
return True
except Exception as e:
log_error(f"Signing failed: {e}")
return False
def verify_macos_signature(binary_path: Path) -> bool:
"""Verify macOS binary signature"""
if not IS_MACOS():
return False
try:
result = subprocess.run(
["codesign", "--verify", "--verbose=2", str(binary_path)],
capture_output=True,
text=True,
check=False,
)
return result.returncode == 0
except Exception:
return False
def notarize_macos_binary(
binary_path: Path,
env: Optional[EnvConfig] = None,
) -> bool:
"""Notarize a macOS binary with Apple
The binary must be zipped for notarization submission.
Args:
binary_path: Path to binary to notarize (will be zipped internally)
env: Environment config with notarization credentials
Returns:
True on success, False on failure
"""
if not IS_MACOS():
log_error("macOS notarization requires macOS")
return False
if env is None:
env = EnvConfig()
apple_id = env.macos_notarization_apple_id
team_id = env.macos_notarization_team_id
password = env.macos_notarization_password
if not all([apple_id, team_id, password]):
log_error("Missing notarization credentials:")
if not apple_id:
log_error(" PROD_MACOS_NOTARIZATION_APPLE_ID not set")
if not team_id:
log_error(" PROD_MACOS_NOTARIZATION_TEAM_ID not set")
if not password:
log_error(" PROD_MACOS_NOTARIZATION_PWD not set")
return False
log_info(f"Notarizing {binary_path.name}...")
notarize_zip = None
try:
fd, tmp_path = tempfile.mkstemp(suffix=".zip")
import os
os.close(fd)
notarize_zip = Path(tmp_path)
result = subprocess.run(
["ditto", "-c", "-k", "--keepParent", str(binary_path), str(notarize_zip)],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
log_error(f"Failed to create zip: {result.stderr}")
return False
assert apple_id is not None
assert team_id is not None
assert password is not None
subprocess.run(
[
"xcrun", "notarytool", "store-credentials", "notarytool-profile",
"--apple-id", apple_id,
"--team-id", team_id,
"--password", password,
],
capture_output=True,
text=True,
check=False,
)
log_info("Submitting for notarization (this may take a while)...")
result = subprocess.run(
[
"xcrun", "notarytool", "submit", str(notarize_zip),
"--keychain-profile", "notarytool-profile",
"--wait",
],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
log_error(f"Notarization failed: {result.stderr}")
log_error(result.stdout)
return False
if "status: Accepted" not in result.stdout:
log_error("Notarization was not accepted")
log_error(result.stdout)
return False
log_success(f"Notarized {binary_path.name}")
return True
except Exception as e:
log_error(f"Notarization failed: {e}")
return False
finally:
if notarize_zip and notarize_zip.exists():
notarize_zip.unlink()
def sign_windows_binary(
binary_path: Path,
env: Optional[EnvConfig] = None,
) -> bool:
"""Sign a Windows binary with SSL.com CodeSignTool
Args:
binary_path: Path to binary to sign
env: Environment config with eSigner credentials
Returns:
True on success, False on failure
"""
if not IS_WINDOWS():
log_warning("Windows signing should be done on Windows")
log_warning("Skipping signing - binary will be unsigned")
return True
if env is None:
env = EnvConfig()
if not env.code_sign_tool_path:
log_error("CODE_SIGN_TOOL_PATH not set")
return False
if not all([env.esigner_username, env.esigner_password, env.esigner_totp_secret]):
log_error("Missing eSigner credentials")
return False
codesigntool_path = Path(env.code_sign_tool_path) / "CodeSignTool.bat"
if not codesigntool_path.exists():
log_error(f"CodeSignTool.bat not found at: {codesigntool_path}")
return False
log_info(f"Signing {binary_path.name}...")
try:
temp_output_dir = binary_path.parent / "signed_temp"
temp_output_dir.mkdir(exist_ok=True)
cmd = [
str(codesigntool_path),
"sign",
"-username", env.esigner_username,
"-password", f'"{env.esigner_password}"',
]
if env.esigner_credential_id:
cmd.extend(["-credential_id", env.esigner_credential_id])
cmd.extend([
"-totp_secret", env.esigner_totp_secret,
"-input_file_path", str(binary_path),
"-output_dir_path", str(temp_output_dir),
"-override",
])
result = subprocess.run(
" ".join(cmd),
shell=True,
capture_output=True,
text=True,
cwd=str(codesigntool_path.parent),
)
if result.stdout and "Error:" in result.stdout:
log_error(f"Signing failed: {result.stdout}")
return False
signed_file = temp_output_dir / binary_path.name
if signed_file.exists():
import shutil
shutil.move(str(signed_file), str(binary_path))
try:
temp_output_dir.rmdir()
except Exception:
pass
verify_cmd = [
"powershell", "-Command",
f"(Get-AuthenticodeSignature '{binary_path}').Status",
]
verify_result = subprocess.run(verify_cmd, capture_output=True, text=True)
if "Valid" in verify_result.stdout:
log_success(f"Signed and verified {binary_path.name}")
return True
else:
log_error(f"Signature verification failed: {verify_result.stdout.strip()}")
return False
except Exception as e:
log_error(f"Signing failed: {e}")
return False
def get_entitlements_path(root_dir: Path) -> Optional[Path]:
"""Get path to server binary entitlements file"""
candidates = [
root_dir / "resources" / "entitlements" / "browseros-executable-entitlements.plist",
root_dir / "packages" / "browseros" / "resources" / "entitlements" / "browseros-executable-entitlements.plist",
]
for candidate in candidates:
if candidate.exists():
return candidate
return None

View File

@@ -1,23 +1,16 @@
#!/usr/bin/env python3
"""Sparkle signing module for macOS auto-update"""
import base64
import os
import re
import subprocess
import tempfile
from pathlib import Path
from typing import Dict, Optional, Tuple
from typing import Dict, Tuple
from ...common.module import CommandModule, ValidationError
from ...common.context import Context
from ...common.sparkle import sparkle_sign_file
from ...common.utils import (
log_info,
log_error,
log_success,
log_warning,
IS_MACOS,
join_paths,
)
@@ -29,24 +22,11 @@ class SparkleSignModule(CommandModule):
description = "Sign DMG files with Sparkle Ed25519 key for auto-update"
def validate(self, ctx: Context) -> None:
if not IS_MACOS():
raise ValidationError("Sparkle signing is macOS only")
# Check sign_update tool exists
sign_update = self._get_sign_update_path(ctx)
if not sign_update.exists():
raise ValidationError(f"sign_update not found: {sign_update}")
# Check Sparkle private key is available
if not ctx.env.has_sparkle_key():
raise ValidationError(
"SPARKLE_PRIVATE_KEY environment variable not set"
)
def _get_sign_update_path(self, ctx: Context) -> Path:
"""Get path to Sparkle sign_update tool"""
return join_paths(ctx.chromium_src, "third_party", "sparkle", "bin", "sign_update")
def execute(self, ctx: Context) -> None:
log_info("\n🔐 Signing DMGs with Sparkle...")
@@ -88,120 +68,18 @@ def sign_dmgs_with_sparkle(
Returns:
Dict mapping filename to (signature, length) tuple
"""
env = ctx.env
sign_update = join_paths(ctx.chromium_src, "third_party", "sparkle", "bin", "sign_update")
if not sign_update.exists():
log_error(f"sign_update not found: {sign_update}")
return {}
if not env.has_sparkle_key():
log_error("SPARKLE_PRIVATE_KEY not set")
return {}
signatures = {}
# Write private key to temp file
key_file = None
try:
# Decode base64 key if it looks encoded, otherwise use as-is
key_data = env.sparkle_private_key
try:
# Try to decode as base64
decoded = base64.b64decode(key_data)
key_data = decoded.decode("utf-8")
except Exception:
# Not base64, use as-is
pass
# Write key to temp file
key_file = tempfile.NamedTemporaryFile(
mode="w", suffix=".key", delete=False
)
key_file.write(key_data)
key_file.close()
# Sign each DMG
for dmg_path in dmg_files:
sig, length = _sign_single_dmg(sign_update, key_file.name, dmg_path)
if sig:
signatures[dmg_path.name] = (sig, length)
finally:
# Clean up temp key file
if key_file and os.path.exists(key_file.name):
os.unlink(key_file.name)
for dmg_path in dmg_files:
log_info(f"🔐 Signing {dmg_path.name}...")
sig, length = sparkle_sign_file(dmg_path, ctx.env)
if sig:
signatures[dmg_path.name] = (sig, length)
log_success(f"✓ Signed {dmg_path.name}")
return signatures
def _sign_single_dmg(
sign_update: Path,
key_file: str,
dmg_path: Path,
) -> Tuple[Optional[str], int]:
"""Sign a single DMG and parse the output
Args:
sign_update: Path to sign_update binary
key_file: Path to temporary key file
dmg_path: Path to DMG file
Returns:
(signature, length) tuple, or (None, 0) on failure
"""
log_info(f"🔐 Signing {dmg_path.name}...")
try:
result = subprocess.run(
[str(sign_update), "--ed-key-file", key_file, str(dmg_path)],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
log_error(f"sign_update failed: {result.stderr}")
return None, 0
# Parse output: sparkle:edSignature="..." length="..."
output = result.stdout.strip()
sig, length = parse_sparkle_output(output)
if sig:
log_success(f"✓ Signed {dmg_path.name}")
return sig, length
else:
log_error(f"Failed to parse sign_update output: {output}")
return None, 0
except Exception as e:
log_error(f"Error signing {dmg_path.name}: {e}")
return None, 0
def parse_sparkle_output(output: str) -> Tuple[Optional[str], int]:
"""Parse sign_update output to extract signature and length
Example output:
sparkle:edSignature="abc123..." length="126911210"
Args:
output: Raw output from sign_update
Returns:
(signature, length) tuple, or (None, 0) if parsing fails
"""
# Match: sparkle:edSignature="..." length="..."
sig_match = re.search(r'sparkle:edSignature="([^"]+)"', output)
len_match = re.search(r'length="(\d+)"', output)
if sig_match and len_match:
return sig_match.group(1), int(len_match.group(1))
return None, 0
def get_sparkle_signatures(ctx: Context) -> Dict[str, Tuple[str, int]]:
"""Get stored Sparkle signatures from context

View File

@@ -15,6 +15,7 @@ dependencies = [
"boto3>=1.34.0",
"python-dotenv>=1.0.0",
"Pillow>=10.0.0",
"cryptography>=41.0.0",
]
[project.scripts]
@@ -34,6 +35,7 @@ packages = [
"build.modules.extract",
"build.modules.apply",
"build.modules.feature",
"build.modules.ota",
]
[tool.black]

View File

@@ -41,6 +41,7 @@ source = { editable = "." }
dependencies = [
{ name = "boto3" },
{ name = "click" },
{ name = "cryptography" },
{ name = "pillow" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
@@ -58,6 +59,7 @@ dev = [
requires-dist = [
{ name = "boto3", specifier = ">=1.34.0" },
{ name = "click", specifier = ">=8.0.0" },
{ name = "cryptography", specifier = ">=41.0.0" },
{ name = "pillow", specifier = ">=10.0.0" },
{ name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "pyyaml", specifier = ">=5.4.1" },
@@ -80,6 +82,63 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" },
]
[[package]]
name = "cffi"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
{ url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
{ url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.4"
@@ -158,6 +217,62 @@ 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 = "cryptography"
version = "46.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" },
{ url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" },
{ url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" },
{ url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" },
{ url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" },
{ url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" },
{ url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" },
{ url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" },
{ url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" },
{ url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" },
{ url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" },
{ url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" },
{ url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" },
{ url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" },
{ url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" },
{ url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" },
{ url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" },
{ url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" },
{ url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" },
{ url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" },
{ url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" },
{ url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" },
{ url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" },
{ url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" },
{ url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" },
{ url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" },
{ url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" },
{ url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" },
{ url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" },
{ url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" },
{ url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" },
{ url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" },
{ url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" },
{ url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" },
{ url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" },
{ url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" },
{ url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" },
{ url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" },
{ url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" },
{ url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" },
{ url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" },
{ url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" },
{ url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" },
{ url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" },
]
[[package]]
name = "idna"
version = "3.11"
@@ -275,6 +390,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" },
]
[[package]]
name = "pycparser"
version = "2.23"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"