diff --git a/.github/workflows/group-workbench-python-ci.yml b/.github/workflows/group-workbench-python-ci.yml index 1c0237a..4d420ce 100644 --- a/.github/workflows/group-workbench-python-ci.yml +++ b/.github/workflows/group-workbench-python-ci.yml @@ -111,7 +111,7 @@ jobs: - name: Test campus-net working-directory: workbench/network/campus-net - run: uv run --frozen python -m unittest discover -s tests -p "test_*.py" -v + run: xvfb-run -a uv run python -m unittest discover -s tests -p "test_*.py" -v audit: runs-on: ubuntu-latest diff --git a/workbench/network/campus-net/.gitignore b/workbench/network/campus-net/.gitignore index e631f1b..49e4563 100644 --- a/workbench/network/campus-net/.gitignore +++ b/workbench/network/campus-net/.gitignore @@ -8,3 +8,9 @@ Auto_Connect_CampusNet.egg-info/ # Local secrets config.json +config.backup.*.json +.config.json.*.tmp +.config.backup.*.tmp +config.*.backup.json + +uv.lock diff --git a/workbench/network/campus-net/README.md b/workbench/network/campus-net/README.md index ec4d933..bc768e6 100644 --- a/workbench/network/campus-net/README.md +++ b/workbench/network/campus-net/README.md @@ -1,33 +1,179 @@ # Campus Net -面向锐捷校园网门户的自动登录脚本,可通过本地配置运行,也可用 PyInstaller 构建为 Windows 可执行文件。 +面向锐捷校园网门户的连接工具,支持旧版 ePortal 和新版 captive SSO。新版适配器完全使用 HTTP 会话复现门户流程,不打开或控制任何浏览器。 + +新版聚合门户不能从固定的 `/sam-sso/login` 地址直接联网。程序会把探测和认证 socket 绑定到指定 Windows 网络接口,从 captive 劫持响应中提取本次连接完整的 `/eportal/index.jsp?...` 入口,再依次完成 SSO、运营商选择和在线确认。接口索引可以属于 Wi-Fi、有线网卡或其他 IPv4 网络接口,不依赖 WLAN 名称。 + +系统图形验证码由门户服务端会话校验,客户端既没有答案也没有可替代答案的令牌。因此: + +- 当前账号不触发验证码时,流程全自动完成。 +- 触发系统图形验证码时,程序用本机原生小窗口显示图片;人工只填写验证码,其余表单和“中国电信”选择仍由程序自动完成。 +- 程序不做 OCR、猜测或验证码绕过,也不支持 Geetest、Google reCAPTCHA 和 USTC 指纹风控。 +- 只有服务端明确返回“验证码错误”时才允许继续输入;结果不明的登录 POST 永远不会自动重放。 + +最终成功必须同时满足门户在线状态和绑定接口上的独立 HTTP 在线指纹,不能只相信门户的“认证成功”页面。 + +新版门户的分析过程、完整请求序列、加密表单、验证码会话和兼容设计见 [新版 captive SSO 适配记录](docs/new-portal-adaptation.md)。 + +## 图形界面 + +Windows 用户可以运行基于 `ttkbootstrap` 的本机图形界面: + +```powershell +uv sync --group dev +& .\.venv\Scripts\python.exe gui_main.py +``` + +界面支持编辑正式的 `version=1` 和 `version=2` 配置、刷新并选择 Windows IPv4 接口、显示或隐藏密码、只读探测、连接、取消和查看过程日志。网卡的 PowerShell 枚举状态会显示为“已连接”“未连接”或“正在认证”。新版触发系统图形验证码时,验证码图片和输入框会直接显示在程序窗口中;程序仍然不打开或控制浏览器。旧版协议没有只读探测能力,界面会禁用对应操作。 + +窗口关闭时会把最后的普通窗口位置、宽高和最大化状态保存到 `%LOCALAPPDATA%\Cedarflake\CampusNet\window-state.json`,下次启动自动恢复。该文件不含账号、密码或门户数据;损坏时会被忽略。若原显示器已断开,窗口会按当前显示器工作区校正,避免恢复到屏幕外。 + +“连接”和“只读探测”使用当前表单内容,不会自动保存密码。需要持久化时必须显式点击“保存配置”。覆盖已有配置前,界面会先把原文件备份到 Windows“文档”目录下的 `CampusNet Backups`,校验备份与原文件的 SHA-256 一致后才原子替换;备份失败或文件在加载后被其他程序修改时,保存会停止且不会覆盖旧文件。内容没有变化时不会重复覆盖或创建无意义备份,新建文件则没有可备份的旧版本。若替换后的最终落盘校验无法确认,界面会把状态标记为不确定并要求重新加载,而不会声称保存失败即代表旧文件仍未改变。 + +GUI 只会写入显式带 `version: 1` 或 `version: 2` 的正式格式。无 `version` 的历史配置仍可由 CLI 兼容运行,但界面不会静默改写。若要指定另一个配置文件,可使用: + +```powershell +& .\.venv\Scripts\python.exe gui_main.py --config C:\path\to\config.json +``` + +打包后的控制台程序会依次检查 `CAMPUSNET_CONFIG`、启动时的当前目录、EXE 同目录,以及 EXE 位于项目 `dist` 时的父项目目录。因此从 `C:\Users\你的用户名` 启动项目 `dist` 中的 EXE,也能找到项目根目录的 `config.json`。父目录必须同时含项目的 `pyproject.toml` 和 `config.example.json`,不会仅凭任意同名 `dist` 目录盲目向上查找。可以用以下命令只核对最终路径;它不会读取配置内容或访问网络: + +```powershell +.\dist\Auto-Connect-CampusNet.exe --print-config-path +``` ## 配置 -真实账号、加密密码和会话 Cookie 不进入 Git。先创建本地配置: +`config.json` 使用扁平配置:`version: 1` 表示旧版 ePortal,`version: 2` 表示新版 captive SSO。迁移已有本地配置前先备份;该文件已在 `.gitignore` 中,不应提交: + +```powershell +$ErrorActionPreference = "Stop" +$backupRoot = Join-Path ([Environment]::GetFolderPath("MyDocuments")) "CampusNet Backups" +New-Item -ItemType Directory -Path $backupRoot -Force | Out-Null +if (Test-Path -LiteralPath config.json) { + $backupPath = Join-Path $backupRoot "config.$(Get-Date -Format yyyyMMdd-HHmmss).json" + Copy-Item -LiteralPath config.json -Destination $backupPath -ErrorAction Stop + if ((Get-FileHash -LiteralPath config.json).Hash -ne (Get-FileHash -LiteralPath $backupPath).Hash) { + throw "配置备份校验失败,已停止覆盖" + } +} +if (-not (Test-Path -LiteralPath config.json)) { + Copy-Item -LiteralPath config.example.json -Destination config.json -ErrorAction Stop +} +``` + +新版 captive SSO 只需要填写 6 个顶层字段: + +```json +{ + "version": 2, + "interface_index": 24, + "portal_url": "http://10.71.29.181", + "username": "你的校园网账号", + "password": "你的校园网密码", + "carrier": "中国电信" +} +``` + +- `version`:新版固定为 `2`;`1` 留给旧版 ePortal。 +- `interface_index`:面向校园网的 Windows IPv4 接口索引。可用 `Get-NetIPInterface -AddressFamily IPv4` 查看;Wi-Fi、以太网或其他 IPv4 接口都可以。 +- `portal_url`:门户根地址,例如 `http://10.71.29.181`。不要填写 `/sam-sso/login` 等深层地址。 +- `username`、`password`:校园网账号和密码。程序直接读取本地配置,不会再次询问密码或读取密码环境变量。 +- `carrier`:运营商显示名。程序会从本次会话的服务列表中找到“中国电信”对应的动态值再提交。 + +程序只使用 `interface_index` 指定的 IPv4 接口,不按接口名称或类型猜测校园网,也不会在失败时改用其他网络接口。校园网可以位于 WLAN、以太网或其他 IPv4 接口;若所选接口未连接、无可用路由或返回其他 captive 门户,CLI 与 GUI 会停止并提示重新选择实际承载校园网的接口,不会提交账号密码。所选接口已经可以访问互联网时,程序会跳过登录,并提醒校园网位于其他接口时需要改选接口。 + +以下协议参数不再属于用户配置,由代码统一维护:User-Agent、连通性探测指纹、门户入口路径、验证码模式和次数、登录后确认间隔与超时。这样升级门户适配时只改实现,不要求用户同步一组内部常量。 + +`config.json` 及其备份包含敏感信息,不要提交或分享。系统验证码关闭窗口会立即取消;服务端明确返回验证码错误时,程序会刷新验证码并继续输入,不设置客户端重试次数上限。 + +先执行只读探测: + +```powershell +& .\.venv\Scripts\python.exe main.py --probe-only +``` + +确认能识别 captive 状态并填写账号、密码后运行: ```powershell -Copy-Item config.example.json config.json +& .\.venv\Scripts\python.exe main.py ``` -填写抓包得到的登录地址、账号信息、运营商和身份组。也可以通过 `CAMPUSNET_CONFIG` 指定其他配置文件路径。 +程序不会修改 Windows 路由、网卡随机 MAC、sing-box 或 v2rayN 配置,也不会保存固定 Cookie、`sessionId` 或 ticket。若系统代理接管所有流量,接口绑定仍会让新版 HTTP socket 直接使用配置的校园网接口。 -## 运行 +## 旧版 ePortal + +旧版也使用扁平配置,不再手写 Cookie 和 Header: + +```json +{ + "version": 1, + "login_url": "http://你的校园网门户/eportal/InterFace.do?method=login", + "username": "你的学号", + "encrypted_password": "你的加密后密码", + "carrier": "旧门户中的运营商值", + "user_group": "旧门户中的身份组", + "session_id": "从旧门户取得的 JSESSIONID" +} +``` + +`user_group` 和 `session_id` 可省略;`encrypted_password` 必须是旧门户原本使用的加密值,不能换成新版明文密码。`carrier`、`user_group` 按旧门户原值填写,不自动解码或重新编码。可直接复制示例: + +```powershell +Copy-Item -LiteralPath config.legacy.example.json -Destination config.json +``` + +兼容规则:没有 `version` 的配置不会被自动改写;带 `adapter: "captive-sso-http"` 的嵌套新版配置继续可用,带 `adapter: "legacy-eportal"` 或没有 `adapter` 的原始 Cookie 配置继续按旧版加载。新建配置只需用 `version` 选择协议。旧版模式仅适用于 `/eportal/InterFace.do?method=login` 协议,不能用于当前聚合 SSO 门户。 + +## 开发与构建 本项目使用 `uv` 管理 Python 3.11+ 环境: ```powershell -uv sync -uv run python main.py +uv sync --group dev +& .\.venv\Scripts\python.exe -m unittest discover -s tests -p "test_*.py" -v +& .\.venv\Scripts\python.exe -m PyInstaller --clean --noconfirm build.spec +& .\.venv\Scripts\python.exe -m PyInstaller --clean --noconfirm gui.spec ``` -## 构建 +子项目在 `pyproject.toml` 中声明自己的 uv workspace 边界,因此 `uv sync` 不会继续向上扫描仓库根仅用于 Ruff 的 `pyproject.toml`。同步后直接使用该环境的 Python,也不会让后续每条测试或构建命令重新触发项目发现,从而避免与项目构建无关的“缺少 `[project]`”警告。 + +`build.spec` 继续生成控制台版 `dist\Auto-Connect-CampusNet.exe`,`gui.spec` 生成无控制台窗口版 `dist\Auto-Connect-CampusNet-GUI.exe`。两个 spec 都不会把 `config.json` 打进 EXE,发布物也不应携带用户配置或备份。 + +构建 GUI 后可运行不读取配置、不访问网络的窗口冒烟检查: ```powershell -uv run pyinstaller --clean --noconfirm build.spec +$gui = [IO.Path]::GetFullPath(".\dist\Auto-Connect-CampusNet-GUI.exe") +$smokeConfig = Join-Path ([IO.Path]::GetTempPath()) "campus-net-gui-smoke-$PID.json" +$startInfo = [Diagnostics.ProcessStartInfo]::new() +$startInfo.FileName = $gui +$startInfo.Arguments = "--smoke-test --config=`"$smokeConfig`"" +$startInfo.UseShellExecute = $false +$startInfo.CreateNoWindow = $true +$process = [Diagnostics.Process]::Start($startInfo) +if (-not $process.WaitForExit(60000)) { + $process.Kill() + $process.WaitForExit() + throw "GUI smoke test timed out" +} +$exitCode = $process.ExitCode +$process.Dispose() +if ($exitCode -ne 0) { + throw "GUI smoke test failed with exit code $exitCode" +} +if (Test-Path -LiteralPath $smokeConfig) { + throw "GUI smoke test unexpectedly created $smokeConfig" +} +Start-Sleep -Milliseconds 500 +$remaining = @(Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $gui }) +if ($remaining.Count -ne 0) { + throw "GUI smoke test left $($remaining.Count) process(es) running" +} ``` -`workflows/build.example.yml` 是 monorepo 下的失活示例,不会被 GitHub Actions 自动执行。需要发布时,应将它复制到仓库根 `.github/workflows/` 并复核版本标签与工作目录。 +这里使用 `ProcessStartInfo.Arguments`,同时兼容 Windows PowerShell 5.1 和 PowerShell 7;不要改用仅在较新 .NET 中存在的 `ArgumentList` 属性,否则参数可能没有传给程序,冒烟检查会误启动普通 GUI。 + +`workflows/build.example.yml` 是 monorepo 下的失活构建示例,不会被 GitHub Actions 自动执行。示例会运行测试、构建并冒烟检查两个 EXE、生成 SHA-256 清单,再上传以提交哈希命名的 Actions artifact;启用时应复制为仓库根 `.github/workflows/project-campus-net-ci.yml`。它不会创建标签或 GitHub Release;正式发布需要另行实现符合仓库发布规则的项目级 release 工作流。 ## License diff --git a/workbench/network/campus-net/assets/app-icon.ico b/workbench/network/campus-net/assets/app-icon.ico new file mode 100644 index 0000000..3b2a65c Binary files /dev/null and b/workbench/network/campus-net/assets/app-icon.ico differ diff --git a/workbench/network/campus-net/assets/app-icon.png b/workbench/network/campus-net/assets/app-icon.png new file mode 100644 index 0000000..8f697b7 Binary files /dev/null and b/workbench/network/campus-net/assets/app-icon.png differ diff --git a/workbench/network/campus-net/assets/cli-icon.ico b/workbench/network/campus-net/assets/cli-icon.ico new file mode 100644 index 0000000..aebf8a4 Binary files /dev/null and b/workbench/network/campus-net/assets/cli-icon.ico differ diff --git a/workbench/network/campus-net/assets/cli-icon.png b/workbench/network/campus-net/assets/cli-icon.png new file mode 100644 index 0000000..5fcbde6 Binary files /dev/null and b/workbench/network/campus-net/assets/cli-icon.png differ diff --git a/workbench/network/campus-net/build.spec b/workbench/network/campus-net/build.spec index 2f8b438..561a87f 100644 --- a/workbench/network/campus-net/build.spec +++ b/workbench/network/campus-net/build.spec @@ -2,7 +2,7 @@ a = Analysis( - ['main.py'], + ["main.py"], pathex=[], binaries=[], datas=[], @@ -22,7 +22,7 @@ exe = EXE( a.binaries, a.datas, [], - name='Auto-Connect-CampusNet', + name="Auto-Connect-CampusNet", debug=False, bootloader_ignore_signals=False, strip=False, @@ -33,6 +33,7 @@ exe = EXE( disable_windowed_traceback=False, argv_emulation=False, target_arch=None, + icon="assets/cli-icon.ico", codesign_identity=None, entitlements_file=None, ) diff --git a/workbench/network/campus-net/campus_net/__init__.py b/workbench/network/campus-net/campus_net/__init__.py new file mode 100644 index 0000000..704ff36 --- /dev/null +++ b/workbench/network/campus-net/campus_net/__init__.py @@ -0,0 +1 @@ +"""Campus network portal adapters.""" diff --git a/workbench/network/campus-net/campus_net/aggregate.py b/workbench/network/campus-net/campus_net/aggregate.py new file mode 100644 index 0000000..e5829d3 --- /dev/null +++ b/workbench/network/campus-net/campus_net/aggregate.py @@ -0,0 +1,573 @@ +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import json +import secrets +import time +from collections.abc import Awaitable, Callable, Collection, Mapping +from dataclasses import dataclass +from urllib.parse import ParseResult, parse_qsl, quote, urlencode, urljoin, urlparse + +import aiohttp + +from .captive import NetworkState, ProbeResult, probe_connectivity +from .config import CaptiveHttpConfig +from .sso import ( + MAX_CAPTCHA_BYTES, + CaptchaRequirement, + LoginResultType, + SsoPage, + SsoProtocolError, + build_login_form, + ensure_supported_challenge, + parse_captcha_requirement, + parse_login_response, + parse_sso_page, + resolve_captcha_url, +) + +PORTAL_MAIN_PATH = "/portal/portal-main" +PORTAL_HEADERS = {"isPortal": "true"} +CAPTCHA_POLICY_PATH = "/sam-sso/api/protected/user/findCaptchaCount/" +DEFAULT_CAPTCHA_SWITCH = "DEFAULT_CAPTCHA_SWITCH" +CSRF_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + +CaptchaProvider = Callable[[bytes, str], Awaitable[str]] + + +class PortalProtocolError(RuntimeError): + pass + + +class PortalTransientError(PortalProtocolError): + pass + + +class AuthenticationRejected(RuntimeError): + pass + + +class AuthenticationUncertain(RuntimeError): + pass + + +@dataclass(frozen=True) +class PortalContext: + session_id: str + custom_page_id: str + nas_ip: str + user_ip: str + user_mac: str + current_node_path: str + + +@dataclass(frozen=True) +class InitializationResult: + probe: ProbeResult + context: PortalContext | None = None + + +class AggregatePortalClient: + def __init__( + self, + session: aiohttp.ClientSession, + config: CaptiveHttpConfig, + ) -> None: + self.session = session + self.config = config + + async def initialize(self) -> InitializationResult: + probe = await probe_connectivity(self.session, self.config) + if probe.state is not NetworkState.CAPTIVE: + return InitializationResult(probe=probe) + if probe.captive_url is None: + raise PortalProtocolError("captive 探测没有返回入口 URL") + + async with self.session.get( + probe.captive_url, + allow_redirects=False, + ) as response: + location = response.headers.get("Location", "") + if response.status != 302 or not location: + raise PortalProtocolError("captive 入口没有返回预期的 302") + + portal_main_url = urljoin(probe.captive_url, location) + self._require_portal_url(portal_main_url, expected_path=PORTAL_MAIN_PATH) + parameters = _unique_query_parameters(portal_main_url) + required = ("sessionId", "customPageId", "nasIp", "userIp", "userMac") + missing = [key for key in required if not parameters.get(key)] + if missing: + raise PortalProtocolError("门户初始化重定向缺少参数:" + ", ".join(sorted(missing))) + + async with self.session.get(portal_main_url, allow_redirects=False) as response: + if response.status != 200: + raise PortalProtocolError(f"portal-main 返回意外状态码 {response.status}") + await response.content.read(64 * 1024) + + session_id = parameters["sessionId"] + await self._query_terminal(session_id) + await self._get_identity_config(session_id) + current_node = await self.get_current_node(session_id) + return InitializationResult( + probe=probe, + context=PortalContext( + session_id=session_id, + custom_page_id=parameters["customPageId"], + nas_ip=parameters["nasIp"], + user_ip=parameters["userIp"], + user_mac=parameters["userMac"], + current_node_path=current_node, + ), + ) + + async def authenticate( + self, + context: PortalContext, + *, + username: str, + password: str, + captcha_provider: CaptchaProvider, + ) -> None: + page = await self._load_sso_page(context) + captcha_error_count = 0 + captcha_required_by_previous_error = False + is_first_submission = True + while True: + ensure_supported_challenge(page) + if is_first_submission: + requirement = await self._get_captcha_requirement(DEFAULT_CAPTCHA_SWITCH) + else: + requirement = await self._get_captcha_requirement(username) + if is_first_submission and not requirement.required: + requirement = await self._get_captcha_requirement(username) + if captcha_required_by_previous_error and not requirement.required: + raise PortalProtocolError( + "服务端刚拒绝了验证码,但新策略没有返回可用图片;未继续提交" + ) + captcha_code = "" + if requirement.required: + image, content_type = await self._load_captcha(page, requirement) + captcha_code = (await captcha_provider(image, content_type)).strip() + if not captcha_code: + raise AuthenticationRejected("未提供验证码") + + form = build_login_form( + page, + username=username, + password=password, + captcha_code=captcha_code, + captcha_required=requirement.required, + ) + try: + async with self.session.post( + page.action_url, + data=form, + headers={ + "Origin": self.config.portal_origin, + "Referer": page.page_url, + }, + allow_redirects=False, + ) as response: + body = await _read_limited_body( + response, + 512 * 1024, + operation="SSO 登录响应", + ) + try: + result = parse_login_response( + status=response.status, + headers=response.headers, + body=body, + page_url=page.page_url, + ) + except SsoProtocolError as error: + raise AuthenticationUncertain( + "SSO POST 已返回但响应无法确认;未自动重放请求" + ) from error + except (aiohttp.ClientError, TimeoutError) as error: + raise AuthenticationUncertain("SSO POST 结果未知;未自动重放请求") from error + + if result.result_type is LoginResultType.SUCCESS: + if result.redirect_url is not None: + try: + async with self.session.get( + result.redirect_url, + headers={"Referer": page.action_url}, + allow_redirects=False, + ) as response: + await response.content.read(64 * 1024) + if response.status != 200: + raise AuthenticationUncertain( + "SSO 成功回调状态未知;未自动重放登录请求" + ) + except (aiohttp.ClientError, TimeoutError) as error: + raise AuthenticationUncertain( + "SSO 成功回调失败;未自动重放登录请求" + ) from error + return + if result.result_type is LoginResultType.CAPTCHA_ERROR: + if requirement.required: + captcha_error_count += 1 + if ( + self.config.captcha_attempts > 0 + and captcha_error_count >= self.config.captcha_attempts + ): + break + page = result.next_page or await self._load_sso_page(context) + captcha_required_by_previous_error = True + is_first_submission = False + continue + if result.result_type is LoginResultType.FAILURE: + error_message = result.error_message.strip() + for secret, replacement in ( + (username, "[账号]"), + (password, "[密码]"), + (captcha_code, "[验证码]"), + ): + if secret: + error_message = error_message.replace(secret, replacement) + if len(error_message) > 200: + error_message = error_message[:200] + "…" + if result.error_code and error_message: + detail = f"{result.error_code}:{error_message}" + else: + detail = result.error_code or error_message or "未提供错误详情" + raise AuthenticationRejected(f"SSO 拒绝登录:{detail}") + raise AuthenticationUncertain("无法确认 SSO POST 的结果;未自动重放请求") + + raise AuthenticationRejected("验证码尝试次数已用完") + + async def wait_for_node( + self, + session_id: str, + expected_paths: str | Collection[str], + *, + timeout_seconds: float = 8, + ) -> str: + paths = ( + frozenset({expected_paths}) + if isinstance(expected_paths, str) + else frozenset(expected_paths) + ) + if not paths: + raise ValueError("expected_paths 不能为空") + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout_seconds + while loop.time() < deadline: + try: + current_path = await self.get_current_node(session_id) + except (PortalTransientError, aiohttp.ClientError, TimeoutError): + await asyncio.sleep(0.5) + continue + if current_path in paths: + return current_path + await asyncio.sleep(0.5) + expected = ", ".join(sorted(paths)) + raise PortalProtocolError(f"门户工作流没有进入以下节点之一:{expected}") + + async def select_service(self, session_id: str, display_name: str) -> None: + response = await self._portal_json( + "POST", + "/eportal/network/serviceSelection", + json={"sessionId": session_id}, + ) + data = _require_success_data(response, "运营商列表") + if not isinstance(data, list): + raise PortalProtocolError("运营商列表 data 不是数组") + + matches = [ + item + for item in data + if isinstance(item, dict) + and isinstance(item.get("key"), str) + and item["key"].strip() == display_name + and isinstance(item.get("value"), str) + and item["value"] + ] + if len(matches) != 1: + raise PortalProtocolError(f"运营商显示名 {display_name!r} 的匹配数量不是 1") + + try: + result = await self._portal_json( + "POST", + "/eportal/network/serviceLogin", + json={"sessionId": session_id, "service": matches[0]["value"]}, + ) + except (PortalProtocolError, aiohttp.ClientError, TimeoutError) as error: + raise AuthenticationUncertain("运营商认证 POST 结果未知;未自动重放请求") from error + try: + result_data = _require_success_data(result, "运营商认证") + except PortalProtocolError as error: + raise AuthenticationRejected("运营商认证被门户明确拒绝") from error + if not isinstance(result_data, dict) or result_data.get("authResult") != "success": + raise AuthenticationRejected("运营商认证没有返回 success") + + async def trigger_online_check(self, session_id: str) -> None: + try: + response = await self._portal_json( + "POST", + "/eportal/network/userOnline", + json={"sessionId": session_id}, + ) + except (PortalProtocolError, aiohttp.ClientError, TimeoutError) as error: + raise AuthenticationUncertain("门户在线检查 POST 结果未知;未自动重放请求") from error + if not isinstance(response, Mapping): + raise AuthenticationUncertain("门户在线检查响应无法确认;未自动重放请求") + if response.get("code") != 200: + raise AuthenticationRejected("门户在线检查被明确拒绝") + data = response.get("data") + if not isinstance(data, dict) or data.get("online") is not True: + raise AuthenticationRejected("门户在线检查明确返回离线") + + async def verify_online(self, session_id: str) -> bool: + loop = asyncio.get_running_loop() + deadline = loop.time() + self.config.verification_timeout_seconds + while loop.time() < deadline: + try: + portal_online = await self._is_portal_online(session_id) + except (PortalTransientError, aiohttp.ClientError, TimeoutError): + portal_online = False + probe = await probe_connectivity(self.session, self.config) + if portal_online and probe.state is NetworkState.ONLINE: + return True + await asyncio.sleep(self.config.verification_interval_seconds) + return False + + async def get_current_node(self, session_id: str) -> str: + response = await self._portal_json( + "POST", + "/eportal/workFlow/getCurrentNode", + json={"sessionId": session_id, "flowKey": "portal_auth"}, + ) + data = _require_success_data(response, "当前工作流节点") + if not isinstance(data, dict) or not isinstance(data.get("currentNodePath"), str): + raise PortalProtocolError("当前工作流节点缺少 currentNodePath") + return data["currentNodePath"] + + async def _load_sso_page(self, context: PortalContext) -> SsoPage: + query = urlencode( + { + "flowSessionId": context.session_id, + "customPageId": context.custom_page_id, + "preview": "false", + "appType": "normal", + "language": "zh-CN", + "nasIp": context.nas_ip, + "userIp": context.user_ip, + "userMac": context.user_mac, + } + ) + page_url = urljoin(self.config.portal_origin, "/sam-sso/login") + "?" + query + async with self.session.get(page_url, allow_redirects=False) as response: + if response.status != 200: + raise PortalProtocolError(f"SSO 登录页返回状态码 {response.status}") + body = await _read_limited_body( + response, + 512 * 1024, + operation="SSO 登录页", + ) + return parse_sso_page(body, page_url) + + async def _get_captcha_requirement(self, username: str) -> CaptchaRequirement: + path = CAPTCHA_POLICY_PATH + quote(username, safe="") + policy_url = _append_get_timestamp(urljoin(self.config.portal_origin, path)) + async with self.session.get( + policy_url, + headers=_protected_sso_headers(), + allow_redirects=False, + ) as response: + if response.status != 200: + raise PortalProtocolError(f"验证码策略返回状态码 {response.status}") + body = await _read_limited_body( + response, + 256 * 1024, + operation="验证码策略", + ) + try: + payload = json.loads(body) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise PortalProtocolError("验证码策略返回非 JSON 内容") from error + if not isinstance(payload, Mapping) or payload.get("code") != 200: + code = payload.get("code") if isinstance(payload, Mapping) else None + raise PortalProtocolError(f"验证码策略业务状态不是 200:{code}") + return parse_captcha_requirement(payload) + + async def _load_captcha( + self, + page: SsoPage, + requirement: CaptchaRequirement, + ) -> tuple[bytes, str]: + if requirement.image_path is None: + raise PortalProtocolError("验证码策略没有图片路径") + image_url = _append_get_timestamp( + resolve_captcha_url(page.page_url, requirement.image_path) + ) + headers = _protected_sso_headers() if "protected" in image_url else _sso_headers() + async with self.session.get( + image_url, + headers=headers, + allow_redirects=False, + ) as response: + content_type = response.headers.get("Content-Type", "").split(";", 1)[0] + if response.status != 200 or not content_type.startswith("image/"): + raise PortalProtocolError("验证码图片响应无效") + image = await _read_limited_body( + response, + MAX_CAPTCHA_BYTES, + operation="验证码图片", + ) + return image, content_type + + async def _query_terminal(self, session_id: str) -> None: + response = await self._portal_json( + "GET", + "/eportal/adaptor/queryTerminalInfo", + params={ + "sessionId": session_id, + "macAddr": "", + "_": str(int(time.time() * 1000)), + "version": "this is a git-commit", + }, + ) + data = _require_success_data(response, "终端信息") + if not isinstance(data, dict) or not data.get("ipAddr"): + raise PortalProtocolError("终端信息缺少 IP 地址") + + async def _get_identity_config(self, session_id: str) -> None: + response = await self._portal_json( + "POST", + "/eportal/network/getIdentityConfig", + json={"sessionId": session_id}, + ) + _require_success_data(response, "身份配置") + + async def _is_portal_online(self, session_id: str) -> bool: + response = await self._portal_json( + "GET", + "/eportal/adaptor/getOnlineUserInfo", + params={ + "sessionId": session_id, + "_": str(int(time.time() * 1000)), + "version": "this is a git-commit", + }, + ) + if not isinstance(response, dict) or response.get("code") != 200: + return False + data = response.get("data") + if not isinstance(data, dict) or not data.get("onlineUser"): + return False + online_info = data.get("portalOnlineUserInfo") + return isinstance(online_info, dict) and online_info.get("result") == "success" + + async def _portal_json( + self, + method: str, + path: str, + **kwargs: object, + ) -> object: + async with self.session.request( + method, + urljoin(self.config.portal_origin, path), + headers=PORTAL_HEADERS, + allow_redirects=False, + **kwargs, + ) as response: + if response.status != 200: + if response.status >= 500: + raise PortalTransientError(f"门户请求 {path} 暂时返回状态码 {response.status}") + raise PortalProtocolError(f"门户请求 {path} 返回状态码 {response.status}") + try: + return await response.json(content_type=None) + except (aiohttp.ContentTypeError, ValueError) as error: + raise PortalProtocolError(f"门户请求 {path} 返回非 JSON 内容") from error + + def _require_portal_url(self, url: str, *, expected_path: str) -> None: + target = urlparse(url) + origin = urlparse(self.config.portal_origin) + if ( + _normalized_origin(target) != _normalized_origin(origin) + or target.path != expected_path + or target.params + or target.username is not None + or target.password is not None + or target.fragment + ): + raise PortalProtocolError("门户重定向越出配置的 origin 或路径") + + +def _unique_query_parameters(url: str) -> dict[str, str]: + parameters: dict[str, list[str]] = {} + for key, value in parse_qsl(urlparse(url).query, keep_blank_values=True): + parameters.setdefault(key, []).append(value) + duplicates = sorted(key for key, values in parameters.items() if len(values) != 1) + if duplicates: + raise PortalProtocolError("门户重定向包含重复参数:" + ", ".join(duplicates)) + return {key: values[0] for key, values in parameters.items()} + + +def _require_success_data(response: object, operation: str) -> object: + if not isinstance(response, Mapping): + raise PortalProtocolError(f"{operation}响应不是 JSON 对象") + if response.get("code") != 200: + raise PortalProtocolError(f"{operation}响应 code 不是 200") + return response.get("data") + + +async def _read_limited_body( + response: aiohttp.ClientResponse, + limit: int, + *, + operation: str, +) -> bytes: + body = bytearray() + while True: + remaining = limit + 1 - len(body) + chunk = await response.content.read(min(64 * 1024, remaining)) + if not chunk: + break + body.extend(chunk) + if len(body) > limit: + raise PortalProtocolError(f"{operation}响应过大") + return bytes(body) + + +def _append_get_timestamp(url: str) -> str: + separator = "&" if "?" in url else "?" + return f"{url}{separator}{int(time.time() * 1000)}" + + +def _sso_headers() -> dict[str, str]: + return { + "Accept-Language": "zh-CN", + "sid-language": "zh-CN", + } + + +def _protected_sso_headers() -> dict[str, str]: + csrf_key = "".join(secrets.choice(CSRF_ALPHABET) for _ in range(32)) + encoded_key = base64.b64encode(csrf_key.encode("ascii")).decode("ascii") + midpoint = len(encoded_key) // 2 + csrf_source = encoded_key[:midpoint] + encoded_key + encoded_key[midpoint:] + csrf_value = hashlib.md5( + csrf_source.encode("ascii"), + usedforsecurity=False, + ).hexdigest() + return { + **_sso_headers(), + "Csrf-Key": csrf_key, + "Csrf-Value": csrf_value, + } + + +def _normalized_origin(parsed_url: ParseResult) -> tuple[str, str, int]: + if not parsed_url.hostname: + raise PortalProtocolError("门户 URL 缺少 host") + try: + port = parsed_url.port + except ValueError as error: + raise PortalProtocolError("门户 URL 端口无效") from error + if port is None: + port = 80 if parsed_url.scheme.casefold() == "http" else 443 + return parsed_url.scheme.casefold(), parsed_url.hostname.casefold(), port diff --git a/workbench/network/campus-net/campus_net/application.py b/workbench/network/campus-net/campus_net/application.py new file mode 100644 index 0000000..89dd43a --- /dev/null +++ b/workbench/network/campus-net/campus_net/application.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import json +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +import aiohttp + +from .aggregate import ( + AuthenticationRejected, + AuthenticationUncertain, + CaptchaProvider, + PortalProtocolError, +) +from .config import ( + ADAPTER_CAPTIVE_SSO_HTTP, + ADAPTER_LEGACY_EPORTAL, + build_captive_http_config, + get_adapter, +) +from .interactive import CaptchaPromptError, prompt_captcha +from .legacy import run_legacy +from .runner import run_captive_http +from .sso import SsoProtocolError + +StatusReporter = Callable[[str], None] + + +@dataclass(frozen=True) +class RunErrorInfo: + exit_code: int + title: str + message: str + + +async def execute_config( + cfg: dict[str, Any], + *, + probe_only: bool = False, + captcha_provider: CaptchaProvider = prompt_captcha, + status_callback: StatusReporter = print, +) -> int: + adapter = get_adapter(cfg) + if adapter == ADAPTER_CAPTIVE_SSO_HTTP: + config = build_captive_http_config(cfg) + return await run_captive_http( + config, + probe_only=probe_only, + captcha_provider=captcha_provider, + status_callback=status_callback, + ) + if adapter == ADAPTER_LEGACY_EPORTAL: + if probe_only: + status_callback("legacy-eportal 暂不支持 --probe-only。") + return 2 + return await run_legacy(cfg, status_callback=status_callback) + raise ValueError(f"不支持的 adapter:{adapter}") + + +def classify_run_error(error: BaseException) -> RunErrorInfo | None: + if isinstance(error, (FileNotFoundError, json.JSONDecodeError, TypeError, ValueError)): + return RunErrorInfo(1, "配置错误", str(error)) + if isinstance(error, TimeoutError): + return RunErrorInfo( + 3, + "连接失败", + "校园网请求超时,请检查配置的 IPv4 接口和校园网连接。", + ) + if isinstance(error, aiohttp.ClientError): + return RunErrorInfo( + 3, + "连接失败", + "校园网请求失败,请检查配置的 IPv4 接口和校园网连接。", + ) + if isinstance( + error, + ( + AuthenticationRejected, + AuthenticationUncertain, + CaptchaPromptError, + PortalProtocolError, + SsoProtocolError, + ), + ): + return RunErrorInfo(3, "连接失败", str(error)) + return None diff --git a/workbench/network/campus-net/campus_net/captive.py b/workbench/network/campus-net/campus_net/captive.py new file mode 100644 index 0000000..6dbe27f --- /dev/null +++ b/workbench/network/campus-net/campus_net/captive.py @@ -0,0 +1,271 @@ +from __future__ import annotations + +import html +import re +import socket +import struct +import sys +from collections.abc import Mapping +from dataclasses import dataclass +from enum import Enum +from html.parser import HTMLParser +from urllib.parse import parse_qsl, urlparse + +import aiohttp + +from .config import CaptiveHttpConfig, ProbeConfig + +MAX_RESPONSE_BYTES = 64 * 1024 +MAX_CAPTIVE_URL_LENGTH = 4096 +REQUIRED_CAPTIVE_PARAMETERS = frozenset({"wlanuserip", "wlanacname", "nasip", "mac"}) +IP_UNICAST_IF = getattr(socket, "IP_UNICAST_IF", 31) +UNREACHABLE_SOCKET_ERROR_CODES = frozenset({10050, 10051, 10057, 10065, 1231, 1232}) + +LOCATION_ASSIGNMENT = re.compile( + r"(?['\"])(?P[^'\"<>]+)(?P=quote)", + re.IGNORECASE, +) + +AddressInfo = tuple[ + int, + int, + int, + str, + tuple[str, int] | tuple[str, int, int, int], +] + + +class NetworkState(Enum): + ONLINE = "online" + CAPTIVE = "captive" + UNKNOWN = "unknown" + + +class CaptiveParseError(ValueError): + pass + + +@dataclass(frozen=True) +class ResponseSnapshot: + status: int + headers: Mapping[str, str] + body: bytes + + +@dataclass(frozen=True) +class ProbeResult: + state: NetworkState + captive_url: str | None = None + reason: str = "" + + +class _ScriptCollector(HTMLParser): + def __init__(self) -> None: + super().__init__(convert_charrefs=False) + self._is_script = False + self.scripts: list[str] = [] + self._parts: list[str] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag.casefold() == "script": + self._is_script = True + self._parts = [] + + def handle_data(self, data: str) -> None: + if self._is_script: + self._parts.append(data) + + def handle_endtag(self, tag: str) -> None: + if tag.casefold() == "script" and self._is_script: + self.scripts.append("".join(self._parts)) + self._is_script = False + self._parts = [] + + +class WindowsInterfaceSocketFactory: + def __init__(self, interface_index: int) -> None: + if isinstance(interface_index, bool) or not 1 <= interface_index <= 0xFFFFFFFF: + raise ValueError("interface_index 必须是有效的 Windows 接口索引") + self.interface_index = interface_index + + def __call__(self, address_info: AddressInfo) -> socket.socket: + if sys.platform != "win32": + raise OSError("按接口绑定当前仅支持 Windows") + + family, socket_type, protocol = address_info[:3] + if family != socket.AF_INET: + raise OSError("captive-sso-http 当前仅支持 IPv4") + + client_socket = socket.socket(family, socket_type, protocol) + try: + client_socket.setsockopt( + socket.IPPROTO_IP, + IP_UNICAST_IF, + struct.pack("!I", self.interface_index), + ) + except OSError: + client_socket.close() + raise + return client_socket + + +def create_bound_connector(interface_index: int) -> aiohttp.TCPConnector: + return aiohttp.TCPConnector( + family=socket.AF_INET, + force_close=True, + socket_factory=WindowsInterfaceSocketFactory(interface_index), + ) + + +def extract_captive_url( + response_body: bytes, + portal_origin: str, + entry_path: str, +) -> str | None: + if len(response_body) > MAX_RESPONSE_BYTES: + raise CaptiveParseError("探测响应体过大") + + parser = _ScriptCollector() + try: + parser.feed(response_body.decode("utf-8", errors="replace")) + except (UnicodeError, ValueError) as error: + raise CaptiveParseError("无法解析探测响应") from error + + candidates: list[str] = [] + for script in parser.scripts: + for match in LOCATION_ASSIGNMENT.finditer(script): + candidate = html.unescape(match.group("url")) + _validate_captive_url(candidate, portal_origin, entry_path) + candidates.append(candidate) + + unique_candidates = list(dict.fromkeys(candidates)) + if not unique_candidates: + return None + if len(unique_candidates) > 1: + raise CaptiveParseError("探测响应包含多个不同的 captive 入口") + return unique_candidates[0] + + +def classify_response( + snapshot: ResponseSnapshot, + config: CaptiveHttpConfig, +) -> ProbeResult: + try: + captive_url = extract_captive_url( + snapshot.body, + config.portal_origin, + config.portal_entry_path, + ) + except CaptiveParseError as error: + return ProbeResult(NetworkState.UNKNOWN, reason=str(error)) + + if captive_url is not None: + return ProbeResult(NetworkState.CAPTIVE, captive_url=captive_url) + + if _matches_online_fingerprint(snapshot, config.probe): + return ProbeResult(NetworkState.ONLINE) + + return ProbeResult(NetworkState.UNKNOWN, reason="响应不匹配 captive 或精确在线指纹") + + +async def probe_connectivity( + session: aiohttp.ClientSession, + config: CaptiveHttpConfig, +) -> ProbeResult: + timeout = aiohttp.ClientTimeout(total=config.probe.timeout_seconds) + try: + async with session.get( + config.probe.url, + allow_redirects=False, + timeout=timeout, + ) as response: + body = await response.content.read(MAX_RESPONSE_BYTES + 1) + snapshot = ResponseSnapshot( + status=response.status, + headers=dict(response.headers), + body=body, + ) + except (aiohttp.ClientError, TimeoutError) as error: + return ProbeResult(NetworkState.UNKNOWN, reason=_probe_failure_reason(error)) + + return classify_response(snapshot, config) + + +def _probe_failure_reason(error: aiohttp.ClientError | TimeoutError) -> str: + if isinstance(error, TimeoutError): + return "配置接口访问连通性探测地址超时" + + if isinstance(error, aiohttp.ClientConnectorError): + os_error = error.os_error + error_codes = { + getattr(os_error, "errno", None), + getattr(os_error, "winerror", None), + } + if error_codes & UNREACHABLE_SOCKET_ERROR_CODES: + return "配置接口未连接,或该接口当前没有可用路由" + if isinstance(os_error, socket.gaierror): + return "配置接口无法解析连通性探测地址" + return "配置接口无法连接连通性探测地址" + + return "配置接口的连通性探测请求未完成" + + +def _validate_captive_url(candidate: str, portal_origin: str, entry_path: str) -> None: + if len(candidate) > MAX_CAPTIVE_URL_LENGTH: + raise CaptiveParseError("captive 入口 URL 过长") + + parsed = urlparse(candidate) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + raise CaptiveParseError("captive 入口不是完整的 HTTP 或 HTTPS URL") + if parsed.username is not None or parsed.password is not None: + raise CaptiveParseError("captive 入口不能包含用户信息") + if parsed.fragment: + raise CaptiveParseError("captive 入口不能包含片段") + if parsed.params: + raise CaptiveParseError("captive 入口不能包含路径参数") + if _normalized_origin(candidate) != _normalized_origin(portal_origin): + raise CaptiveParseError("captive 入口不属于配置的门户 origin") + if parsed.path != entry_path: + raise CaptiveParseError("captive 入口路径与配置不匹配") + + parameters: dict[str, list[str]] = {} + for key, value in parse_qsl(parsed.query, keep_blank_values=True): + parameters.setdefault(key, []).append(value) + + for key in REQUIRED_CAPTIVE_PARAMETERS: + values = parameters.get(key, []) + if len(values) != 1 or not values[0]: + raise CaptiveParseError(f"captive 入口缺少唯一且非空的 {key}") + + +def _normalized_origin(value: str) -> tuple[str, str, int]: + parsed = urlparse(value) + if not parsed.hostname: + raise CaptiveParseError("门户 origin 缺少 host") + try: + port = parsed.port + except ValueError as error: + raise CaptiveParseError("门户 origin 端口无效") from error + if port is None: + port = 80 if parsed.scheme == "http" else 443 + return parsed.scheme.casefold(), parsed.hostname.casefold(), port + + +def _matches_online_fingerprint(snapshot: ResponseSnapshot, probe: ProbeConfig) -> bool: + if snapshot.status != probe.online_status: + return False + if probe.online_body is not None: + try: + if snapshot.body.decode("utf-8") != probe.online_body: + return False + except UnicodeDecodeError: + return False + if probe.online_location is not None: + location = next( + (value for key, value in snapshot.headers.items() if key.casefold() == "location"), + None, + ) + if location != probe.online_location: + return False + return True diff --git a/workbench/network/campus-net/campus_net/config.py b/workbench/network/campus-net/campus_net/config.py new file mode 100644 index 0000000..106f843 --- /dev/null +++ b/workbench/network/campus-net/campus_net/config.py @@ -0,0 +1,535 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any +from urllib.parse import ParseResult, parse_qsl, urlparse + +ADAPTER_CAPTIVE_SSO_HTTP = "captive-sso-http" +ADAPTER_LEGACY_EPORTAL = "legacy-eportal" +CONFIG_VERSION_LEGACY_EPORTAL = 1 +CONFIG_VERSION_CAPTIVE_SSO = 2 +DEFAULT_CAPTIVE_PROBE_URL = "http://www.msftconnecttest.com/connecttest.txt" +DEFAULT_CAPTIVE_PROBE_STATUS = 200 +DEFAULT_CAPTIVE_PROBE_BODY = "Microsoft Connect Test" +DEFAULT_CAPTIVE_PROBE_TIMEOUT_SECONDS = 8 +DEFAULT_PORTAL_ENTRY_PATH = "/eportal/index.jsp" +DEFAULT_AUTH_MODE = "interactive-system-captcha" +DEFAULT_CAPTCHA_ATTEMPTS = 0 +DEFAULT_VERIFICATION_INTERVAL_SECONDS = 2 +DEFAULT_VERIFICATION_TIMEOUT_SECONDS = 30 +DEFAULT_CONNECTIVITY_CHECK_URL = "http://www.baidu.com" +DEFAULT_USER_AGENT = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/131.0.0.0 Safari/537.36" +) + + +@dataclass(frozen=True) +class ProbeConfig: + url: str + online_status: int + online_body: str | None + online_location: str | None + timeout_seconds: float + + +@dataclass(frozen=True) +class CaptiveHttpConfig: + adapter: str + interface_index: int + user_agent: str + probe: ProbeConfig + portal_origin: str + portal_entry_path: str + auth_mode: str + username: str + password: str = field(repr=False) + service_display_name: str + captcha_attempts: int + verification_interval_seconds: float + verification_timeout_seconds: float + + +def portal_base_url(login_url: str) -> str: + parsed_url = urlparse(login_url) + if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc: + raise ValueError("login_url 必须是完整的 HTTP 或 HTTPS 地址") + return f"{parsed_url.scheme}://{parsed_url.netloc}/" + + +def build_legacy_runtime_from_config(cfg: dict[str, Any]): + if _config_version(cfg) is not None: + adapter = get_adapter(cfg) + if adapter != ADAPTER_LEGACY_EPORTAL: + raise ValueError( + f"legacy-eportal 配置的 version 必须是 {CONFIG_VERSION_LEGACY_EPORTAL}" + ) + return _build_legacy_runtime_v1(cfg) + + return _build_legacy_runtime_compat(cfg) + + +def _build_legacy_runtime_compat(cfg: dict[str, Any]): + login_url = str(cfg.get("login_url", "")).strip() + if not login_url: + raise ValueError("config.json 缺少 login_url") + + parsed_login_url = urlparse(login_url) + portal_url = portal_base_url(login_url) + connectivity_check_url = str( + cfg.get("connectivity_check_url", DEFAULT_CONNECTIVITY_CHECK_URL) + ).strip() + portal_base_url(connectivity_check_url) + + user_cookies = cfg.get("cookies", {}) + if not isinstance(user_cookies, dict): + raise TypeError("cookies 必须是 JSON 对象") + cookies = {**_legacy_default_cookies(), **user_cookies} + user_group = str(cfg.get("user_group", "")).strip() + if user_group and "EPORTAL_USER_GROUP" not in user_cookies: + cookies["EPORTAL_USER_GROUP"] = user_group + + configured_headers = cfg.get("headers", {}) + if not isinstance(configured_headers, dict): + raise TypeError("headers 必须是 JSON 对象") + user_agent = configured_headers.get("User-Agent", DEFAULT_USER_AGENT) + headers = { + "Host": parsed_login_url.hostname or "", + "User-Agent": str(user_agent), + } + jsessionid = cookies.get("JSESSIONID") + if jsessionid: + headers["JSESSIONID"] = str(jsessionid) + + service = str(cfg.get("service", cookies.get("EPORTAL_COOKIE_SERVER", ""))) + return login_url, portal_url, connectivity_check_url, headers, cookies, service + + +def _build_legacy_runtime_v1(cfg: dict[str, Any]): + if "password" in cfg: + raise ValueError("legacy-eportal 使用 encrypted_password,不接受 password") + _reject_unknown_fields( + cfg, + { + "version", + "login_url", + "username", + "encrypted_password", + "carrier", + "user_group", + "session_id", + }, + "legacy-eportal version 1", + ) + + login_url = _require_legacy_login_url(cfg, "login_url") + portal_url = portal_base_url(login_url) + parsed_login_url = urlparse(login_url) + username = _require_nonempty_string(cfg, "username") + encrypted_password = _require_secret_string(cfg, "encrypted_password") + carrier = _require_nonempty_string(cfg, "carrier") + user_group = _optional_trimmed_string(cfg, "user_group") + session_id = _optional_session_id(cfg, "session_id") + + cookies: dict[str, Any] = { + **_legacy_default_cookies(), + "EPORTAL_COOKIE_USERNAME": username, + "EPORTAL_COOKIE_PASSWORD": encrypted_password, + "EPORTAL_COOKIE_SERVER": carrier, + "EPORTAL_COOKIE_SERVER_NAME": carrier, + } + if user_group is not None: + cookies["EPORTAL_USER_GROUP"] = user_group + if session_id is not None: + cookies["JSESSIONID"] = session_id + + headers = { + "Host": parsed_login_url.hostname or "", + "User-Agent": DEFAULT_USER_AGENT, + } + if session_id is not None: + headers["JSESSIONID"] = session_id + + return ( + login_url, + portal_url, + DEFAULT_CONNECTIVITY_CHECK_URL, + headers, + cookies, + carrier, + ) + + +def _legacy_default_cookies() -> dict[str, str]: + return { + "EPORTAL_COOKIE_DOMAIN": "false", + "EPORTAL_COOKIE_SAVEPASSWORD": "true", + "EPORTAL_COOKIE_OPERATORPWD": "", + "EPORTAL_COOKIE_NEWV": "true", + } + + +def get_adapter(cfg: dict[str, Any]) -> str: + version = _config_version(cfg) + if version is not None: + adapters_by_version = { + CONFIG_VERSION_LEGACY_EPORTAL: ADAPTER_LEGACY_EPORTAL, + CONFIG_VERSION_CAPTIVE_SSO: ADAPTER_CAPTIVE_SSO_HTTP, + } + adapter = adapters_by_version.get(version) + if adapter is None: + supported_versions = ", ".join( + str(supported_version) for supported_version in sorted(adapters_by_version) + ) + raise ValueError(f"不支持的 version:{version};可选值:{supported_versions}") + return adapter + + adapter = str(cfg.get("adapter", ADAPTER_LEGACY_EPORTAL)).strip() + if adapter not in {ADAPTER_CAPTIVE_SSO_HTTP, ADAPTER_LEGACY_EPORTAL}: + raise ValueError(f"不支持的 adapter:{adapter}") + return adapter + + +def build_captive_http_config(cfg: dict[str, Any]) -> CaptiveHttpConfig: + adapter = get_adapter(cfg) + if adapter != ADAPTER_CAPTIVE_SSO_HTTP: + if _config_version(cfg) is None: + raise ValueError(f"adapter 必须是 {ADAPTER_CAPTIVE_SSO_HTTP}") + raise ValueError(f"captive SSO 配置的 version 必须是 {CONFIG_VERSION_CAPTIVE_SSO}") + + if _config_version(cfg) is not None: + return _build_captive_http_config_v2(cfg) + + return _build_captive_http_config_compat(cfg) + + +def _build_captive_http_config_v2(cfg: dict[str, Any]) -> CaptiveHttpConfig: + if "encrypted_password" in cfg: + raise ValueError("captive-sso 使用 password,不接受 encrypted_password") + _reject_unknown_fields( + cfg, + { + "version", + "interface_index", + "portal_url", + "username", + "password", + "carrier", + }, + "captive SSO version 2", + ) + + interface_index = _require_interface_index(cfg, "interface_index") + portal_origin = _require_origin(cfg, "portal_url") + username = _require_nonempty_string(cfg, "username") + password = _require_secret_string(cfg, "password") + carrier = _require_nonempty_string(cfg, "carrier") + + return CaptiveHttpConfig( + adapter=ADAPTER_CAPTIVE_SSO_HTTP, + interface_index=interface_index, + user_agent=DEFAULT_USER_AGENT, + probe=ProbeConfig( + url=DEFAULT_CAPTIVE_PROBE_URL, + online_status=DEFAULT_CAPTIVE_PROBE_STATUS, + online_body=DEFAULT_CAPTIVE_PROBE_BODY, + online_location=None, + timeout_seconds=DEFAULT_CAPTIVE_PROBE_TIMEOUT_SECONDS, + ), + portal_origin=portal_origin, + portal_entry_path=DEFAULT_PORTAL_ENTRY_PATH, + auth_mode=DEFAULT_AUTH_MODE, + username=username, + password=password, + service_display_name=carrier, + captcha_attempts=DEFAULT_CAPTCHA_ATTEMPTS, + verification_interval_seconds=DEFAULT_VERIFICATION_INTERVAL_SECONDS, + verification_timeout_seconds=DEFAULT_VERIFICATION_TIMEOUT_SECONDS, + ) + + +def _build_captive_http_config_compat(cfg: dict[str, Any]) -> CaptiveHttpConfig: + adapter = ADAPTER_CAPTIVE_SSO_HTTP + + legacy_fields = { + "cookies", + "headers", + "login_url", + "service", + "user_group", + } + mixed_fields = sorted(legacy_fields.intersection(cfg)) + if mixed_fields: + raise ValueError("captive-sso-http 不接受旧版登录字段:" + ", ".join(mixed_fields)) + + network = _require_object(cfg, "network") + portal = _require_object(cfg, "portal") + auth = _require_object(cfg, "auth") + probe = _require_object(network, "probe") + + interface_index = _require_interface_index(network, "interface_index") + + user_agent = str(network.get("user_agent", DEFAULT_USER_AGENT)).strip() + if not user_agent: + raise ValueError("network.user_agent 不能为空") + + probe_url = _require_url(probe, "url", allowed_schemes={"http"}) + online_status = _require_integer( + probe, + "online_status", + minimum=100, + maximum=599, + ) + online_body = _optional_nonempty_string(probe, "online_body") + online_location = _optional_nonempty_string(probe, "online_location") + if online_location is not None: + _validate_absolute_url(online_location, "network.probe.online_location") + if online_body is None and online_location is None: + raise ValueError("network.probe 至少需要 online_body 或 online_location 作为精确在线指纹") + probe_timeout = _optional_number( + probe, + "timeout_seconds", + default=8, + minimum=0.1, + maximum=60, + ) + + portal_origin = _require_origin(portal, "origin") + portal_entry_path = str(portal.get("entry_path", DEFAULT_PORTAL_ENTRY_PATH)).strip() + if not portal_entry_path.startswith("/"): + raise ValueError("portal.entry_path 必须是以 / 开头的绝对路径") + if "?" in portal_entry_path or "#" in portal_entry_path: + raise ValueError("portal.entry_path 不能包含查询参数或片段") + + auth_mode = str(auth.get("mode", "")).strip() + if auth_mode != DEFAULT_AUTH_MODE: + raise ValueError("当前仅支持 auth.mode=interactive-system-captcha") + username = _require_nonempty_string(auth, "username") + if "password_env" in auth: + raise ValueError("auth.password_env 已移除,请改用 auth.password") + password = _require_secret_string(auth, "password") + service_display_name = _require_nonempty_string(auth, "service_display_name") + captcha_attempts = _require_integer( + auth, + "captcha_attempts", + minimum=0, + maximum=100, + default=DEFAULT_CAPTCHA_ATTEMPTS, + ) + verification_interval = _optional_number( + auth, + "verification_interval_seconds", + default=DEFAULT_VERIFICATION_INTERVAL_SECONDS, + minimum=0.1, + maximum=60, + ) + verification_timeout = _optional_number( + auth, + "verification_timeout_seconds", + default=DEFAULT_VERIFICATION_TIMEOUT_SECONDS, + minimum=verification_interval, + maximum=600, + ) + + return CaptiveHttpConfig( + adapter=adapter, + interface_index=interface_index, + user_agent=user_agent, + probe=ProbeConfig( + url=probe_url, + online_status=online_status, + online_body=online_body, + online_location=online_location, + timeout_seconds=probe_timeout, + ), + portal_origin=portal_origin, + portal_entry_path=portal_entry_path, + auth_mode=auth_mode, + username=username, + password=password, + service_display_name=service_display_name, + captcha_attempts=captcha_attempts, + verification_interval_seconds=verification_interval, + verification_timeout_seconds=verification_timeout, + ) + + +def _config_version(cfg: dict[str, Any]) -> int | None: + removed_fields = sorted({"schema_version", "mode"}.intersection(cfg)) + if removed_fields: + raise ValueError( + "已移除字段:" + ", ".join(removed_fields) + ";请只使用 version=1 或 version=2" + ) + if "version" not in cfg: + flat_fields = { + "interface_index", + "portal_url", + "username", + "password", + "carrier", + "encrypted_password", + "session_id", + } + if flat_fields.intersection(cfg): + raise ValueError("扁平配置必须提供 version=1 或 version=2") + return None + + version = cfg["version"] + if isinstance(version, bool) or not isinstance(version, int): + raise TypeError("version 必须是整数") + return version + + +def _reject_unknown_fields( + container: dict[str, Any], + allowed_fields: set[str], + context: str, +) -> None: + unknown_fields = sorted(set(container).difference(allowed_fields)) + if unknown_fields: + raise ValueError(f"{context} 不支持以下字段:" + ", ".join(unknown_fields)) + + +def _require_object(container: dict[str, Any], key: str) -> dict[str, Any]: + value = container.get(key) + if not isinstance(value, dict): + raise TypeError(f"{key} 必须是 JSON 对象") + return value + + +def _require_integer( + container: dict[str, Any], + key: str, + *, + minimum: int, + maximum: int | None = None, + default: int | None = None, +) -> int: + value = container.get(key, default) + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError(f"{key} 必须是整数") + if value < minimum or (maximum is not None and value > maximum): + bounds = f"{minimum}..{maximum}" if maximum is not None else f">={minimum}" + raise ValueError(f"{key} 必须在 {bounds} 范围内") + return value + + +def _require_nonempty_string(container: dict[str, Any], key: str) -> str: + value = container.get(key) + if not isinstance(value, str) or not value.strip(): + raise TypeError(f"{key} 必须是非空字符串") + return value.strip() + + +def _require_secret_string(container: dict[str, Any], key: str) -> str: + value = container.get(key) + if not isinstance(value, str) or not value: + raise TypeError(f"{key} 必须是非空字符串") + return value + + +def _optional_trimmed_string(container: dict[str, Any], key: str) -> str | None: + value = container.get(key) + if value is None: + return None + if not isinstance(value, str) or not value.strip(): + raise TypeError(f"{key} 必须是非空字符串") + return value.strip() + + +def _optional_session_id(container: dict[str, Any], key: str) -> str | None: + value = container.get(key) + if value is None: + return None + if not isinstance(value, str) or not value.strip(): + raise TypeError(f"{key} 必须是非空字符串") + if any(ord(character) < 0x20 or ord(character) == 0x7F for character in value): + raise ValueError(f"{key} 不能包含控制字符") + return value.strip() + + +def _require_interface_index(container: dict[str, Any], key: str) -> int: + interface_index = _require_integer(container, key, minimum=1) + if interface_index > 0xFFFFFFFF: + raise ValueError(f"{key} 超出 Windows 接口索引范围") + return interface_index + + +def _optional_number( + container: dict[str, Any], + key: str, + *, + default: float, + minimum: float, + maximum: float, +) -> float: + value = container.get(key, default) + if isinstance(value, bool) or not isinstance(value, int | float): + raise TypeError(f"{key} 必须是数字") + number = float(value) + if not minimum <= number <= maximum: + raise ValueError(f"{key} 必须在 {minimum}..{maximum} 范围内") + return number + + +def _optional_nonempty_string(container: dict[str, Any], key: str) -> str | None: + value = container.get(key) + if value is None: + return None + if not isinstance(value, str) or not value: + raise TypeError(f"{key} 必须是非空字符串") + return value + + +def _require_url( + container: dict[str, Any], + key: str, + *, + allowed_schemes: set[str], +) -> str: + value = _require_nonempty_string(container, key) + parsed = _validate_absolute_url(value, key) + if parsed.scheme not in allowed_schemes: + schemes = ", ".join(sorted(allowed_schemes)) + raise ValueError(f"{key} 仅允许以下协议:{schemes}") + return value + + +def _require_legacy_login_url(container: dict[str, Any], key: str) -> str: + value = _require_url(container, key, allowed_schemes={"http", "https"}) + parsed = urlparse(value) + method_values = [ + parameter_value + for parameter_name, parameter_value in parse_qsl( + parsed.query, + keep_blank_values=True, + ) + if parameter_name == "method" + ] + if parsed.path != "/eportal/InterFace.do" or method_values != ["login"]: + raise ValueError(f"{key} 必须指向 /eportal/InterFace.do?method=login") + return value + + +def _require_origin(container: dict[str, Any], key: str) -> str: + value = _require_nonempty_string(container, key) + parsed = _validate_absolute_url(value, key) + if parsed.path not in {"", "/"} or parsed.query or parsed.fragment: + raise ValueError(f"{key} 必须只包含 scheme、host 和可选端口") + return f"{parsed.scheme}://{parsed.netloc}" + + +def _validate_absolute_url(value: str, field_name: str) -> ParseResult: + parsed = urlparse(value) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + raise ValueError(f"{field_name} 必须是完整的 HTTP 或 HTTPS 地址") + if parsed.username is not None or parsed.password is not None: + raise ValueError(f"{field_name} 不能包含用户信息") + if parsed.fragment: + raise ValueError(f"{field_name} 不能包含片段") + try: + parsed.port + except ValueError as error: + raise ValueError(f"{field_name} 端口无效") from error + return parsed diff --git a/workbench/network/campus-net/campus_net/config_paths.py b/workbench/network/campus-net/campus_net/config_paths.py new file mode 100644 index 0000000..18f3cf9 --- /dev/null +++ b/workbench/network/campus-net/campus_net/config_paths.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import os +import sys +from collections.abc import Iterator +from pathlib import Path + +CONFIG_ENV = "CAMPUSNET_CONFIG" +PROJECT_MARKERS = ("pyproject.toml", "config.example.json") + + +def packaged_project_root( + executable: str | os.PathLike[str] | None = None, +) -> Path | None: + executable_directory = Path(executable or sys.executable).expanduser().absolute().parent + if executable_directory.name.casefold() != "dist": + return None + project_root = executable_directory.parent + if all((project_root / marker).is_file() for marker in PROJECT_MARKERS): + return project_root + return None + + +def config_candidates( + configured_path: str | os.PathLike[str] | None = None, + *, + frozen: bool | None = None, + executable: str | os.PathLike[str] | None = None, + cwd: str | os.PathLike[str] | None = None, + source_root: str | os.PathLike[str] | None = None, + bundled_directory: str | os.PathLike[str] | None = None, +) -> Iterator[Path]: + configured = configured_path + if configured is None: + configured = os.getenv(CONFIG_ENV) + + is_frozen = getattr(sys, "frozen", False) if frozen is None else frozen + executable_path = Path(executable or sys.executable).expanduser() + working_directory = Path(cwd or Path.cwd()).expanduser() + project_root = Path(source_root or Path(__file__).resolve().parent.parent).expanduser() + bundle_root_value = ( + getattr(sys, "_MEIPASS", None) if bundled_directory is None else bundled_directory + ) + + raw_candidates: list[Path] = [] + if configured: + raw_candidates.append(Path(configured).expanduser()) + + raw_candidates.append(working_directory / "config.json") + + if is_frozen: + executable_directory = executable_path.absolute().parent + raw_candidates.append(executable_directory / "config.json") + packaged_root = packaged_project_root(executable_path) + if packaged_root is not None: + raw_candidates.append(packaged_root / "config.json") + else: + raw_candidates.append(project_root / "config.json") + + if bundle_root_value: + raw_candidates.append(Path(bundle_root_value).expanduser() / "config.json") + + seen: set[str] = set() + for candidate in raw_candidates: + absolute = candidate.absolute() + comparison_key = os.path.normcase(os.path.normpath(str(absolute))) + if comparison_key not in seen: + seen.add(comparison_key) + yield absolute + + +def resolve_config_path( + configured_path: str | os.PathLike[str] | None = None, + **candidate_overrides: object, +) -> Path: + if configured_path: + explicit_path = Path(configured_path).expanduser().absolute() + if explicit_path.is_file(): + return explicit_path + raise FileNotFoundError(f"指定的配置文件不存在或不是普通文件:{explicit_path}") + + candidates = list(config_candidates(configured_path, **candidate_overrides)) + config_path = next((path for path in candidates if path.is_file()), None) + if config_path is not None: + return config_path + + searched_paths = "\n".join(f"- {path}" for path in candidates) + raise FileNotFoundError( + f"未找到 config.json。请创建本地配置或设置 CAMPUSNET_CONFIG。\n已检查:\n{searched_paths}" + ) + + +def preferred_config_path( + configured_path: str | os.PathLike[str] | None = None, + **candidate_overrides: object, +) -> Path: + if configured_path: + return Path(configured_path).expanduser().absolute() + + candidates = list(config_candidates(configured_path, **candidate_overrides)) + existing_path = next((path for path in candidates if path.is_file()), None) + if existing_path is not None: + return existing_path + if not candidates: + raise RuntimeError("没有可用的配置路径") + return candidates[0] diff --git a/workbench/network/campus-net/campus_net/config_store.py b/workbench/network/campus-net/campus_net/config_store.py new file mode 100644 index 0000000..4e9534a --- /dev/null +++ b/workbench/network/campus-net/campus_net/config_store.py @@ -0,0 +1,511 @@ +from __future__ import annotations + +import hashlib +import hmac +import json +import os +import stat +import uuid +from collections.abc import Mapping +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from tempfile import NamedTemporaryFile +from typing import Any, Literal, TypeAlias + +from .config import ( + CONFIG_VERSION_CAPTIVE_SSO, + CONFIG_VERSION_LEGACY_EPORTAL, + build_captive_http_config, + build_legacy_runtime_from_config, +) + +MAX_CONFIG_BYTES = 64 * 1024 + +PathInput: TypeAlias = str | os.PathLike[str] + + +class ConfigStoreError(RuntimeError): + pass + + +class ConfigFormatError(ConfigStoreError): + pass + + +class ConfigTooLargeError(ConfigFormatError): + pass + + +class ConfigPathError(ConfigStoreError): + pass + + +class ConfigRevisionError(ConfigStoreError): + pass + + +class ConfigIntegrityError(ConfigStoreError): + pass + + +class ConfigCommitUncertainError(ConfigStoreError): + def __init__( + self, + message: str, + *, + backup: BackupReceipt | None, + ) -> None: + super().__init__(message) + self.backup = backup + + +@dataclass(frozen=True, slots=True) +class LegacyConfigV1: + login_url: str + username: str = field(repr=False) + encrypted_password: str = field(repr=False) + carrier: str + user_group: str | None = None + session_id: str | None = field(default=None, repr=False) + version: Literal[1] = field( + default=CONFIG_VERSION_LEGACY_EPORTAL, + init=False, + ) + + +@dataclass(frozen=True, slots=True) +class CaptiveConfigV2: + interface_index: int + portal_url: str + username: str = field(repr=False) + password: str = field(repr=False) + carrier: str + version: Literal[2] = field( + default=CONFIG_VERSION_CAPTIVE_SSO, + init=False, + ) + + +EditableConfig: TypeAlias = LegacyConfigV1 | CaptiveConfigV2 + + +@dataclass(frozen=True, slots=True) +class ConfigRevision: + exists: bool + sha256: str | None + + def __post_init__(self) -> None: + if self.exists: + if self.sha256 is None or not _is_sha256(self.sha256): + raise ValueError("存在的配置必须提供有效 SHA-256") + object.__setattr__(self, "sha256", self.sha256.casefold()) + elif self.sha256 is not None: + raise ValueError("不存在的配置不能携带 SHA-256") + + @classmethod + def absent(cls) -> ConfigRevision: + return cls(exists=False, sha256=None) + + @classmethod + def from_bytes(cls, content: bytes) -> ConfigRevision: + return cls(exists=True, sha256=_sha256_bytes(content)) + + +@dataclass(frozen=True, slots=True) +class LoadedConfig: + path: Path + revision: ConfigRevision + config: EditableConfig = field(repr=False) + + +@dataclass(frozen=True, slots=True) +class BackupReceipt: + path: Path + sha256: str + size: int + + +@dataclass(frozen=True, slots=True) +class SaveReceipt: + path: Path + revision: ConfigRevision + backup: BackupReceipt | None + changed: bool + + +@dataclass(frozen=True, slots=True) +class _FileSnapshot: + revision: ConfigRevision + content: bytes | None = field(repr=False) + + +def load_editable_config( + path: PathInput, + *, + max_bytes: int = MAX_CONFIG_BYTES, +) -> LoadedConfig: + config_path = _absolute_path(path) + try: + content = _read_regular_file(config_path, max_bytes=max_bytes) + except FileNotFoundError: + raise ConfigPathError(f"配置文件不存在:{config_path}") from None + + config = _parse_config_bytes(content) + return LoadedConfig( + path=config_path, + revision=ConfigRevision.from_bytes(content), + config=config, + ) + + +def config_from_mapping(raw_config: Mapping[str, Any]) -> EditableConfig: + return _parse_formal_mapping(dict(raw_config)) + + +def config_to_mapping(config: EditableConfig) -> dict[str, Any]: + return _mapping_from_config(config) + + +def save_editable_config( + config: EditableConfig, + *, + path: PathInput, + backup_dir: PathInput, + expected_revision: ConfigRevision, + max_bytes: int = MAX_CONFIG_BYTES, +) -> SaveReceipt: + config_path = _absolute_path(path) + backup_directory = _absolute_path(backup_dir) + content = _encode_config(config) + if len(content) > max_bytes: + raise ConfigTooLargeError(f"配置大小超过限制:{len(content)} > {max_bytes} 字节") + + parent = config_path.parent + if not parent.is_dir(): + raise ConfigPathError(f"配置目录不存在:{parent}") + + initial_snapshot = _snapshot(config_path, max_bytes=max_bytes) + _require_revision(initial_snapshot.revision, expected_revision) + + if initial_snapshot.content == content: + return SaveReceipt( + path=config_path, + revision=initial_snapshot.revision, + backup=None, + changed=False, + ) + + backup = None + if initial_snapshot.content is not None: + backup = _create_verified_backup( + source_path=config_path, + source_content=initial_snapshot.content, + backup_directory=backup_directory, + ) + + temporary_path = _write_fsynced_temporary_file( + parent, + prefix=f".{config_path.name}.", + content=content, + ) + expected_content_hash = _sha256_bytes(content) + try: + temporary_hash = _sha256_file(temporary_path) + if not hmac.compare_digest(temporary_hash, expected_content_hash): + raise ConfigIntegrityError("配置临时文件 SHA-256 校验失败") + + current_snapshot = _snapshot(config_path, max_bytes=max_bytes) + _require_revision(current_snapshot.revision, initial_snapshot.revision) + + os.replace(temporary_path, config_path) + temporary_path = None + finally: + if temporary_path is not None: + temporary_path.unlink(missing_ok=True) + + try: + _fsync_directory(parent) + final_snapshot = _snapshot(config_path, max_bytes=max_bytes) + if final_snapshot.content is None or not hmac.compare_digest( + final_snapshot.revision.sha256 or "", + expected_content_hash, + ): + raise ConfigIntegrityError("配置原子写入后的 SHA-256 校验失败") + except (OSError, ConfigStoreError) as error: + raise ConfigCommitUncertainError( + "配置已执行原子替换,但最终落盘状态无法确认", + backup=backup, + ) from error + + return SaveReceipt( + path=config_path, + revision=final_snapshot.revision, + backup=backup, + changed=True, + ) + + +def _parse_config_bytes(content: bytes) -> EditableConfig: + try: + text = content.decode("utf-8") + except UnicodeDecodeError as error: + raise ConfigFormatError(f"配置不是有效 UTF-8,错误位置:{error.start}") from None + + try: + raw_config = json.loads(text, object_pairs_hook=_object_without_duplicates) + except json.JSONDecodeError as error: + raise ConfigFormatError( + f"配置不是有效 JSON:第 {error.lineno} 行,第 {error.colno} 列" + ) from None + except _DuplicateKeyError as error: + raise ConfigFormatError(str(error)) from None + + if not isinstance(raw_config, dict): + raise ConfigFormatError("配置顶层必须是 JSON 对象") + return _parse_formal_mapping(raw_config) + + +def _parse_formal_mapping(raw_config: dict[str, Any]) -> EditableConfig: + version = raw_config.get("version") + if isinstance(version, bool) or not isinstance(version, int): + raise ConfigFormatError("正式 GUI 配置必须提供整数 version=1 或 version=2") + + try: + if version == CONFIG_VERSION_LEGACY_EPORTAL: + build_legacy_runtime_from_config(raw_config) + return LegacyConfigV1( + login_url=str(raw_config["login_url"]).strip(), + username=str(raw_config["username"]).strip(), + encrypted_password=str(raw_config["encrypted_password"]), + carrier=str(raw_config["carrier"]).strip(), + user_group=_optional_trimmed_value(raw_config, "user_group"), + session_id=_optional_trimmed_value(raw_config, "session_id"), + ) + if version == CONFIG_VERSION_CAPTIVE_SSO: + runtime_config = build_captive_http_config(raw_config) + return CaptiveConfigV2( + interface_index=runtime_config.interface_index, + portal_url=runtime_config.portal_origin, + username=runtime_config.username, + password=runtime_config.password, + carrier=runtime_config.service_display_name, + ) + except (KeyError, TypeError, ValueError) as error: + raise ConfigFormatError(str(error)) from None + + raise ConfigFormatError(f"不支持的配置 version:{version}") + + +def _encode_config(config: EditableConfig) -> bytes: + raw_config = config_to_mapping(config) + normalized_config = _parse_formal_mapping(raw_config) + normalized_mapping = config_to_mapping(normalized_config) + text = json.dumps(normalized_mapping, ensure_ascii=False, indent=2) + "\n" + return text.encode("utf-8") + + +def _mapping_from_config(config: EditableConfig) -> dict[str, Any]: + if isinstance(config, LegacyConfigV1): + raw_config: dict[str, Any] = { + "version": CONFIG_VERSION_LEGACY_EPORTAL, + "login_url": config.login_url, + "username": config.username, + "encrypted_password": config.encrypted_password, + "carrier": config.carrier, + } + if config.user_group is not None: + raw_config["user_group"] = config.user_group + if config.session_id is not None: + raw_config["session_id"] = config.session_id + return raw_config + + if isinstance(config, CaptiveConfigV2): + return { + "version": CONFIG_VERSION_CAPTIVE_SSO, + "interface_index": config.interface_index, + "portal_url": config.portal_url, + "username": config.username, + "password": config.password, + "carrier": config.carrier, + } + + raise TypeError("config 必须是正式的 LegacyConfigV1 或 CaptiveConfigV2") + + +def _optional_trimmed_value( + raw_config: dict[str, Any], + key: str, +) -> str | None: + value = raw_config.get(key) + return None if value is None else str(value).strip() + + +class _DuplicateKeyError(ValueError): + pass + + +def _object_without_duplicates(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise _DuplicateKeyError(f"配置包含重复字段:{key}") + result[key] = value + return result + + +def _snapshot(path: Path, *, max_bytes: int) -> _FileSnapshot: + try: + content = _read_regular_file(path, max_bytes=max_bytes) + except FileNotFoundError: + return _FileSnapshot(revision=ConfigRevision.absent(), content=None) + return _FileSnapshot( + revision=ConfigRevision.from_bytes(content), + content=content, + ) + + +def _read_regular_file(path: Path, *, max_bytes: int) -> bytes: + if max_bytes < 1: + raise ValueError("max_bytes 必须大于 0") + + file_status = path.lstat() + if stat.S_ISLNK(file_status.st_mode): + raise ConfigPathError(f"拒绝读取符号链接配置:{path}") + if not stat.S_ISREG(file_status.st_mode): + raise ConfigPathError(f"配置路径不是普通文件:{path}") + if file_status.st_size > max_bytes: + raise ConfigTooLargeError(f"配置大小超过限制:{file_status.st_size} > {max_bytes} 字节") + + with path.open("rb") as config_file: + content = config_file.read(max_bytes + 1) + if len(content) > max_bytes: + raise ConfigTooLargeError(f"配置大小超过限制:{len(content)} > {max_bytes} 字节") + return content + + +def _create_verified_backup( + *, + source_path: Path, + source_content: bytes, + backup_directory: Path, +) -> BackupReceipt: + if backup_directory.exists() and not backup_directory.is_dir(): + raise ConfigPathError(f"备份路径不是目录:{backup_directory}") + backup_directory.mkdir(parents=True, exist_ok=True) + + source_hash = _sha256_bytes(source_content) + temporary_path = _write_fsynced_temporary_file( + backup_directory, + prefix=f".{source_path.stem}.backup.", + content=source_content, + ) + final_path = _unique_backup_path(source_path, backup_directory) + try: + temporary_hash = _sha256_file(temporary_path) + if not hmac.compare_digest(temporary_hash, source_hash): + raise ConfigIntegrityError("配置备份 SHA-256 校验失败") + + os.replace(temporary_path, final_path) + temporary_path = None + _fsync_directory(backup_directory) + + final_hash = _sha256_file(final_path) + if not hmac.compare_digest(final_hash, source_hash): + raise ConfigIntegrityError("配置备份落盘后的 SHA-256 校验失败") + if final_path.stat().st_size != len(source_content): + raise ConfigIntegrityError("配置备份落盘后的大小校验失败") + except BaseException: + final_path.unlink(missing_ok=True) + raise + finally: + if temporary_path is not None: + temporary_path.unlink(missing_ok=True) + + return BackupReceipt( + path=final_path, + sha256=source_hash, + size=len(source_content), + ) + + +def _write_fsynced_temporary_file( + directory: Path, + *, + prefix: str, + content: bytes, +) -> Path: + temporary_path: Path | None = None + try: + with NamedTemporaryFile( + mode="wb", + dir=directory, + prefix=prefix, + suffix=".tmp", + delete=False, + ) as temporary_file: + temporary_path = Path(temporary_file.name) + temporary_file.write(content) + temporary_file.flush() + os.fsync(temporary_file.fileno()) + return temporary_path + except BaseException: + if temporary_path is not None: + temporary_path.unlink(missing_ok=True) + raise + + +def _unique_backup_path(source_path: Path, backup_directory: Path) -> Path: + timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S.%fZ") + for _attempt in range(10): + token = uuid.uuid4().hex[:12] + candidate = backup_directory / (f"{source_path.stem}.{timestamp}.{token}.backup.json") + if not candidate.exists(): + return candidate + raise ConfigStoreError("无法生成唯一的配置备份文件名") + + +def _require_revision( + actual_revision: ConfigRevision, + expected_revision: ConfigRevision, +) -> None: + if actual_revision.exists != expected_revision.exists: + raise ConfigRevisionError("配置文件状态已变化,请重新加载后再保存") + if not actual_revision.exists: + return + if not hmac.compare_digest( + actual_revision.sha256 or "", + expected_revision.sha256 or "", + ): + raise ConfigRevisionError("配置文件内容已变化,请重新加载后再保存") + + +def _sha256_bytes(content: bytes) -> str: + return hashlib.sha256(content).hexdigest() + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source_file: + for chunk in iter(lambda: source_file.read(64 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _is_sha256(value: str) -> bool: + return len(value) == 64 and all(character in "0123456789abcdefABCDEF" for character in value) + + +def _absolute_path(path: PathInput) -> Path: + candidate = Path(path).expanduser() + return candidate if candidate.is_absolute() else candidate.absolute() + + +def _fsync_directory(directory: Path) -> None: + if os.name == "nt": + return + directory_descriptor = os.open(directory, os.O_RDONLY) + try: + os.fsync(directory_descriptor) + finally: + os.close(directory_descriptor) diff --git a/workbench/network/campus-net/campus_net/gui.py b/workbench/network/campus-net/campus_net/gui.py new file mode 100644 index 0000000..b10c641 --- /dev/null +++ b/workbench/network/campus-net/campus_net/gui.py @@ -0,0 +1,1745 @@ +from __future__ import annotations + +import ctypes +import os +import subprocess +import sys +import threading +import time +from dataclasses import dataclass +from datetime import datetime +from io import BytesIO +from pathlib import Path +from queue import Empty, Queue +from tkinter import BooleanVar, IntVar, StringVar, TclError, Text, Toplevel, messagebox + +import ttkbootstrap as ttk +from PIL import Image, ImageTk, UnidentifiedImageError +from ttkbootstrap.widgets.scrolled import ScrolledFrame + +from .config_paths import packaged_project_root, resolve_config_path +from .config_store import ( + ConfigCommitUncertainError, + ConfigIntegrityError, + ConfigRevision, + ConfigRevisionError, + ConfigStoreError, + EditableConfig, + load_editable_config, + save_editable_config, +) +from .gui_core import ( + CaptchaRequested, + FailedEvent, + FinishedEvent, + LogEvent, + NetworkInterface, + OperationController, + build_config_from_form, + form_values_from_config, + list_windows_ipv4_interfaces, +) +from .window_state import ( + ScreenRect, + WindowState, + clamp_window_state, + load_window_state, + save_window_state, +) + +THEMES = ("darkly", "flatly") +THEME_LABELS = { + "深色": "darkly", + "浅色": "flatly", +} +CARRIERS = ("中国移动", "中国电信", "中国联通") +COMBOBOX_WHEEL_GUARD_TAG = "CampusNetComboboxWheelGuard" +STATUS_ICONS = { + "info": "●", + "success": "✓", + "warning": "!", + "danger": "×", + "secondary": "○", +} +LIGHT_SEMANTIC_COLORS = { + "info": "#0369a1", + "success": "#0f766e", + "warning": "#a16207", + "danger": "#b91c1c", + "secondary": "#566573", +} +MAX_LOG_LINES = 1000 +MIN_WINDOW_WIDTH = 940 +MIN_WINDOW_HEIGHT = 620 +DEFAULT_WINDOW_WIDTH = 1100 +DEFAULT_WINDOW_HEIGHT = 720 + + +@dataclass(frozen=True, slots=True) +class _InterfaceResult: + interfaces: tuple[NetworkInterface, ...] = () + error: str | None = None + + +@dataclass(slots=True) +class _CaptchaDialog: + operation_id: int + request_id: int + window: Toplevel + photo: ImageTk.PhotoImage + + +class CampusNetApp: + def __init__( + self, + root: ttk.Window, + *, + config_path: Path, + initialize_runtime: bool = True, + ) -> None: + self.root = root + self._initialize_runtime = initialize_runtime + self.config_path = config_path.absolute() + self.backup_directory = default_backup_directory() + self.window_state_path = default_window_state_path() + self.controller = OperationController() + self._revision = ConfigRevision.absent() + self._save_allowed = True + self._active_operation_id: int | None = None + self._captcha_dialog: _CaptchaDialog | None = None + self._interface_results: Queue[_InterfaceResult] = Queue() + self._interface_by_display: dict[str, int] = {} + self._interface_details_by_display: dict[str, NetworkInterface] = {} + self._interface_loading = False + self._is_closing = False + self._close_deadline = 0.0 + self._poll_after_id: str | None = None + self._window_state_saved = False + self._last_normal_window_state: WindowState | None = None + self._restore_maximized = False + + self.version_var = IntVar(master=root, value=2) + self.theme_var = StringVar(master=root, value="darkly") + self.theme_display_var = StringVar(master=root, value="深色") + self.show_secrets_var = BooleanVar(master=root, value=False) + self.status_var = StringVar(master=root, value="准备就绪") + self.header_status_var = StringVar(master=root, value="准备就绪") + self.status_icon_var = StringVar(master=root, value=STATUS_ICONS["info"]) + self.config_path_var = StringVar(master=root, value=str(self.config_path)) + self.interface_display_var = StringVar(master=root) + self.interface_status_var = StringVar(master=root, value="等待读取网络接口") + self.interface_meta_var = StringVar(master=root, value="支持 Wi-Fi、以太网及其他 IPv4 网卡") + self.v2_vars = { + "portal_url": StringVar(master=root, value="http://10.71.29.181"), + "username": StringVar(master=root), + "password": StringVar(master=root), + "carrier": StringVar(master=root, value="中国电信"), + } + self.v1_vars = { + "login_url": StringVar(master=root), + "username": StringVar(master=root), + "encrypted_password": StringVar(master=root), + "carrier": StringVar(master=root), + "user_group": StringVar(master=root), + "session_id": StringVar(master=root), + } + self._secret_entries: list[ttk.Entry] = [] + self._version_buttons: list[ttk.Radiobutton] = [] + self._status_style = "info" + self._interface_status_style = "secondary" + self._window_icon_photo: ImageTk.PhotoImage | None = None + self._header_logo_photo: ImageTk.PhotoImage | None = None + + for sequence in ("", "", ""): + self.root.bind_class( + COMBOBOX_WHEEL_GUARD_TAG, + sequence, + self._stop_combobox_wheel, + ) + + self._configure_window() + self._configure_styles() + self._load_brand_images() + self._build_layout() + if self._restore_maximized: + self.root.after_idle(self._restore_window_maximized) + self._show_version_form() + self._apply_text_theme() + self._update_controls() + + if initialize_runtime: + self._load_config() + self.root.after(150, self.refresh_interfaces) + self._schedule_event_poll() + + def _configure_window(self) -> None: + self.root.title("Campus Net · 校园网连接") + work_areas = _screen_work_areas(self.root) + primary_work_area = work_areas[0] + effective_min_width = min(MIN_WINDOW_WIDTH, primary_work_area.width) + effective_min_height = min(MIN_WINDOW_HEIGHT, primary_work_area.height) + saved_state = ( + load_window_state(self.window_state_path) if self._initialize_runtime else None + ) + restored_state = clamp_window_state( + saved_state, + work_areas, + min_width=effective_min_width, + min_height=effective_min_height, + default_width=DEFAULT_WINDOW_WIDTH, + default_height=DEFAULT_WINDOW_HEIGHT, + ) + self._last_normal_window_state = WindowState( + width=restored_state.width, + height=restored_state.height, + x=restored_state.x, + y=restored_state.y, + ) + self._restore_maximized = restored_state.maximized + self.root.geometry(restored_state.geometry) + self.root.minsize(effective_min_width, effective_min_height) + self.root.bind("", self._remember_window_geometry, add="+") + self.root.protocol("WM_DELETE_WINDOW", self._request_close) + + def _configure_styles(self) -> None: + style = self.root.style + colors = style.colors + is_dark = self.theme_var.get() == "darkly" + canvas_background = colors.active if is_dark else colors.light + card_background = colors.bg + panel_background = colors.inputbg if is_dark else colors.light + muted_foreground = colors.light if is_dark else colors.dark + border_color = colors.secondary if is_dark else colors.border + + self.root.configure(background=canvas_background) + style.configure("App.TFrame", background=canvas_background) + style.configure("CardBorder.TFrame", background=border_color) + style.configure("Card.TFrame", background=card_background) + style.configure("Card.TLabel", background=card_background, foreground=colors.fg) + style.configure( + "CardTitle.TLabel", + background=card_background, + foreground=colors.fg, + font=("Segoe UI", 14, "bold"), + ) + style.configure( + "CardSubtitle.TLabel", + background=card_background, + foreground=muted_foreground, + font=("Segoe UI", 9), + ) + style.configure( + "Field.TLabel", + background=card_background, + foreground=muted_foreground, + font=("Segoe UI", 9, "bold"), + ) + style.configure("Panel.TFrame", background=panel_background) + style.configure("ToggleBorder.TFrame", background=colors.border) + style.configure("ToggleControl.TFrame", background=colors.inputbg) + style.configure( + "PanelMeta.TLabel", + background=panel_background, + foreground=muted_foreground, + font=("Segoe UI", 9), + ) + style.configure( + "Hero.TFrame", + background=colors.primary, + ) + style.configure( + "HeroMark.TLabel", + background="#ffffff", + foreground=colors.primary, + font=("Segoe UI", 12, "bold"), + anchor="center", + ) + style.configure( + "HeroLogo.TLabel", + background=colors.primary, + anchor="center", + ) + style.configure( + "HeroTitle.TLabel", + background=colors.primary, + foreground="#ffffff", + font=("Segoe UI", 20, "bold"), + ) + style.configure( + "HeroSubtitle.TLabel", + background=colors.primary, + foreground="#dbe7f3", + font=("Segoe UI", 9), + ) + style.configure( + "HeroStatus.TLabel", + background=colors.primary, + foreground="#ffffff", + font=("Segoe UI", 9, "bold"), + ) + style.configure( + "Footer.TLabel", + background=canvas_background, + foreground=muted_foreground, + font=("Segoe UI", 9), + ) + style.configure("TEntry", padding=(9, 7)) + style.configure("TCombobox", padding=(7, 7)) + + for semantic_style in STATUS_ICONS: + semantic_color = ( + getattr(colors, semantic_style) + if is_dark + else LIGHT_SEMANTIC_COLORS[semantic_style] + ) + style_name = semantic_style.title() + style.configure( + f"{style_name}.StatusIcon.TLabel", + background=panel_background, + foreground=semantic_color, + font=("Segoe UI", 18, "bold"), + anchor="center", + ) + style.configure( + f"{style_name}.StatusText.TLabel", + background=panel_background, + foreground=semantic_color, + font=("Segoe UI", 12, "bold"), + ) + style.configure( + f"{style_name}.Interface.TLabel", + background=card_background, + foreground=semantic_color, + font=("Segoe UI", 9, "bold"), + ) + + def _load_brand_images(self) -> None: + try: + with Image.open(app_icon_path()) as source: + source.load() + brand_image = source.convert("RGBA") + except (OSError, UnidentifiedImageError): + return + + brand_image.thumbnail((64, 64), Image.Resampling.LANCZOS) + window_icon = brand_image.copy() + header_logo = brand_image.copy() + header_logo.thumbnail((56, 56), Image.Resampling.LANCZOS) + self._window_icon_photo = ImageTk.PhotoImage(window_icon, master=self.root) + self._header_logo_photo = ImageTk.PhotoImage(header_logo, master=self.root) + try: + self.root.iconphoto(True, self._window_icon_photo) + except TclError: + pass + + def _restore_window_maximized(self) -> None: + try: + self.root.state("zoomed") + except TclError: + pass + + def _remember_window_geometry(self, event: object | None = None) -> None: + if ( + not self._initialize_runtime + or self._is_closing + or (event is not None and getattr(event, "widget", None) is not self.root) + ): + return + try: + if self.root.state() != "normal": + return + width = self.root.winfo_width() + height = self.root.winfo_height() + x = self.root.winfo_x() + y = self.root.winfo_y() + except TclError: + return + if width < 2 or height < 2: + return + self._last_normal_window_state = WindowState( + width=width, + height=height, + x=x, + y=y, + ) + + def _persist_window_geometry(self) -> None: + if not self._initialize_runtime or self._window_state_saved: + return + self._window_state_saved = True + self._remember_window_geometry() + state = self._last_normal_window_state + if state is None: + return + try: + maximized = self.root.state() == "zoomed" + save_window_state( + WindowState( + width=state.width, + height=state.height, + x=state.x, + y=state.y, + maximized=maximized, + ), + self.window_state_path, + ) + except (OSError, TclError, ValueError): + pass + + def _build_layout(self) -> None: + shell = ttk.Frame(self.root, padding=18, style="App.TFrame") + shell.pack(fill="both", expand=True) + shell.columnconfigure(0, weight=1) + shell.rowconfigure(1, weight=1) + + header = ttk.Frame(shell, padding=(20, 14), style="Hero.TFrame") + header.grid(row=0, column=0, sticky="ew", pady=(0, 14)) + header.columnconfigure(1, weight=1) + + if self._header_logo_photo is None: + ttk.Label( + header, + text="CN", + width=4, + padding=(6, 7), + style="HeroMark.TLabel", + ).grid(row=0, column=0, rowspan=2, sticky="nsw", padx=(0, 12)) + else: + ttk.Label( + header, + image=self._header_logo_photo, + style="HeroLogo.TLabel", + ).grid(row=0, column=0, rowspan=2, sticky="nsw", padx=(0, 14)) + ttk.Label( + header, + text="Campus Net", + style="HeroTitle.TLabel", + ).grid(row=0, column=1, sticky="sw") + ttk.Label( + header, + text="校园网门户连接、配置与状态检查", + style="HeroSubtitle.TLabel", + ).grid(row=1, column=1, sticky="nw", pady=(1, 0)) + + header_tools = ttk.Frame(header, style="Hero.TFrame") + header_tools.grid(row=0, column=2, rowspan=2, sticky="e") + self.header_status_icon_label = ttk.Label( + header_tools, + textvariable=self.status_icon_var, + style="HeroStatus.TLabel", + ) + self.header_status_icon_label.pack(side="left", padx=(0, 5)) + ttk.Label( + header_tools, + textvariable=self.header_status_var, + style="HeroStatus.TLabel", + ).pack(side="left", padx=(0, 14)) + ttk.Label( + header_tools, + text="外观", + style="HeroSubtitle.TLabel", + ).pack(side="left", padx=(0, 7)) + theme_combo = ttk.Combobox( + header_tools, + textvariable=self.theme_display_var, + values=tuple(THEME_LABELS), + width=6, + state="readonly", + ) + theme_combo.pack(side="left") + theme_combo.bind("<>", self._change_theme) + + content = ttk.Frame(shell, style="App.TFrame") + content.grid(row=1, column=0, sticky="nsew") + content.columnconfigure(0, weight=3, uniform="content") + content.columnconfigure(1, weight=2, uniform="content") + content.rowconfigure(0, weight=1) + + form_border = ttk.Frame(content, padding=1, style="CardBorder.TFrame") + form_border.grid(row=0, column=0, sticky="nsew", padx=(0, 7)) + form_border.columnconfigure(0, weight=1) + form_border.rowconfigure(0, weight=1) + form_panel = ttk.Frame(form_border, padding=18, style="Card.TFrame") + form_panel.grid(row=0, column=0, sticky="nsew") + form_panel.columnconfigure(0, weight=1) + form_panel.rowconfigure(3, weight=1) + + heading = ttk.Frame(form_panel, style="Card.TFrame") + heading.grid(row=0, column=0, sticky="ew") + ttk.Label(heading, text="连接配置", style="CardTitle.TLabel").pack(anchor="w") + ttk.Label( + heading, + text="选择门户协议并填写此设备的连接信息", + style="CardSubtitle.TLabel", + ).pack(anchor="w", pady=(2, 0)) + + version_row = ttk.Frame(form_panel, padding=4, style="Panel.TFrame") + version_row.grid(row=1, column=0, sticky="ew", pady=(14, 12)) + version_row.columnconfigure(0, weight=1) + version_row.columnconfigure(1, weight=1) + for value, label in ((2, "新版 captive SSO"), (1, "旧版 ePortal")): + button = ttk.Radiobutton( + version_row, + text="新版 SSO(推荐)" if value == 2 else label, + value=value, + variable=self.version_var, + command=self._show_version_form, + bootstyle="primary-toolbutton", + padding=(10, 7), + ) + button.grid( + row=0, + column=0 if value == 2 else 1, + sticky="ew", + padx=(0, 3) if value == 2 else (3, 0), + ) + self._version_buttons.append(button) + + ttk.Separator(form_panel).grid(row=2, column=0, sticky="ew", pady=(0, 12)) + + self.form_stack = ScrolledFrame( + form_panel, + padding=(0, 0, 8, 0), + autohide=False, + height=240, + width=500, + ) + self.form_stack.grid(row=3, column=0, sticky="nsew") + self.form_stack.columnconfigure(0, weight=1) + self.form_stack.rowconfigure(0, weight=1) + self.v2_frame = self._build_v2_form(self.form_stack) + self.v1_frame = self._build_v1_form(self.form_stack) + + ttk.Separator(form_panel).grid(row=4, column=0, sticky="ew", pady=(12, 10)) + action_bar = ttk.Frame(form_panel, style="Card.TFrame") + action_bar.grid(row=5, column=0, sticky="ew") + action_bar.columnconfigure(0, weight=1) + action_bar.columnconfigure(1, weight=1) + self.save_button = ttk.Button( + action_bar, + text="保存配置", + command=self._save_config, + bootstyle="primary-outline", + padding=(10, 7), + ) + self.save_button.grid(row=0, column=0, sticky="ew", padx=(0, 5)) + self.reload_button = ttk.Button( + action_bar, + text="重新加载", + command=self._load_config, + bootstyle="secondary-outline", + padding=(10, 7), + ) + self.reload_button.grid(row=0, column=1, sticky="ew", padx=(5, 0)) + + log_border = ttk.Frame(content, padding=1, style="CardBorder.TFrame") + log_border.grid(row=0, column=1, sticky="nsew", padx=(7, 0)) + log_border.columnconfigure(0, weight=1) + log_border.rowconfigure(0, weight=1) + log_panel = ttk.Frame(log_border, padding=18, style="Card.TFrame") + log_panel.grid(row=0, column=0, sticky="nsew") + log_panel.columnconfigure(0, weight=1) + log_panel.rowconfigure(6, weight=1) + + ttk.Label(log_panel, text="运行中心", style="CardTitle.TLabel").grid( + row=0, + column=0, + sticky="w", + ) + ttk.Label( + log_panel, + text="探测网络状态并执行校园网认证", + style="CardSubtitle.TLabel", + ).grid(row=1, column=0, sticky="w", pady=(2, 12)) + + status_panel = ttk.Frame(log_panel, padding=(14, 11), style="Panel.TFrame") + status_panel.grid(row=2, column=0, sticky="ew") + status_panel.columnconfigure(0, weight=1) + status_summary = ttk.Frame(status_panel, style="Panel.TFrame") + status_summary.grid(row=0, column=0) + self.status_icon_label = ttk.Label( + status_summary, + textvariable=self.status_icon_var, + style="Info.StatusIcon.TLabel", + ) + self.status_icon_label.pack(side="left", padx=(0, 7)) + self.status_label = ttk.Label( + status_summary, + textvariable=self.status_var, + justify="center", + wraplength=300, + style="Info.StatusText.TLabel", + ) + self.status_label.pack(side="left") + ttk.Label( + status_panel, + text="详细过程与门户返回信息会记录在下方日志中", + style="PanelMeta.TLabel", + ).grid(row=1, column=0, pady=(2, 0)) + + self.progress = ttk.Progressbar(log_panel, mode="indeterminate", bootstyle="success") + self.progress.grid(row=3, column=0, sticky="ew", pady=(9, 0)) + + self.run_bar = ttk.Frame(log_panel, style="Card.TFrame") + self.run_bar.grid(row=4, column=0, sticky="ew", pady=(12, 13)) + self.run_bar.columnconfigure(0, weight=2) + self.run_bar.columnconfigure(1, weight=1) + self.connect_button = ttk.Button( + self.run_bar, + text="连接校园网", + command=lambda: self._start_operation(probe_only=False), + bootstyle="success", + padding=(14, 9), + ) + self.connect_button.grid(row=0, column=0, sticky="ew", padx=(0, 5)) + self.probe_button = ttk.Button( + self.run_bar, + text="只读探测", + command=lambda: self._start_operation(probe_only=True), + bootstyle="info-outline", + padding=(12, 9), + ) + self.probe_button.grid(row=0, column=1, sticky="ew", padx=(5, 0)) + self.cancel_button = ttk.Button( + self.run_bar, + text="取消当前操作", + command=self._cancel_operation, + bootstyle="danger-outline", + padding=(14, 9), + ) + self.cancel_button.grid(row=0, column=0, columnspan=2, sticky="ew") + + log_heading = ttk.Frame(log_panel, style="Card.TFrame") + log_heading.grid(row=5, column=0, sticky="ew", pady=(0, 7)) + log_heading.columnconfigure(0, weight=1) + ttk.Label(log_heading, text="过程日志", style="Field.TLabel").grid( + row=0, + column=0, + sticky="w", + ) + ttk.Button( + log_heading, + text="复制", + command=self._copy_log, + bootstyle="secondary-link", + padding=(5, 2), + ).grid(row=0, column=1, padx=(4, 0)) + ttk.Button( + log_heading, + text="清空", + command=self._clear_log, + bootstyle="secondary-link", + padding=(5, 2), + ).grid(row=0, column=2, padx=(2, 0)) + + log_container = ttk.Frame(log_panel, style="Card.TFrame") + log_container.grid(row=6, column=0, sticky="nsew") + log_container.columnconfigure(0, weight=1) + log_container.rowconfigure(0, weight=1) + self.log_text = Text( + log_container, + wrap="word", + relief="flat", + borderwidth=0, + padx=10, + pady=10, + font=("Cascadia Mono", 10), + state="disabled", + ) + self.log_text.grid(row=0, column=0, sticky="nsew") + log_scrollbar = ttk.Scrollbar( + log_container, + orient="vertical", + command=self.log_text.yview, + ) + log_scrollbar.grid(row=0, column=1, sticky="ns") + self.log_text.configure(yscrollcommand=log_scrollbar.set) + + path_row = ttk.Frame(shell, style="App.TFrame") + path_row.grid(row=2, column=0, sticky="ew", pady=(12, 0)) + path_row.columnconfigure(1, weight=1) + ttk.Label(path_row, text="配置文件", style="Footer.TLabel").grid( + row=0, + column=0, + sticky="w", + padx=(0, 10), + ) + ttk.Entry( + path_row, + textvariable=self.config_path_var, + state="readonly", + ).grid(row=0, column=1, sticky="ew") + ttk.Button( + path_row, + text="复制路径", + command=self._copy_config_path, + bootstyle="secondary-outline", + padding=(9, 5), + ).grid(row=0, column=2, padx=(8, 0)) + + def _build_v2_form(self, parent: ttk.Frame) -> ttk.Frame: + frame = ttk.Frame(parent, style="Card.TFrame") + frame.columnconfigure(0, weight=1, uniform="v2-fields") + frame.columnconfigure(1, weight=1, uniform="v2-fields") + + interface_field = ttk.Frame(frame, style="Card.TFrame") + interface_field.grid(row=0, column=0, columnspan=2, sticky="ew", pady=(0, 8)) + interface_field.columnconfigure(0, weight=1) + ttk.Label( + interface_field, + text="Windows IPv4 接口", + style="Field.TLabel", + ).grid(row=0, column=0, sticky="w", pady=(0, 4)) + interface_row = ttk.Frame(interface_field, style="Card.TFrame") + interface_row.grid(row=1, column=0, sticky="ew") + interface_row.columnconfigure(0, weight=1) + self.interface_combo = ttk.Combobox( + interface_row, + textvariable=self.interface_display_var, + state="normal", + ) + self.interface_combo.grid(row=0, column=0, sticky="ew") + self._guard_combobox_wheel(self.interface_combo) + self.refresh_interfaces_button = ttk.Button( + interface_row, + text="刷新", + command=self.refresh_interfaces, + bootstyle="secondary-outline", + width=7, + padding=(7, 6), + ) + self.refresh_interfaces_button.grid(row=0, column=1, padx=(8, 0)) + self.interface_combo.bind("<>", self._update_interface_summary) + self.interface_combo.bind("", self._update_interface_summary) + + interface_summary = ttk.Frame(interface_field, style="Card.TFrame") + interface_summary.grid(row=2, column=0, sticky="ew", pady=(6, 0)) + self.interface_status_label = ttk.Label( + interface_summary, + textvariable=self.interface_status_var, + style="Secondary.Interface.TLabel", + ) + self.interface_status_label.pack(side="left") + ttk.Label( + interface_summary, + textvariable=self.interface_meta_var, + style="CardSubtitle.TLabel", + ).pack(side="left", padx=(9, 0)) + + self._add_entry( + frame, + 1, + 0, + "门户根地址", + self.v2_vars["portal_url"], + columnspan=2, + ) + self._add_entry( + frame, + 2, + 0, + "校园网账号", + self.v2_vars["username"], + padx=(0, 6), + ) + password_entry = self._add_entry( + frame, + 2, + 1, + "校园网密码", + self.v2_vars["password"], + show="•", + padx=(6, 0), + ) + self._secret_entries.append(password_entry) + self.v2_carrier_combo = self._add_combobox( + frame, + 3, + 0, + "运营商", + self.v2_vars["carrier"], + values=CARRIERS, + padx=(0, 6), + ) + self._add_secret_toggle( + frame, + 3, + 1, + text="显示密码", + padx=(6, 0), + ) + self._add_form_hint( + frame, + 4, + text="接口可以是 Wi-Fi、以太网或其他 IPv4 网卡;也可直接填写接口索引。", + ) + return frame + + def _build_v1_form(self, parent: ttk.Frame) -> ttk.Frame: + frame = ttk.Frame(parent, style="Card.TFrame") + frame.columnconfigure(0, weight=1, uniform="v1-fields") + frame.columnconfigure(1, weight=1, uniform="v1-fields") + + self._add_entry( + frame, + 0, + 0, + "登录地址", + self.v1_vars["login_url"], + columnspan=2, + ) + self._add_entry( + frame, + 1, + 0, + "校园网账号", + self.v1_vars["username"], + padx=(0, 6), + ) + password_entry = self._add_entry( + frame, + 1, + 1, + "加密密码", + self.v1_vars["encrypted_password"], + show="•", + padx=(6, 0), + ) + self._secret_entries.append(password_entry) + self._add_entry( + frame, + 2, + 0, + "运营商", + self.v1_vars["carrier"], + padx=(0, 6), + ) + self._add_entry( + frame, + 2, + 1, + "用户组(可选)", + self.v1_vars["user_group"], + padx=(6, 0), + ) + session_entry = self._add_entry( + frame, + 3, + 0, + "会话 ID(可选)", + self.v1_vars["session_id"], + show="•", + columnspan=2, + ) + self._secret_entries.append(session_entry) + self._add_secret_toggle( + frame, + 4, + 0, + text="显示敏感字段", + columnspan=2, + ) + self._add_form_hint( + frame, + 5, + text="旧版使用门户生成的 encrypted_password,不接受新版明文密码。", + ) + return frame + + def _add_entry( + self, + parent: ttk.Frame, + row: int, + column: int, + label: str, + variable: StringVar, + *, + show: str | None = None, + columnspan: int = 1, + padx: tuple[int, int] = (0, 0), + ) -> ttk.Entry: + field = ttk.Frame(parent, style="Card.TFrame") + field.grid( + row=row, + column=column, + columnspan=columnspan, + sticky="ew", + padx=padx, + pady=(0, 8), + ) + ttk.Label(field, text=label, style="Field.TLabel").pack(anchor="w") + entry = ttk.Entry(field, textvariable=variable, show=show or "") + entry.pack(fill="x", pady=(4, 0)) + return entry + + def _add_secret_toggle( + self, + parent: ttk.Frame, + row: int, + column: int, + *, + text: str, + columnspan: int = 1, + padx: tuple[int, int] = (0, 0), + ) -> None: + field = ttk.Frame(parent, style="Card.TFrame") + field.grid( + row=row, + column=column, + columnspan=columnspan, + sticky="nsew", + padx=padx, + pady=(0, 8), + ) + ttk.Label(field, text="敏感信息", style="Field.TLabel").pack(anchor="w") + toggle_border = ttk.Frame(field, padding=1, style="ToggleBorder.TFrame") + toggle_border.pack(fill="x", pady=(4, 0)) + toggle_control = ttk.Frame( + toggle_border, + padding=(9, 8), + style="ToggleControl.TFrame", + ) + toggle_control.pack(fill="x") + ttk.Checkbutton( + toggle_control, + text=text, + variable=self.show_secrets_var, + command=self._toggle_secrets, + bootstyle="primary-square-toggle", + ).pack(anchor="w") + + def _add_combobox( + self, + parent: ttk.Frame, + row: int, + column: int, + label: str, + variable: StringVar, + *, + values: tuple[str, ...], + columnspan: int = 1, + padx: tuple[int, int] = (0, 0), + ) -> ttk.Combobox: + field = ttk.Frame(parent, style="Card.TFrame") + field.grid( + row=row, + column=column, + columnspan=columnspan, + sticky="ew", + padx=padx, + pady=(0, 8), + ) + ttk.Label(field, text=label, style="Field.TLabel").pack(anchor="w") + combobox = ttk.Combobox( + field, + textvariable=variable, + values=values, + state="readonly", + ) + combobox.pack(fill="x", pady=(4, 0)) + self._guard_combobox_wheel(combobox) + return combobox + + @staticmethod + def _stop_combobox_wheel(_event: object) -> str: + return "break" + + @staticmethod + def _guard_combobox_wheel(combobox: ttk.Combobox) -> None: + bindtags = combobox.bindtags() + if COMBOBOX_WHEEL_GUARD_TAG in bindtags: + return + combobox.bindtags((bindtags[0], COMBOBOX_WHEEL_GUARD_TAG, *bindtags[1:])) + + def _add_form_hint(self, parent: ttk.Frame, row: int, *, text: str) -> None: + hint = ttk.Label( + parent, + text=text, + justify="left", + style="CardSubtitle.TLabel", + ) + hint.grid(row=row, column=0, columnspan=2, sticky="ew", pady=(1, 0)) + + def resize_hint(event: object) -> None: + width = getattr(event, "width", 0) + if isinstance(width, int) and width > 40: + hint.configure(wraplength=max(240, width - 12)) + + parent.bind("", resize_hint, add="+") + + def _show_version_form(self) -> None: + version = self.version_var.get() + if version == 2: + self.v2_frame.grid(row=0, column=0, sticky="nsew") + self.v1_frame.grid_remove() + else: + self.v1_frame.grid(row=0, column=0, sticky="nsew") + self.v2_frame.grid_remove() + self._update_controls() + + def _toggle_secrets(self) -> None: + show = "" if self.show_secrets_var.get() else "•" + for entry in self._secret_entries: + entry.configure(show=show) + + def _change_theme(self, _event: object | None = None) -> None: + theme = THEME_LABELS.get(self.theme_display_var.get(), self.theme_var.get()) + if theme not in THEMES: + return + self.theme_var.set(theme) + self.theme_display_var.set( + next(label for label, value in THEME_LABELS.items() if value == theme) + ) + self.root.style.theme_use(theme) + self._configure_styles() + self._apply_text_theme() + + def _apply_text_theme(self) -> None: + colors = self.root.style.colors + is_dark = self.theme_var.get() == "darkly" + muted_foreground = colors.light if is_dark else colors.dark + self.log_text.configure( + background=colors.inputbg, + foreground=colors.inputfg, + insertbackground=colors.inputfg, + selectbackground=colors.selectbg, + selectforeground=colors.selectfg, + ) + self.log_text.tag_configure("timestamp", foreground=muted_foreground) + self.log_text.tag_configure("message", foreground=colors.inputfg) + self.root.style.configure( + "primary.Square.Toggle", + background=colors.inputbg, + foreground=colors.inputfg, + ) + self.root.style.map( + "primary.Square.Toggle", + background=[ + ("active", colors.inputbg), + ("disabled", colors.inputbg), + ], + foreground=[("disabled", muted_foreground)], + ) + self.root.style.configure( + "primary.Toolbutton", + background=colors.inputbg if is_dark else colors.light, + foreground=colors.fg, + bordercolor=colors.border, + lightcolor=colors.border, + darkcolor=colors.border, + ) + + def _load_config(self) -> None: + if self._active_operation_id is not None: + return + if not self.config_path.exists(): + self._revision = ConfigRevision.absent() + self._save_allowed = True + self._set_status("尚未创建配置,可填写后保存", "info") + self._append_log(f"将新建配置:{self.config_path}") + self._update_controls() + return + + try: + loaded = load_editable_config(self.config_path) + version, values = form_values_from_config(loaded.config) + except (ConfigStoreError, OSError, TypeError, ValueError) as error: + self._save_allowed = False + self._set_status("配置加载失败,已禁止覆盖", "danger") + self._append_log(f"配置加载失败:{error}") + self._update_controls() + return + + self._revision = loaded.revision + self._save_allowed = True + self._apply_form_values(version, values) + self._set_status(f"已加载 version={version} 配置", "success") + self._append_log(f"已加载配置:{loaded.path}") + self._update_controls() + + def _apply_form_values(self, version: int, values: dict[str, str]) -> None: + self.version_var.set(version) + if version == 2: + self.interface_display_var.set(values["interface_index"]) + for key, variable in self.v2_vars.items(): + variable.set(values[key]) + self._update_interface_summary() + else: + for key, variable in self.v1_vars.items(): + variable.set(values[key]) + self._show_version_form() + + def _collect_config(self) -> EditableConfig: + version = self.version_var.get() + if version == 2: + interface_text = self.interface_display_var.get().strip() + interface_index = self._interface_by_display.get(interface_text) + if interface_index is None: + interface_index_text = interface_text.split("·", 1)[0].strip() + else: + interface_index_text = str(interface_index) + values = { + "interface_index": interface_index_text, + **{key: variable.get() for key, variable in self.v2_vars.items()}, + } + else: + values = {key: variable.get() for key, variable in self.v1_vars.items()} + return build_config_from_form(version, values) + + def _save_config(self) -> None: + if not self._save_allowed or self._active_operation_id is not None: + return + try: + config = self._collect_config() + receipt = save_editable_config( + config, + path=self.config_path, + backup_dir=self.backup_directory, + expected_revision=self._revision, + ) + except ConfigRevisionError as error: + self._save_allowed = False + self._set_status("配置已被其他程序修改,请重新加载", "warning") + self._append_log(f"保存已停止:{error}") + messagebox.showwarning("保存已停止", str(error), parent=self.root) + except ConfigCommitUncertainError as error: + self._save_allowed = False + self._set_status("写入后的文件状态无法确认,请重新加载", "danger") + self._append_log(f"配置替换后的落盘状态无法确认:{error}") + if error.backup is not None: + self._append_log(f"可用于恢复的原配置备份:{error.backup.path}") + messagebox.showerror( + "保存状态不确定", + "配置已执行原子替换,但最终落盘状态无法确认。请重新加载配置并检查备份目录。", + parent=self.root, + ) + except ConfigIntegrityError as error: + self._set_status("完整性校验失败,已停止覆盖", "danger") + self._append_log(f"保存已在替换前停止:{error}") + messagebox.showerror( + "保存已停止", + "配置或备份的完整性校验失败,旧配置未被覆盖。", + parent=self.root, + ) + except (ConfigStoreError, OSError, TypeError, ValueError) as error: + self._set_status("配置保存失败", "danger") + self._append_log(f"配置保存失败:{error}") + messagebox.showerror("配置保存失败", str(error), parent=self.root) + else: + self._revision = receipt.revision + if not receipt.changed: + self._set_status("配置内容没有变化", "info") + self._append_log("配置内容没有变化,未覆盖文件,也未创建备份。") + elif receipt.backup is None: + self._set_status("配置已创建", "success") + self._append_log(f"已创建配置:{receipt.path}") + else: + self._set_status("配置已备份并保存", "success") + self._append_log(f"原配置备份:{receipt.backup.path}") + self._append_log(f"已原子保存配置:{receipt.path}") + finally: + self._update_controls() + + def _start_operation(self, *, probe_only: bool) -> None: + if self._active_operation_id is not None: + return + if probe_only and self.version_var.get() == 1: + self._set_status("旧版 ePortal 不支持独立探测", "warning") + return + try: + config = self._collect_config() + operation_id = self.controller.start(config, probe_only=probe_only) + except (ConfigStoreError, OSError, TypeError, ValueError, RuntimeError) as error: + self._set_status("无法启动操作", "danger") + self._append_log(f"无法启动操作:{error}") + return + + self._active_operation_id = operation_id + action = "只读探测" if probe_only else "连接" + self._set_status(f"正在执行{action}", "info") + self._append_log(f"开始{action},配置版本 {self.version_var.get()}。") + self.progress.start(12) + self._update_controls() + + def _cancel_operation(self) -> None: + if self._active_operation_id is None: + return + if self.controller.cancel(): + self._set_status("正在取消操作…", "warning") + self._append_log("已请求取消;正在提交的请求可能已被服务端处理。") + self.cancel_button.configure(state="disabled") + + def refresh_interfaces(self) -> None: + if self._interface_loading: + return + self._interface_loading = True + self.refresh_interfaces_button.configure(state="disabled") + self._set_interface_status( + "正在读取网络接口", + "info", + "正在查询 Windows IPv4 路由接口", + ) + self._append_log("正在读取 Windows IPv4 接口…") + thread = threading.Thread( + target=self._load_interfaces_in_worker, + name="campus-net-interface-list", + daemon=True, + ) + try: + thread.start() + except (OSError, RuntimeError) as error: + self._interface_loading = False + self._set_interface_status( + "无法读取网络接口", + "danger", + "后台读取任务未能启动", + ) + self._append_log(f"无法启动网卡读取任务:{error}") + self._update_controls() + + def _load_interfaces_in_worker(self) -> None: + try: + interfaces = tuple(list_windows_ipv4_interfaces()) + except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error: + result = _InterfaceResult(error=str(error)) + else: + result = _InterfaceResult(interfaces=interfaces) + self._interface_results.put(result) + + def _handle_interface_result(self, result: _InterfaceResult) -> None: + self._interface_loading = False + if result.error is not None: + self._set_interface_status( + "网络接口读取失败", + "danger", + "可手动填写 Windows IPv4 接口索引", + ) + self._append_log(f"网卡列表读取失败:{result.error}") + self._update_controls() + return + + current_index = self._current_interface_index() + self._interface_details_by_display = { + interface.display_name: interface for interface in result.interfaces + } + self._interface_by_display = { + interface.display_name: interface.index for interface in result.interfaces + } + displays = tuple(self._interface_by_display) + self.interface_combo.configure(values=displays) + matching_display = next( + ( + display + for display, index in self._interface_by_display.items() + if index == current_index + ), + None, + ) + if matching_display is not None: + self.interface_display_var.set(matching_display) + elif not self.interface_display_var.get().strip() and displays: + self.interface_display_var.set(displays[0]) + self._update_interface_summary() + self._append_log(f"已读取 {len(displays)} 个 IPv4 接口。") + self._update_controls() + + def _update_interface_summary(self, _event: object | None = None) -> None: + display = self.interface_display_var.get().strip() + interface = self._interface_details_by_display.get(display) + if interface is None: + interface_index = self._current_interface_index() + if interface_index is None: + self._set_interface_status( + "尚未选择网络接口", + "secondary", + "刷新列表或直接填写 IPv4 接口索引", + ) + else: + self._set_interface_status( + "使用手动接口索引", + "info", + f"IPv4 接口索引 {interface_index}", + ) + return + + state = interface.state.casefold() + if state == "connected": + message, style = "接口已连接", "success" + elif state == "disconnected": + message, style = "接口未连接", "secondary" + elif state == "authenticating": + message, style = "接口正在认证", "warning" + else: + message, style = interface.state or "接口状态未知", "warning" + self._set_interface_status( + message, + style, + f"接口索引 {interface.index} · 路由跃点 {interface.metric}", + ) + + def _set_interface_status(self, message: str, style: str, meta: str) -> None: + if style not in STATUS_ICONS: + style = "secondary" + self._interface_status_style = style + self.interface_status_var.set(f"{STATUS_ICONS[style]} {message}") + self.interface_meta_var.set(meta) + if hasattr(self, "interface_status_label"): + self.interface_status_label.configure(style=f"{style.title()}.Interface.TLabel") + + def _current_interface_index(self) -> int | None: + value = self.interface_display_var.get().strip() + mapped = self._interface_by_display.get(value) + if mapped is not None: + return mapped + try: + return int(value.split("·", 1)[0].strip()) + except ValueError: + return None + + def _poll_events(self) -> None: + self._poll_after_id = None + try: + while True: + self._handle_interface_result(self._interface_results.get_nowait()) + except Empty: + pass + + for event in self.controller.drain_events(): + if event.operation_id != self._active_operation_id: + continue + if isinstance(event, LogEvent): + self._append_log(event.message) + elif isinstance(event, CaptchaRequested): + if not self._is_closing: + self._show_captcha(event) + elif isinstance(event, FailedEvent): + self._finish_operation(event.message, style="danger") + self._append_log(f"{event.title}:{event.message}") + elif isinstance(event, FinishedEvent): + style = self._terminal_style(event) + self._finish_operation(event.message, style=style) + self._append_log(event.message) + + if self._is_closing and not self.controller.is_running: + self._destroy_root() + return + if self._is_closing and time.monotonic() >= self._close_deadline: + self._destroy_root() + return + self._schedule_event_poll() + + def _schedule_event_poll(self) -> None: + if self._poll_after_id is not None: + return + try: + self._poll_after_id = self.root.after(50, self._poll_events) + except TclError: + self._poll_after_id = None + + def _terminal_style(self, event: FinishedEvent) -> str: + if event.cancelled or event.exit_code in {2, 4}: + return "warning" + return "success" if event.exit_code == 0 else "danger" + + def _finish_operation(self, message: str, *, style: str) -> None: + self.progress.stop() + self._active_operation_id = None + self._close_captcha() + self._set_status(message, style) + self._update_controls() + + def _show_captcha(self, event: CaptchaRequested) -> None: + self._close_captcha() + try: + with Image.open(BytesIO(event.image_bytes)) as source: + source.load() + captcha_image = source.convert("RGB") + except (OSError, UnidentifiedImageError) as error: + self._append_log(f"验证码图片无效:{error}") + self.controller.cancel() + self._set_status("验证码图片无效,正在取消", "danger") + return + + captcha_image.thumbnail((560, 280), Image.Resampling.LANCZOS) + window = Toplevel(self.root) + window.title("校园网图形验证码") + window.resizable(False, False) + window.transient(self.root) + window.configure(background=self.root.style.colors.bg) + + body = ttk.Frame(window, padding=20, style="Card.TFrame") + body.pack(fill="both", expand=True) + ttk.Label( + body, + text="门户需要图形验证码", + style="CardTitle.TLabel", + ).pack(anchor="w") + ttk.Label( + body, + text="只需填写图片内容,其余登录表单仍由程序自动提交。", + style="CardSubtitle.TLabel", + ).pack(anchor="w", pady=(2, 12)) + + photo = ImageTk.PhotoImage(captcha_image, master=window) + image_border = ttk.Frame(body, padding=1, style="CardBorder.TFrame") + image_border.pack(fill="x", pady=(0, 13)) + ttk.Label(image_border, image=photo, anchor="center").pack(fill="x") + code_var = StringVar(master=window) + ttk.Label(body, text="验证码", style="Field.TLabel").pack(anchor="w") + entry = ttk.Entry(body, textvariable=code_var, justify="center", width=28) + entry.pack(fill="x", pady=(4, 0)) + validation_var = StringVar(master=window) + ttk.Label(body, textvariable=validation_var, bootstyle="danger").pack( + anchor="w", + pady=(4, 0), + ) + + button_row = ttk.Frame(body, style="Card.TFrame") + button_row.pack(fill="x", pady=(12, 0)) + + def submit() -> None: + code = code_var.get().strip() + if not code: + validation_var.set("请输入验证码") + entry.focus_set() + return + accepted = self.controller.answer_captcha( + event.operation_id, + event.request_id, + code, + ) + if accepted: + self._append_log("验证码已提交,等待门户校验。") + self._close_captcha() + self._set_status("验证码已提交,正在校验", "info") + else: + validation_var.set("验证码请求已失效") + + def cancel() -> None: + self._close_captcha() + self._cancel_operation() + + ttk.Button( + button_row, + text="提交验证码", + command=submit, + bootstyle="success", + padding=(12, 8), + ).pack(side="left", fill="x", expand=True, padx=(0, 5)) + ttk.Button( + button_row, + text="取消连接", + command=cancel, + bootstyle="danger-outline", + padding=(12, 8), + ).pack(side="left", fill="x", expand=True, padx=(5, 0)) + + window.bind("", lambda _event: submit()) + window.protocol("WM_DELETE_WINDOW", cancel) + self._center_child_window(window) + window.grab_set() + entry.focus_force() + self._captcha_dialog = _CaptchaDialog( + operation_id=event.operation_id, + request_id=event.request_id, + window=window, + photo=photo, + ) + self._set_status("等待输入图形验证码", "warning") + self._append_log("门户要求图形验证码。") + + def _center_child_window(self, window: Toplevel) -> None: + try: + window.update_idletasks() + width = window.winfo_reqwidth() + height = window.winfo_reqheight() + parent_x = self.root.winfo_rootx() + parent_y = self.root.winfo_rooty() + parent_width = self.root.winfo_width() + parent_height = self.root.winfo_height() + except TclError: + return + + x = parent_x + (parent_width - width) // 2 + y = parent_y + (parent_height - height) // 2 + parent_center_x = parent_x + parent_width // 2 + parent_center_y = parent_y + parent_height // 2 + work_areas = _screen_work_areas(self.root) + target_area = next( + ( + area + for area in work_areas + if area.left <= parent_center_x < area.right + and area.top <= parent_center_y < area.bottom + ), + work_areas[0], + ) + x = min(max(x, target_area.left), target_area.right - width) + y = min(max(y, target_area.top), target_area.bottom - height) + window.geometry(f"+{x}+{y}") + + def _close_captcha(self) -> None: + dialog = self._captcha_dialog + self._captcha_dialog = None + if dialog is None: + return + try: + dialog.window.grab_release() + except Exception: + pass + if dialog.window.winfo_exists(): + dialog.window.destroy() + + def _copy_log(self) -> None: + try: + content = self.log_text.get("1.0", "end-1c") + if not content: + return + self.root.clipboard_clear() + self.root.clipboard_append(content) + except TclError: + return + + def _clear_log(self) -> None: + try: + self.log_text.configure(state="normal") + self.log_text.delete("1.0", "end") + self.log_text.configure(state="disabled") + except TclError: + return + + def _copy_config_path(self) -> None: + try: + self.root.clipboard_clear() + self.root.clipboard_append(self.config_path_var.get()) + except TclError: + return + + def _append_log(self, message: str) -> None: + normalized = " ".join(str(message).split()) + if not normalized: + return + timestamp = datetime.now().strftime("%H:%M:%S") + self.log_text.configure(state="normal") + self.log_text.insert("end", f"{timestamp} ", "timestamp") + self.log_text.insert("end", f"{normalized}\n", "message") + line_count = int(self.log_text.index("end-1c").split(".", 1)[0]) + if line_count > MAX_LOG_LINES: + self.log_text.delete("1.0", f"{line_count - MAX_LOG_LINES}.0") + self.log_text.configure(state="disabled") + self.log_text.see("end") + + def _set_status(self, message: str, style: str) -> None: + if style not in STATUS_ICONS: + style = "info" + self._status_style = style + self.status_var.set(message) + self.header_status_var.set(message if len(message) <= 16 else f"{message[:15]}…") + self.status_icon_var.set(STATUS_ICONS[style]) + if hasattr(self, "status_icon_label"): + self.status_icon_label.configure(style=f"{style.title()}.StatusIcon.TLabel") + if hasattr(self, "status_label"): + self.status_label.configure(style=f"{style.title()}.StatusText.TLabel") + + def _update_controls(self) -> None: + is_busy = self._active_operation_id is not None + normal_when_idle = "disabled" if is_busy else "normal" + for button in self._version_buttons: + button.configure(state=normal_when_idle) + self.connect_button.configure(state=normal_when_idle) + self.reload_button.configure(state=normal_when_idle) + self.save_button.configure( + state="normal" if self._save_allowed and not is_busy else "disabled" + ) + self.probe_button.configure( + state=("normal" if not is_busy and self.version_var.get() == 2 else "disabled") + ) + self.cancel_button.configure(state="normal" if is_busy else "disabled") + self.refresh_interfaces_button.configure( + state="disabled" if is_busy or self._interface_loading else "normal" + ) + if is_busy: + self.connect_button.grid_remove() + self.probe_button.grid_remove() + self.cancel_button.grid() + self.progress.grid() + else: + self.cancel_button.grid_remove() + self.connect_button.grid() + self.probe_button.grid() + self.progress.grid_remove() + + def _request_close(self) -> None: + if self._is_closing: + return + if self._active_operation_id is None: + self._destroy_root() + return + self._is_closing = True + self._close_deadline = time.monotonic() + 3 + self._close_captcha() + self.controller.cancel() + self._set_status("正在停止后台操作…", "warning") + self._update_controls() + + def _destroy_root(self) -> None: + self._persist_window_geometry() + self._close_captcha() + poll_after_id = self._poll_after_id + self._poll_after_id = None + if poll_after_id is not None: + try: + self.root.after_cancel(poll_after_id) + except TclError: + pass + try: + if self.root.winfo_exists(): + self.root.destroy() + except TclError: + pass + + +def default_backup_directory() -> Path: + if sys.platform == "win32": + shell32 = getattr(getattr(ctypes, "windll", None), "shell32", None) + if shell32 is not None: + buffer = ctypes.create_unicode_buffer(260) + if shell32.SHGetFolderPathW(None, 5, None, 0, buffer) == 0 and buffer.value: + return Path(buffer.value) / "CampusNet Backups" + return Path.home() / "Documents" / "CampusNet Backups" + + +def default_window_state_path() -> Path: + local_app_data = os.environ.get("LOCALAPPDATA") + if local_app_data: + base_directory = Path(local_app_data) + else: + base_directory = Path.home() / "AppData" / "Local" + return base_directory / "Cedarflake" / "CampusNet" / "window-state.json" + + +def app_icon_path() -> Path: + bundle_directory = getattr(sys, "_MEIPASS", None) + if isinstance(bundle_directory, (str, os.PathLike)): + base_directory = Path(bundle_directory) + else: + base_directory = Path(__file__).resolve().parent.parent + return base_directory / "assets" / "app-icon.png" + + +def select_config_path(config_path: Path | None) -> Path: + if config_path is not None: + return config_path.expanduser().absolute() + try: + return resolve_config_path() + except FileNotFoundError: + if getattr(sys, "frozen", False): + executable_directory = Path(sys.executable).absolute().parent + project_root = packaged_project_root(sys.executable) + if project_root is not None: + return project_root / "config.json" + return executable_directory / "config.json" + return Path(__file__).resolve().parent.parent / "config.json" + + +def _screen_work_areas(root: ttk.Window) -> tuple[ScreenRect, ...]: + if sys.platform == "win32": + try: + areas = _windows_work_areas() + except (AttributeError, OSError, TypeError, ValueError, ctypes.ArgumentError): + areas = () + if areas: + return areas + return ( + ScreenRect( + left=0, + top=0, + right=root.winfo_screenwidth(), + bottom=root.winfo_screenheight(), + ), + ) + + +def _windows_work_areas() -> tuple[ScreenRect, ...]: + class _Rect(ctypes.Structure): + _fields_ = [ + ("left", ctypes.c_long), + ("top", ctypes.c_long), + ("right", ctypes.c_long), + ("bottom", ctypes.c_long), + ] + + class _MonitorInfo(ctypes.Structure): + _fields_ = [ + ("size", ctypes.c_ulong), + ("monitor", _Rect), + ("work", _Rect), + ("flags", ctypes.c_ulong), + ] + + user32 = ctypes.windll.user32 + monitor_callback_type = ctypes.WINFUNCTYPE( + ctypes.c_int, + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.POINTER(_Rect), + ctypes.c_ssize_t, + ) + user32.GetMonitorInfoW.argtypes = [ + ctypes.c_void_p, + ctypes.POINTER(_MonitorInfo), + ] + user32.GetMonitorInfoW.restype = ctypes.c_int + user32.EnumDisplayMonitors.argtypes = [ + ctypes.c_void_p, + ctypes.c_void_p, + monitor_callback_type, + ctypes.c_ssize_t, + ] + user32.EnumDisplayMonitors.restype = ctypes.c_int + discovered: list[tuple[bool, ScreenRect]] = [] + + @monitor_callback_type + def collect_monitor( + monitor: int, + _device_context: int, + _monitor_rect: ctypes.POINTER(_Rect), + _data: int, + ) -> int: + info = _MonitorInfo(size=ctypes.sizeof(_MonitorInfo)) + if user32.GetMonitorInfoW(monitor, ctypes.byref(info)): + discovered.append( + ( + bool(info.flags & 1), + ScreenRect( + left=info.work.left, + top=info.work.top, + right=info.work.right, + bottom=info.work.bottom, + ), + ) + ) + return 1 + + if not user32.EnumDisplayMonitors(None, None, collect_monitor, 0): + raise OSError("无法枚举 Windows 显示器") + return tuple(area for _primary, area in sorted(discovered, key=lambda item: not item[0])) + + +def launch_gui(*, config_path: Path | None = None, smoke_test: bool = False) -> None: + root = ttk.Window(themename="darkly") + if smoke_test: + root.withdraw() + selected_path = ( + config_path.expanduser().absolute() + if config_path is not None + else Path.cwd().absolute() / "config.json" + ) + app = CampusNetApp(root, config_path=selected_path, initialize_runtime=False) + root.update_idletasks() + root.update() + app._destroy_root() + return + + CampusNetApp(root, config_path=select_config_path(config_path)) + root.mainloop() diff --git a/workbench/network/campus-net/campus_net/gui_core.py b/workbench/network/campus-net/campus_net/gui_core.py new file mode 100644 index 0000000..310e77f --- /dev/null +++ b/workbench/network/campus-net/campus_net/gui_core.py @@ -0,0 +1,462 @@ +from __future__ import annotations + +import asyncio +import copy +import json +import os +import subprocess +import sys +import threading +from collections.abc import Awaitable, Callable, Mapping +from dataclasses import dataclass, field +from pathlib import Path +from queue import Empty, Queue +from typing import Any + +from .application import RunErrorInfo, classify_run_error, execute_config +from .config_store import ( + CaptiveConfigV2, + EditableConfig, + LegacyConfigV1, + config_from_mapping, + config_to_mapping, +) + +AsyncRunner = Callable[..., Awaitable[int]] + + +@dataclass(frozen=True, slots=True) +class LogEvent: + operation_id: int + message: str + + +@dataclass(frozen=True, slots=True) +class CaptchaRequested: + operation_id: int + request_id: int + image_bytes: bytes = field(repr=False) + content_type: str + + +@dataclass(frozen=True, slots=True) +class FinishedEvent: + operation_id: int + exit_code: int + message: str + cancelled: bool = False + + +@dataclass(frozen=True, slots=True) +class FailedEvent: + operation_id: int + exit_code: int + title: str + message: str + + +TerminalEvent = FinishedEvent | FailedEvent +GuiEvent = LogEvent | CaptchaRequested | TerminalEvent + + +@dataclass(frozen=True, slots=True) +class NetworkInterface: + index: int + alias: str + state: str + metric: int + + @property + def display_name(self) -> str: + state = { + "connected": "已连接", + "disconnected": "未连接", + "authenticating": "正在认证", + }.get(self.state.casefold(), self.state or "未知状态") + return f"{self.index} · {self.alias} · {state}" + + +@dataclass(slots=True) +class _PendingCaptcha: + loop: asyncio.AbstractEventLoop + future: asyncio.Future[str] + + +class CaptchaBroker: + def __init__(self, operation_id: int, events: Queue[GuiEvent]) -> None: + self.operation_id = operation_id + self.events = events + self._lock = threading.Lock() + self._next_request_id = 0 + self._pending: dict[int, _PendingCaptcha] = {} + + async def provider(self, image_bytes: bytes, content_type: str) -> str: + loop = asyncio.get_running_loop() + future = loop.create_future() + with self._lock: + if self._pending: + raise RuntimeError("同一连接任务不能同时等待多个验证码") + self._next_request_id += 1 + request_id = self._next_request_id + self._pending[request_id] = _PendingCaptcha(loop=loop, future=future) + self.events.put( + CaptchaRequested( + operation_id=self.operation_id, + request_id=request_id, + image_bytes=image_bytes, + content_type=content_type, + ) + ) + try: + return await future + finally: + with self._lock: + self._pending.pop(request_id, None) + + def answer(self, request_id: int, code: str) -> bool: + with self._lock: + pending = self._pending.pop(request_id, None) + if pending is None: + return False + try: + pending.loop.call_soon_threadsafe(_set_future_result, pending.future, code) + except RuntimeError: + return False + return True + + def cancel_pending(self) -> bool: + with self._lock: + pending = list(self._pending.values()) + self._pending.clear() + cancelled = False + for item in pending: + try: + item.loop.call_soon_threadsafe(_cancel_future, item.future) + except RuntimeError: + continue + cancelled = True + return cancelled + + +class OperationController: + def __init__(self, runner: AsyncRunner = execute_config) -> None: + self.events: Queue[GuiEvent] = Queue() + self._runner = runner + self._lock = threading.Lock() + self._next_operation_id = 0 + self._operation_id: int | None = None + self._thread: threading.Thread | None = None + self._loop: asyncio.AbstractEventLoop | None = None + self._task: asyncio.Task[int] | None = None + self._broker: CaptchaBroker | None = None + self._cancel_requested = False + self._is_active = False + + @property + def is_running(self) -> bool: + with self._lock: + return self._is_active + + @property + def operation_id(self) -> int | None: + with self._lock: + return self._operation_id + + def start(self, config: EditableConfig, *, probe_only: bool) -> int: + with self._lock: + if self._is_active: + raise RuntimeError("已有连接任务正在运行") + self._next_operation_id += 1 + operation_id = self._next_operation_id + broker = CaptchaBroker(operation_id, self.events) + thread = threading.Thread( + target=self._thread_main, + args=(operation_id, copy.deepcopy(config), probe_only, broker), + name=f"campus-net-operation-{operation_id}", + daemon=True, + ) + self._operation_id = operation_id + self._broker = broker + self._cancel_requested = False + self._is_active = True + self._thread = thread + try: + thread.start() + except BaseException: + self._is_active = False + self._thread = None + self._broker = None + raise + return operation_id + + def cancel(self) -> bool: + with self._lock: + if not self._is_active: + return False + self._cancel_requested = True + loop = self._loop + task = self._task + broker = self._broker + if broker is not None: + broker.cancel_pending() + if loop is not None and task is not None: + try: + loop.call_soon_threadsafe(task.cancel) + except RuntimeError: + pass + return True + + def answer_captcha(self, operation_id: int, request_id: int, code: str) -> bool: + with self._lock: + if operation_id != self._operation_id: + return False + broker = self._broker + return broker.answer(request_id, code) if broker is not None else False + + def drain_events(self, *, limit: int = 100) -> list[GuiEvent]: + drained: list[GuiEvent] = [] + for _ in range(limit): + try: + drained.append(self.events.get_nowait()) + except Empty: + break + return drained + + def _thread_main( + self, + operation_id: int, + config: EditableConfig, + probe_only: bool, + broker: CaptchaBroker, + ) -> None: + asyncio.run(self._run(operation_id, config, probe_only, broker)) + + async def _run( + self, + operation_id: int, + config: EditableConfig, + probe_only: bool, + broker: CaptchaBroker, + ) -> None: + loop = asyncio.get_running_loop() + current_task = asyncio.current_task() + if current_task is None: + raise RuntimeError("GUI 后台任务没有 asyncio Task") + with self._lock: + self._loop = loop + self._task = current_task + should_cancel = self._cancel_requested + terminal_event: TerminalEvent + try: + if should_cancel: + raise asyncio.CancelledError + exit_code = await self._runner( + config_to_mapping(config), + probe_only=probe_only, + captcha_provider=broker.provider, + status_callback=lambda message: self.events.put( + LogEvent(operation_id=operation_id, message=message) + ), + ) + except asyncio.CancelledError: + terminal_event = FinishedEvent( + operation_id=operation_id, + exit_code=3, + message="操作已取消;如果请求正在提交,服务端可能已经处理。", + cancelled=True, + ) + except Exception as error: + error_info = classify_run_error(error) or RunErrorInfo( + exit_code=3, + title="内部错误", + message=f"发生未处理的 {type(error).__name__}", + ) + terminal_event = FailedEvent( + operation_id=operation_id, + exit_code=error_info.exit_code, + title=error_info.title, + message=error_info.message, + ) + else: + terminal_event = FinishedEvent( + operation_id=operation_id, + exit_code=exit_code, + message=_completion_message(exit_code), + ) + finally: + broker.cancel_pending() + with self._lock: + if self._operation_id == operation_id: + self._loop = None + self._task = None + self._is_active = False + self.events.put(terminal_event) + + +def build_config_from_form(version: int, values: Mapping[str, str]) -> EditableConfig: + if version == 2: + interface_index_text = values.get("interface_index", "").strip() + try: + interface_index = int(interface_index_text) + except ValueError as error: + raise TypeError("interface_index 必须是整数") from error + config: dict[str, Any] = { + "version": 2, + "interface_index": interface_index, + "portal_url": values.get("portal_url", ""), + "username": values.get("username", ""), + "password": values.get("password", ""), + "carrier": values.get("carrier", ""), + } + return config_from_mapping(config) + + if version == 1: + config = { + "version": 1, + "login_url": values.get("login_url", ""), + "username": values.get("username", ""), + "encrypted_password": values.get("encrypted_password", ""), + "carrier": values.get("carrier", ""), + } + user_group = values.get("user_group", "").strip() + session_id = values.get("session_id", "").strip() + if user_group: + config["user_group"] = user_group + if session_id: + config["session_id"] = session_id + return config_from_mapping(config) + + raise ValueError("GUI 只支持 version=1 或 version=2") + + +def form_values_from_config(config: EditableConfig) -> tuple[int, dict[str, str]]: + raw_config = config_to_mapping(config) + version = raw_config["version"] + if isinstance(config, CaptiveConfigV2): + return version, { + "interface_index": str(config.interface_index), + "portal_url": config.portal_url, + "username": config.username, + "password": config.password, + "carrier": config.carrier, + } + if isinstance(config, LegacyConfigV1): + return version, { + "login_url": config.login_url, + "username": config.username, + "encrypted_password": config.encrypted_password, + "carrier": config.carrier, + "user_group": config.user_group or "", + "session_id": config.session_id or "", + } + raise TypeError("GUI 只接受正式 version=1 或 version=2 配置") + + +def list_windows_ipv4_interfaces() -> list[NetworkInterface]: + if sys.platform != "win32": + raise OSError("网卡列表当前仅支持 Windows") + system_root = os.environ.get("SystemRoot") + if not system_root: + raise OSError("Windows 环境缺少 SystemRoot") + powershell_path = ( + Path(system_root) / "System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe" + ) + if not powershell_path.is_file(): + raise OSError(f"找不到系统 PowerShell:{powershell_path}") + command = ( + "[Console]::OutputEncoding = [Text.UTF8Encoding]::new(); " + "Get-NetIPInterface -AddressFamily IPv4 | " + "Select-Object InterfaceIndex,InterfaceAlias," + "@{Name='ConnectionState';Expression={$_.ConnectionState.ToString()}}," + "InterfaceMetric | " + "ConvertTo-Json -Compress" + ) + creation_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) + result = subprocess.run( + [str(powershell_path), "-NoProfile", "-NonInteractive", "-Command", command], + check=True, + capture_output=True, + text=True, + encoding="utf-8", + timeout=10, + creationflags=creation_flags, + ) + return parse_windows_ipv4_interfaces(result.stdout) + + +def parse_windows_ipv4_interfaces(payload: str) -> list[NetworkInterface]: + try: + decoded = json.loads(payload) + except json.JSONDecodeError as error: + raise ValueError("无法解析 Windows IPv4 接口列表") from error + if isinstance(decoded, dict): + items = [decoded] + elif isinstance(decoded, list): + items = decoded + else: + raise ValueError("Windows IPv4 接口列表格式无效") + + interfaces: list[NetworkInterface] = [] + for item in items: + if not isinstance(item, dict): + continue + index = item.get("InterfaceIndex") + alias = item.get("InterfaceAlias") + state = _normalize_connection_state(item.get("ConnectionState")) + metric = item.get("InterfaceMetric") + if ( + isinstance(index, bool) + or not isinstance(index, int) + or not isinstance(alias, str) + or not alias.strip() + ): + continue + interfaces.append( + NetworkInterface( + index=index, + alias=alias.strip(), + state=state, + metric=metric if isinstance(metric, int) and not isinstance(metric, bool) else 0, + ) + ) + if not interfaces: + raise ValueError("没有找到可用的 Windows IPv4 接口") + return sorted( + interfaces, + key=lambda item: ( + item.state.casefold() != "connected", + item.metric, + item.index, + ), + ) + + +def _normalize_connection_state(value: object) -> str: + if isinstance(value, str): + return value.strip() + if isinstance(value, int) and not isinstance(value, bool): + return { + 0: "Disconnected", + 1: "Connected", + 2: "Disconnected", + 3: "Authenticating", + }.get(value, f"状态 {value}") + return "" + + +def _set_future_result(future: asyncio.Future[str], value: str) -> None: + if not future.done(): + future.set_result(value) + + +def _cancel_future(future: asyncio.Future[str]) -> None: + if not future.done(): + future.cancel() + + +def _completion_message(exit_code: int) -> str: + return { + 0: "操作完成。", + 2: "配置的 IPv4 接口不可用或状态无法识别,请检查实际承载校园网的 IPv4 接口。", + 4: "门户状态与独立连通性探测未能同时确认在线。", + }.get(exit_code, f"操作结束,退出码 {exit_code}。") diff --git a/workbench/network/campus-net/campus_net/interactive.py b/workbench/network/campus-net/campus_net/interactive.py new file mode 100644 index 0000000..4ae3745 --- /dev/null +++ b/workbench/network/campus-net/campus_net/interactive.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from io import BytesIO +from pathlib import Path +from tempfile import NamedTemporaryFile + + +class CaptchaPromptError(RuntimeError): + pass + + +class CaptchaUiUnavailable(CaptchaPromptError): + pass + + +async def prompt_captcha(image_bytes: bytes, content_type: str) -> str: + try: + return _show_native_dialog(image_bytes) + except CaptchaUiUnavailable: + return _prompt_from_temporary_file(image_bytes, content_type) + + +def _show_native_dialog(image_bytes: bytes) -> str: + try: + import tkinter as tk + + from PIL import Image, ImageTk, UnidentifiedImageError + except ImportError as error: + raise CaptchaUiUnavailable("本机缺少原生验证码窗口依赖") from error + + try: + with Image.open(BytesIO(image_bytes)) as source: + source.load() + captcha_image = source.convert("RGB") + except (OSError, UnidentifiedImageError) as error: + raise CaptchaPromptError("门户返回的验证码不是有效图片") from error + + max_width = 640 + max_height = 320 + captcha_image.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) + + try: + window = tk.Tk() + except tk.TclError as error: + raise CaptchaUiUnavailable("无法创建原生验证码窗口") from error + + result = {"code": ""} + window.title("校园网图形验证码") + window.resizable(False, False) + + photo = ImageTk.PhotoImage(captcha_image, master=window) + tk.Label(window, image=photo).pack(padx=16, pady=(16, 8)) + tk.Label(window, text="请输入图片中的验证码").pack(padx=16) + + code_entry = tk.Entry(window, width=24, justify="center") + code_entry.pack(padx=16, pady=8) + + def submit() -> None: + code = code_entry.get().strip() + if code: + result["code"] = code + window.quit() + + def cancel() -> None: + window.quit() + + button_row = tk.Frame(window) + button_row.pack(padx=16, pady=(0, 16)) + tk.Button(button_row, text="提交", width=10, command=submit).pack(side=tk.LEFT, padx=4) + tk.Button(button_row, text="取消", width=10, command=cancel).pack(side=tk.LEFT, padx=4) + + window.bind("", lambda _event: submit()) + window.protocol("WM_DELETE_WINDOW", cancel) + code_entry.focus_force() + window.lift() + window.mainloop() + window.destroy() + return result["code"] + + +def _prompt_from_temporary_file(image_bytes: bytes, content_type: str) -> str: + suffixes = { + "image/gif": ".gif", + "image/jpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", + } + suffix = suffixes.get(content_type.casefold(), ".img") + with NamedTemporaryFile(prefix="campus-net-captcha-", suffix=suffix, delete=False) as file: + file.write(image_bytes) + image_path = Path(file.name) + try: + return input(f"请查看验证码图片 {image_path},输入验证码(直接回车取消):").strip() + finally: + image_path.unlink(missing_ok=True) diff --git a/workbench/network/campus-net/campus_net/legacy.py b/workbench/network/campus-net/campus_net/legacy.py new file mode 100644 index 0000000..ab70d3e --- /dev/null +++ b/workbench/network/campus-net/campus_net/legacy.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +import json +from collections.abc import Callable +from typing import Any +from urllib.parse import urlparse + +import aiohttp + +from .config import build_legacy_runtime_from_config + + +def is_network_available( + status: int, + response_text: str, + redirect_location: str, + portal_host: str, +) -> bool: + if portal_host in response_text or portal_host in redirect_location: + return False + return 200 <= status < 400 + + +async def check_network_status( + session: aiohttp.ClientSession, + connectivity_check_url: str, + portal_host: str, + *, + status_callback: Callable[[str], None] = print, +) -> bool: + try: + timeout = aiohttp.ClientTimeout(total=8) + async with session.get( + connectivity_check_url, + allow_redirects=False, + timeout=timeout, + ) as response: + status = response.status + response_text = await response.text(errors="ignore") + redirect_location = response.headers.get("Location", "") + + if not is_network_available(status, response_text, redirect_location, portal_host): + status_callback("未连接校园网,需要登录") + return False + status_callback("已连接校园网,无需登录") + return True + except (aiohttp.ClientError, TimeoutError) as error: + status_callback(f"网络状态检查失败:{error}") + return False + + +async def get_query_string( + session: aiohttp.ClientSession, + portal_url: str, + *, + status_callback: Callable[[str], None] = print, +) -> str: + timeout = aiohttp.ClientTimeout(total=8) + async with session.get(portal_url, timeout=timeout) as response: + html = await response.text(errors="ignore") + + start = html.find("index.jsp?") + if start == -1: + status_callback("未在门户页面中找到 query_string 入口,请检查 login_url。") + return "" + start += len("index.jsp?") + end = html.find("'", start) + return html[start:] if end == -1 else html[start:end] + + +async def do_login( + session: aiohttp.ClientSession, + login_url: str, + portal_url: str, + headers: dict[str, str], + cookies: dict[str, Any], + service: str, + *, + status_callback: Callable[[str], None] = print, +) -> bool: + username = str(cookies.get("EPORTAL_COOKIE_USERNAME", "")) + password = str(cookies.get("EPORTAL_COOKIE_PASSWORD", "")) + if not username or not password: + status_callback("登录失败:配置缺少校园网账号或加密密码。") + return False + + query_string = await get_query_string( + session, + portal_url, + status_callback=status_callback, + ) + if not query_string: + return False + + post_data = { + "userId": username, + "password": password, + "service": service, + "queryString": query_string, + "operatorPwd": "", + "operatorUserId": "", + "validcode": "", + "passwordEncrypt": "true", + } + + try: + timeout = aiohttp.ClientTimeout(total=10) + async with session.post( + login_url, + headers=headers, + data=post_data, + allow_redirects=False, + timeout=timeout, + ) as response: + try: + data = await response.json(content_type=None) + except (aiohttp.ContentTypeError, json.JSONDecodeError): + status_callback(f"登录失败:门户返回非 JSON 内容,状态码 {response.status}。") + return False + except (aiohttp.ClientError, TimeoutError) as error: + status_callback(f"登录请求失败:{error}") + return False + + if not isinstance(data, dict): + status_callback("登录失败:门户返回的 JSON 不是对象。") + return False + + if data.get("result") == "success": + status_callback("登录成功!") + return True + message = _sanitize_portal_message( + data.get("message", "未知错误"), + username, + password, + str(cookies.get("JSESSIONID", "")), + ) + status_callback(f"登录失败,原因:{message}") + return False + + +async def run_legacy( + cfg: dict[str, Any], + *, + status_callback: Callable[[str], None] = print, +) -> int: + login_url, portal_url, check_url, headers, cookies, service = build_legacy_runtime_from_config( + cfg + ) + portal_host = urlparse(portal_url).hostname or "" + + async with aiohttp.ClientSession() as connectivity_session: + if await check_network_status( + connectivity_session, + check_url, + portal_host, + status_callback=status_callback, + ): + status_callback("跳过登录") + return 0 + + user_agent = headers.get("User-Agent", "") + session_headers = {"User-Agent": user_agent} if user_agent else None + cookie_jar = aiohttp.CookieJar(unsafe=True) + async with aiohttp.ClientSession( + headers=session_headers, + cookies=cookies, + cookie_jar=cookie_jar, + ) as portal_session: + succeeded = await do_login( + portal_session, + login_url, + portal_url, + headers, + cookies, + service, + status_callback=status_callback, + ) + return 0 if succeeded else 3 + + +def _sanitize_portal_message(message: object, *secrets: str) -> str: + sanitized = " ".join(str(message).split()) + for secret in secrets: + if secret: + sanitized = sanitized.replace(secret, "[已隐藏]") + return sanitized[:240] or "未知错误" diff --git a/workbench/network/campus-net/campus_net/runner.py b/workbench/network/campus-net/campus_net/runner.py new file mode 100644 index 0000000..c4372eb --- /dev/null +++ b/workbench/network/campus-net/campus_net/runner.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +from collections.abc import Callable + +import aiohttp + +from .aggregate import ( + AggregatePortalClient, + AuthenticationUncertain, + CaptchaProvider, + PortalProtocolError, +) +from .captive import NetworkState, create_bound_connector +from .config import CaptiveHttpConfig +from .interactive import prompt_captcha + + +async def run_captive_http( + config: CaptiveHttpConfig, + *, + probe_only: bool = False, + captcha_provider: CaptchaProvider = prompt_captcha, + status_callback: Callable[[str], None] = print, +) -> int: + connector = create_bound_connector(config.interface_index) + cookie_jar = aiohttp.CookieJar(unsafe=True) + timeout = aiohttp.ClientTimeout(total=15, connect=8) + headers = { + "Accept-Language": "zh-CN,zh;q=0.9", + "User-Agent": config.user_agent, + } + + async with aiohttp.ClientSession( + connector=connector, + cookie_jar=cookie_jar, + headers=headers, + timeout=timeout, + ) as session: + client = AggregatePortalClient(session, config) + status_callback("正在通过配置的 IPv4 接口探测校园网状态…") + initialization = await client.initialize() + if initialization.probe.state is NetworkState.ONLINE: + status_callback( + f"配置的 IPv4 接口 {config.interface_index} 已通过独立连通性探测,无需登录;" + "如果校园网实际位于另一接口,请重新选择对应的 IPv4 接口。" + ) + return 0 + if initialization.probe.state is NetworkState.UNKNOWN: + reason = initialization.probe.reason.rstrip("。") or "探测结果无法识别" + status_callback( + f"无法确认配置的 IPv4 接口 {config.interface_index} 的网络状态:{reason}。" + "请确认该接口已接入校园网,或重新选择实际承载校园网的 IPv4 接口;" + "已停止,未改用其他网络接口。" + ) + return 2 + if probe_only: + status_callback("已识别当前接口的 captive 门户入口;未提交任何认证信息。") + return 0 + if initialization.context is None: + raise PortalProtocolError("captive 初始化没有生成门户会话") + + context = initialization.context + current_node = context.current_node_path + if current_node == "authenticate": + status_callback("已进入身份认证节点,正在准备 SSO 登录。") + try: + await client.authenticate( + context, + username=config.username, + password=config.password, + captcha_provider=captcha_provider, + ) + except AuthenticationUncertain as auth_error: + current_node = await client.get_current_node(context.session_id) + if current_node == "authenticate": + raise auth_error + status_callback("登录响应不完整,但门户工作流已前进;没有重放登录请求。") + else: + current_node = await client.wait_for_node( + context.session_id, + {"serviceSelection", "finish"}, + ) + + if current_node == "serviceSelection": + status_callback(f"身份认证已通过,正在选择运营商:{config.service_display_name}。") + try: + await client.select_service( + context.session_id, + config.service_display_name, + ) + except AuthenticationUncertain as service_error: + current_node = await client.get_current_node(context.session_id) + if current_node != "finish": + raise service_error + status_callback("运营商认证响应不完整,但工作流已完成;没有重放请求。") + else: + current_node = await client.wait_for_node(context.session_id, "finish") + + if current_node != "finish": + raise PortalProtocolError(f"不支持的门户工作流节点:{current_node}") + + status_callback("门户工作流已完成,正在确认终端真实在线状态。") + try: + await client.trigger_online_check(context.session_id) + except AuthenticationUncertain: + status_callback("门户在线检查响应不完整;没有重放请求,改用只读状态核对。") + if await client.verify_online(context.session_id): + status_callback("校园网登录完成,门户状态与独立连通性探测均已确认在线。") + return 0 + + status_callback("门户未能与独立连通性探测同时确认在线。") + return 4 diff --git a/workbench/network/campus-net/campus_net/sso.py b/workbench/network/campus-net/campus_net/sso.py new file mode 100644 index 0000000..6106065 --- /dev/null +++ b/workbench/network/campus-net/campus_net/sso.py @@ -0,0 +1,330 @@ +from __future__ import annotations + +import base64 +import binascii +import json +from collections.abc import Mapping +from dataclasses import dataclass +from enum import Enum +from html.parser import HTMLParser +from urllib.parse import ParseResult, parse_qsl, urlencode, urljoin, urlparse, urlsplit, urlunsplit + +from Crypto.Cipher import AES + +SSO_LOGIN_PATH = "/sam-sso/login" +AUTH_SUCCESS_PATH = "/portal/assets/auth-success.html" +CAPTCHA_ERROR_CODE = "1320007" +MAX_CAPTCHA_BYTES = 2 * 1024 * 1024 + + +class SsoProtocolError(RuntimeError): + pass + + +class UnsupportedSsoChallenge(SsoProtocolError): + pass + + +class LoginResultType(Enum): + SUCCESS = "success" + CAPTCHA_ERROR = "captcha_error" + FAILURE = "failure" + UNKNOWN = "unknown" + + +@dataclass(frozen=True) +class SsoPage: + page_url: str + action_url: str + crypto_key: str + execution: str + recaptcha_vendor: str + risk_system_switch: str + error_code: str + error_message: str + + +@dataclass(frozen=True) +class CaptchaRequirement: + required: bool + image_path: str | None + + +@dataclass(frozen=True) +class LoginResult: + result_type: LoginResultType + next_page: SsoPage | None = None + error_code: str = "" + error_message: str = "" + redirect_url: str | None = None + + +class _PageMetadataParser(HTMLParser): + METADATA_IDS = frozenset( + { + "login-croypto", + "login-page-flowkey", + "recaptchaVendor", + "riskSystemSwitch", + "login-error-code", + "login-error-msg", + } + ) + + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.values: dict[str, str] = {} + self.duplicate_ids: set[str] = set() + self._current_id: str | None = None + self._depth = 0 + self._parts: list[str] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if self._current_id is not None: + self._depth += 1 + return + element_id = dict(attrs).get("id") + if element_id in self.METADATA_IDS: + if element_id in self.values: + self.duplicate_ids.add(element_id) + self._current_id = element_id + self._depth = 1 + self._parts = [] + + def handle_data(self, data: str) -> None: + if self._current_id is not None: + self._parts.append(data) + + def handle_endtag(self, tag: str) -> None: + if self._current_id is not None: + self._depth -= 1 + if self._depth == 0: + self.values[self._current_id] = "".join(self._parts).strip() + self._current_id = None + self._parts = [] + + +def parse_sso_page(page_body: bytes, page_url: str, *, language: str = "zh-CN") -> SsoPage: + values = _parse_page_metadata(page_body) + crypto_key = values.get("login-croypto", "") + execution = values.get("login-page-flowkey", "") + if not crypto_key or not execution: + raise SsoProtocolError("SSO 页面缺少动态加密密钥或 execution") + try: + decoded_key = base64.b64decode(crypto_key, validate=True) + except (binascii.Error, ValueError) as error: + raise SsoProtocolError("SSO 页面加密密钥不是有效 Base64") from error + if len(decoded_key) != 16: + raise SsoProtocolError("SSO 页面加密密钥不是 128 位") + + return SsoPage( + page_url=page_url, + action_url=build_sso_action_url(page_url, language=language), + crypto_key=crypto_key, + execution=execution, + recaptcha_vendor=values.get("recaptchaVendor", "").casefold(), + risk_system_switch=values.get("riskSystemSwitch", ""), + error_code=values.get("login-error-code", ""), + error_message=values.get("login-error-msg", ""), + ) + + +def _parse_page_metadata(page_body: bytes) -> dict[str, str]: + parser = _PageMetadataParser() + parser.feed(page_body.decode("utf-8", errors="replace")) + if parser.duplicate_ids: + duplicate_ids = ", ".join(sorted(parser.duplicate_ids)) + raise SsoProtocolError(f"SSO 页面包含重复的关键元素:{duplicate_ids}") + return parser.values + + +def ensure_supported_challenge(page: SsoPage) -> None: + if page.recaptcha_vendor not in {"", "system"}: + raise UnsupportedSsoChallenge(f"纯 HTTP 模式不支持 {page.recaptcha_vendor} 验证码") + if page.risk_system_switch == "USTC": + raise UnsupportedSsoChallenge("纯 HTTP 模式不支持 USTC 指纹风控") + + +def build_sso_action_url(page_url: str, *, language: str) -> str: + parsed = urlsplit(page_url) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + raise SsoProtocolError("SSO 页面 URL 无效") + if parsed.username is not None or parsed.password is not None or parsed.fragment: + raise SsoProtocolError("SSO 页面 URL 包含不允许的用户信息或片段") + query = [ + (key, value) + for key, value in parse_qsl(parsed.query, keep_blank_values=True) + if key != "accept-language" + ] + query.append(("accept-language", language)) + return urlunsplit((parsed.scheme, parsed.netloc, SSO_LOGIN_PATH, urlencode(query), "")) + + +def parse_captcha_requirement(payload: object) -> CaptchaRequirement: + if not isinstance(payload, dict): + raise SsoProtocolError("验证码策略响应不是 JSON 对象") + data = payload.get("data") + if data is None: + return CaptchaRequirement(required=False, image_path=None) + if not isinstance(data, dict): + raise SsoProtocolError("验证码策略响应缺少 data") + if "captchaInvisible" not in data: + return CaptchaRequirement(required=False, image_path=None) + required = data.get("captchaInvisible") + if not isinstance(required, bool): + raise SsoProtocolError("验证码策略响应缺少 captchaInvisible") + image_path = data.get("captchaUrl") + if required and (not isinstance(image_path, str) or not image_path.strip()): + raise SsoProtocolError("验证码策略要求图形码但没有图片路径") + return CaptchaRequirement( + required=required, + image_path=image_path.strip() if isinstance(image_path, str) and image_path else None, + ) + + +def resolve_captcha_url(page_url: str, image_path: str) -> str: + page = urlparse(page_url) + image_reference = urlparse(image_path) + if ( + image_reference.scheme + or image_reference.netloc + or image_reference.username is not None + or image_reference.password is not None + or image_reference.fragment + ): + raise SsoProtocolError("验证码图片路径必须是当前 SSO 下的相对路径") + allowed_roots = ("/sam-sso/", "/sso/") + if image_path.startswith("/") and not image_path.startswith(allowed_roots): + raise SsoProtocolError("验证码图片路径不属于允许的 SSO 根路径") + if image_path.startswith(allowed_roots): + candidate = urljoin(page_url, image_path) + else: + candidate = urljoin(page_url, "/sam-sso/" + image_path.lstrip("/")) + parsed = urlparse(candidate) + if ( + _normalized_origin(parsed) != _normalized_origin(page) + or parsed.username is not None + or parsed.password is not None + or parsed.fragment + or parsed.params + or not parsed.path.startswith(allowed_roots) + ): + raise SsoProtocolError("验证码图片 URL 越出当前 SSO origin") + return candidate + + +def aes_encrypt(crypto_key: str, plaintext: str) -> str: + try: + key = base64.b64decode(crypto_key, validate=True) + except (binascii.Error, ValueError) as error: + raise SsoProtocolError("SSO 加密密钥不是有效 Base64") from error + if len(key) != 16: + raise SsoProtocolError("SSO 加密密钥不是 128 位") + encoded = plaintext.encode("utf-8") + padding_length = AES.block_size - len(encoded) % AES.block_size + padded = encoded + bytes([padding_length]) * padding_length + encrypted = AES.new(key, AES.MODE_ECB).encrypt(padded) + return base64.b64encode(encrypted).decode("ascii") + + +def build_login_form( + page: SsoPage, + *, + username: str, + password: str, + captcha_code: str, + captcha_required: bool, +) -> list[tuple[str, str]]: + ensure_supported_challenge(page) + code = captcha_code.strip() + if captcha_required and not code: + raise ValueError("验证码不能为空") + + fields = [ + ("username", username), + ("type", "UsernamePassword"), + ("_eventId", "submit"), + ("geolocation", ""), + ("execution", page.execution), + ("captcha_code", code), + ("croypto", page.crypto_key), + ("password", aes_encrypt(page.crypto_key, password)), + ("captcha_payload", aes_encrypt(page.crypto_key, json.dumps({}, separators=(",", ":")))), + ] + if captcha_required: + fields.append(("captcha_code", code)) + return fields + + +def parse_login_response( + *, + status: int, + headers: Mapping[str, str], + body: bytes, + page_url: str, +) -> LoginResult: + location = next( + (value for key, value in headers.items() if key.casefold() == "location"), + "", + ) + if status in {302, 303} and location: + target = urlparse(urljoin(page_url, location)) + source = urlparse(page_url) + query_parameters = parse_qsl(target.query, keep_blank_values=True) + tickets = [value for key, value in query_parameters if key == "ticket"] + if ( + _normalized_origin(target) == _normalized_origin(source) + and target.path == AUTH_SUCCESS_PATH + and not target.params + and len(tickets) == 1 + and tickets[0] + and target.username is None + and target.password is None + and not target.fragment + ): + return LoginResult( + LoginResultType.SUCCESS, + redirect_url=urljoin(page_url, location), + ) + return LoginResult(LoginResultType.UNKNOWN) + + if status == 200: + values = _parse_page_metadata(body) + error_code = values.get("login-error-code", "") + error_message = values.get("login-error-msg", "") + has_error = bool(error_code or error_message) + next_page = None + if has_error: + try: + next_page = parse_sso_page(body, page_url) + except SsoProtocolError: + next_page = None + else: + next_page = parse_sso_page(body, page_url) + if CAPTCHA_ERROR_CODE in {error_code, error_message}: + return LoginResult( + LoginResultType.CAPTCHA_ERROR, + next_page=next_page, + error_code=error_code, + error_message=error_message, + ) + if has_error: + return LoginResult( + LoginResultType.FAILURE, + next_page=next_page, + error_code=error_code, + error_message=error_message, + ) + return LoginResult(LoginResultType.UNKNOWN) + + +def _normalized_origin(parsed_url: ParseResult) -> tuple[str, str, int]: + if not parsed_url.hostname: + raise SsoProtocolError("SSO URL 缺少 host") + try: + port = parsed_url.port + except ValueError as error: + raise SsoProtocolError("SSO URL 端口无效") from error + if port is None: + port = 80 if parsed_url.scheme.casefold() == "http" else 443 + return parsed_url.scheme.casefold(), parsed_url.hostname.casefold(), port diff --git a/workbench/network/campus-net/campus_net/window_state.py b/workbench/network/campus-net/campus_net/window_state.py new file mode 100644 index 0000000..18a1841 --- /dev/null +++ b/workbench/network/campus-net/campus_net/window_state.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from pathlib import Path +from tempfile import NamedTemporaryFile + +WINDOW_STATE_VERSION = 1 +MAX_WINDOW_STATE_BYTES = 4096 + + +@dataclass(frozen=True, slots=True) +class WindowState: + width: int + height: int + x: int + y: int + maximized: bool = False + + @property + def geometry(self) -> str: + return f"{self.width}x{self.height}{self.x:+d}{self.y:+d}" + + +@dataclass(frozen=True, slots=True) +class ScreenRect: + left: int + top: int + right: int + bottom: int + + @property + def width(self) -> int: + return self.right - self.left + + @property + def height(self) -> int: + return self.bottom - self.top + + +def load_window_state(path: str | os.PathLike[str]) -> WindowState | None: + state_path = Path(path).expanduser() + try: + if not state_path.is_file() or state_path.stat().st_size > MAX_WINDOW_STATE_BYTES: + return None + raw_state = json.loads(state_path.read_text(encoding="utf-8")) + return _state_from_mapping(raw_state) + except (OSError, UnicodeError, json.JSONDecodeError, TypeError, ValueError): + return None + + +def save_window_state( + state: WindowState, + path: str | os.PathLike[str], +) -> None: + _validate_state(state) + state_path = Path(path).expanduser().absolute() + state_path.parent.mkdir(parents=True, exist_ok=True) + content = ( + json.dumps( + { + "version": WINDOW_STATE_VERSION, + "width": state.width, + "height": state.height, + "x": state.x, + "y": state.y, + "maximized": state.maximized, + }, + ensure_ascii=False, + indent=2, + ) + + "\n" + ) + + temporary_path: Path | None = None + try: + with NamedTemporaryFile( + mode="w", + dir=state_path.parent, + prefix=f".{state_path.name}.", + suffix=".tmp", + encoding="utf-8", + newline="\n", + delete=False, + ) as temporary_file: + temporary_path = Path(temporary_file.name) + temporary_file.write(content) + temporary_file.flush() + os.fsync(temporary_file.fileno()) + os.replace(temporary_path, state_path) + temporary_path = None + finally: + if temporary_path is not None: + temporary_path.unlink(missing_ok=True) + + +def clamp_window_state( + state: WindowState | None, + work_areas: list[ScreenRect] | tuple[ScreenRect, ...], + *, + min_width: int = 940, + min_height: int = 620, + default_width: int = 1100, + default_height: int = 720, +) -> WindowState: + if min_width < 1 or min_height < 1: + raise ValueError("窗口最小尺寸必须大于 0") + if default_width < 1 or default_height < 1: + raise ValueError("窗口默认尺寸必须大于 0") + + valid_areas = tuple(area for area in work_areas if area.width > 0 and area.height > 0) + if not valid_areas: + valid_areas = ( + ScreenRect( + left=0, + top=0, + right=max(default_width, min_width), + bottom=max(default_height, min_height), + ), + ) + + if state is None: + return _centered_state( + valid_areas[0], + width=default_width, + height=default_height, + min_width=min_width, + min_height=min_height, + maximized=False, + ) + + _validate_state(state) + target_area = max(valid_areas, key=lambda area: _intersection_area(state, area)) + if _intersection_area(state, target_area) == 0: + return _centered_state( + valid_areas[0], + width=default_width, + height=default_height, + min_width=min_width, + min_height=min_height, + maximized=state.maximized, + ) + + width = min(max(state.width, min_width), target_area.width) + height = min(max(state.height, min_height), target_area.height) + x = min(max(state.x, target_area.left), target_area.right - width) + y = min(max(state.y, target_area.top), target_area.bottom - height) + return WindowState( + width=width, + height=height, + x=x, + y=y, + maximized=state.maximized, + ) + + +def _centered_state( + area: ScreenRect, + *, + width: int, + height: int, + min_width: int, + min_height: int, + maximized: bool, +) -> WindowState: + clamped_width = min(max(width, min_width), area.width) + clamped_height = min(max(height, min_height), area.height) + return WindowState( + width=clamped_width, + height=clamped_height, + x=area.left + (area.width - clamped_width) // 2, + y=area.top + (area.height - clamped_height) // 2, + maximized=maximized, + ) + + +def _intersection_area(state: WindowState, area: ScreenRect) -> int: + right = min(state.x + state.width, area.right) + bottom = min(state.y + state.height, area.bottom) + width = max(0, right - max(state.x, area.left)) + height = max(0, bottom - max(state.y, area.top)) + return width * height + + +def _state_from_mapping(raw_state: object) -> WindowState: + if not isinstance(raw_state, dict) or raw_state.get("version") != WINDOW_STATE_VERSION: + raise ValueError("窗口状态版本无效") + state = WindowState( + width=_required_int(raw_state, "width"), + height=_required_int(raw_state, "height"), + x=_required_int(raw_state, "x"), + y=_required_int(raw_state, "y"), + maximized=raw_state.get("maximized", False), + ) + _validate_state(state) + return state + + +def _required_int(raw_state: dict[str, object], key: str) -> int: + value = raw_state.get(key) + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError(f"窗口状态字段 {key} 必须是整数") + return value + + +def _validate_state(state: WindowState) -> None: + for name, value in ( + ("width", state.width), + ("height", state.height), + ("x", state.x), + ("y", state.y), + ): + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"窗口状态字段 {name} 必须是整数") + if not isinstance(state.maximized, bool): + raise ValueError("窗口状态字段 maximized 必须是布尔值") + if not 1 <= state.width <= 100_000 or not 1 <= state.height <= 100_000: + raise ValueError("窗口尺寸超出允许范围") + if abs(state.x) > 1_000_000 or abs(state.y) > 1_000_000: + raise ValueError("窗口位置超出允许范围") diff --git a/workbench/network/campus-net/config.example.json b/workbench/network/campus-net/config.example.json index 7c7417c..806327d 100644 --- a/workbench/network/campus-net/config.example.json +++ b/workbench/network/campus-net/config.example.json @@ -1,17 +1,8 @@ { - "login_url": "http://你的校园网门户/eportal/InterFace.do?method=login", - "connectivity_check_url": "http://www.baidu.com", - "service": "运营商名称,一般遵循URL编码(例如: 中国电信是 %E4%B8%AD%E5%9B%BD%E7%94%B5%E4%BF%A1)", - "user_group": "身份组,一般遵循URL编码(例如: 学生,老师),例如: 学生是 %E5%AD%A6%E7%94%9F", - "headers": { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0" - }, - "cookies": { - "EPORTAL_COOKIE_USERNAME": "你的学号", - "EPORTAL_COOKIE_PASSWORD": "你的加密后密码", - "EPORTAL_COOKIE_SERVER": "同运营商名称", - "EPORTAL_COOKIE_SERVER_NAME": "同运营商名称", - "EPORTAL_USER_GROUP": "同身份组", - "JSESSIONID": "标识用户会话的一个唯一 ID,只能从抓包里复制。" - } + "version": 2, + "interface_index": 24, + "portal_url": "http://10.71.29.181", + "username": "你的校园网账号", + "password": "你的校园网密码", + "carrier": "中国电信" } diff --git a/workbench/network/campus-net/config.legacy.example.json b/workbench/network/campus-net/config.legacy.example.json new file mode 100644 index 0000000..51a7c50 --- /dev/null +++ b/workbench/network/campus-net/config.legacy.example.json @@ -0,0 +1,9 @@ +{ + "version": 1, + "login_url": "http://你的校园网门户/eportal/InterFace.do?method=login", + "username": "你的学号", + "encrypted_password": "你的加密后密码", + "carrier": "运营商值,一般遵循旧门户的 URL 编码", + "user_group": "身份组,一般遵循 URL 编码", + "session_id": "从旧版门户抓包得到的 JSESSIONID" +} diff --git a/workbench/network/campus-net/docs/new-portal-adaptation.md b/workbench/network/campus-net/docs/new-portal-adaptation.md new file mode 100644 index 0000000..7d08ea9 --- /dev/null +++ b/workbench/network/campus-net/docs/new-portal-adaptation.md @@ -0,0 +1,437 @@ +# 新版 captive SSO 适配记录 + +本文记录 `campus-net` 从旧版 ePortal 适配到新版聚合 captive SSO 的分析过程、协议结论、实现结构和验证方法。它面向后续维护者,不替代项目根目录的使用说明。 + +适用基线:2026-07-17。实现入口见 [`main.py`](../main.py),新版协议代码位于 [`campus_net/`](../campus_net/)。 + +## 1. 适配目标与边界 + +本次适配的目标是: + +- 保留旧版 `/eportal/InterFace.do?method=login` 登录能力; +- 支持从 captive 劫持页进入的新版聚合认证流程; +- 不打开或控制浏览器,只用 HTTP 请求复现门户前端的必要协议; +- 同时存在校园网、手机热点、有线网或代理软件时,让新版 socket 显式请求使用指定校园网接口,而不是依赖默认路由; +- 自动完成账号密码提交、运营商选择和在线确认; +- 服务端要求系统图形验证码时,只让用户输入验证码,其余步骤继续自动完成; +- 不以登录页提示或 Windows 网络图标作为唯一成功依据。 + +这里的“纯 HTTP”指不依赖浏览器自动化。程序仍需要遵守服务端验证码和风控协议,不包含 OCR、验证码猜测或绕过逻辑。 + +## 2. 旧版与新版的根本差异 + +旧版 ePortal 的核心是一次表单提交:从门户首页提取 `queryString`,再向 `/eportal/InterFace.do?method=login` 提交账号、旧版加密密码、运营商和若干 Cookie。 + +新版不是单一登录接口,而是由四层状态共同组成: + +1. captive 劫持入口提供当前连接的接入参数; +2. 聚合门户创建 `sessionId` 并维护 `portal_auth` 工作流; +3. `sam-sso` 完成身份认证并返回 ticket; +4. 聚合门户根据动态服务值完成运营商认证,最后把终端置为在线。 + +因此,直接打开固定的 `/sam-sso/login` 即使能看到登录页,也不能等价替代 captive 入口。它缺少当前接入会话产生的 `flowSessionId`、终端信息和聚合门户上下文;仅完成 SSO 也没有执行后续运营商选择与 `userOnline` 流程。 + +分析期间还排除了两个看似可用、实际不能作为稳定起点的浏览器地址:`/portal/entry/pc/authenticate;flowParams=undefined;from=` 带有字面量 `undefined`,没有可复用的完整流程上下文;带 `service=...` 的 `/sam-sso/login` 则只是身份认证子流程。真正稳定的起点只能来自当前连接被劫持后的 captive 响应。 + +## 3. 分析过程 + +本次分析没有访问服务端源码。协议结论来自当前网络下的实际 HTTP 响应、门户下发的 HTML/前端行为、错误返回和脱敏测试夹具;未观测到的服务端行为不作为实现假设。 + +### 3.1 先保留旧实现和本地配置 + +适配开始前没有覆盖旧协议,而是先把原实现视为 `legacy-eportal` 基线。迁移实际 `config.json` 前先复制到仓库外并校验 SHA-256,确保配置重构失败时仍可恢复。仓库中只保留脱敏示例,账号、密码、Cookie、ticket 和 `sessionId` 都不进入版本控制。 + +### 3.2 解决多网络并存问题 + +分析时外网连接和校园网连接需要同时存在。如果依赖 Windows 默认路由,HTTP 请求可能经过手机热点、以太网、系统代理或 TUN,而不是正在等待认证的校园网接口,抓到的结果也就失去意义。 + +新版实现不按 WLAN 名称判断网络,而是要求一个 Windows IPv4 `interface_index`。每个 aiohttp socket 在连接前设置: + +```text +IPPROTO_IP / IP_UNICAST_IF / network-byte-order(interface_index) +``` + +这样 Wi-Fi、有线网卡或其他 IPv4 接口都使用同一套逻辑。它减少普通默认路由造成的选择歧义;程序不会修改系统路由、sing-box 或 v2rayN 配置。`IP_UNICAST_IF` 只能要求 socket 选定接口,不能证明最终物理出口。内核级 TUN/WFP 仍可能透明截获流量,甚至返回可被分类为 `ONLINE` 或 `CAPTIVE` 的合法响应;需要证明物理出口时仍应使用抓包或系统路由诊断。 + +运行时不会把任何接口名称或类型固定为校园网。校园网可以位于 WLAN、以太网或其他 IPv4 接口,配置必须指向实际承载校园网的接口索引。配置接口未连接、没有可用路由或无法完成探测时,流程保持 `UNKNOWN` 并以退出码 `2` 停止,提示中不暴露 `aiohttp`、主机、SSL 或本地化 socket 异常细节,也不会创建无绑定连接器或回退到其他网络接口。识别门户后的 HTTP 超时或连接错误同样由 CLI 与 GUI 共用的异常分类器转换为通用接口提示,不直接显示底层传输异常。配置接口已通过独立在线指纹时只说明该接口已经联网;如果校园网位于另一接口,用户仍需改选对应的 IPv4 接口。 + +连接器同时固定 `AF_INET` 并启用 `force_close=True`:当前流程只创建 IPv4 socket,也不复用持久连接。 + +实现见 [`campus_net/captive.py`](../campus_net/captive.py) 中的 `WindowsInterfaceSocketFactory` 和 `create_bound_connector()`。 + +### 3.3 从 captive 响应取得真实入口 + +新版不能把 `/sam-sso/login` 或某次捕获的长 URL 固定到配置中。程序先通过绑定接口请求 Windows 连通性探测地址: + +```text +http://www.msftconnecttest.com/connecttest.txt +``` + +响应按以下顺序分类: + +- HTML 脚本中存在合法的 `location.href` 赋值:`CAPTIVE`; +- 状态码和响应体精确等于在线指纹:`ONLINE`; +- 其他响应或歧义响应:`UNKNOWN`。 + +识别出的 captive URL 必须同时满足: + +- 与配置的门户根地址同 origin; +- 路径严格等于 `/eportal/index.jsp`; +- `wlanuserip`、`wlanacname`、`nasip`、`mac` 各出现一次且非空; +- 不含用户信息、路径参数或片段; +- 页面中只能出现一个不同的合法入口。 + +程序保留门户给出的完整 URL,不重排或重新编码查询串。非法 captive 入口不会因为同一响应碰巧匹配在线指纹而被忽略。 + +### 3.4 还原聚合门户工作流 + +请求真实 `/eportal/index.jsp?...` 后,程序要求门户返回 `302`,并校验跳转到同 origin 的 `/portal/portal-main`。跳转 URL 中必须有: + +- `sessionId` +- `customPageId` +- `nasIp` +- `userIp` +- `userMac` + +随后按门户前端顺序初始化: + +1. `GET /portal/portal-main`; +2. `GET /eportal/adaptor/queryTerminalInfo`; +3. `POST /eportal/network/getIdentityConfig`; +4. `POST /eportal/workFlow/getCurrentNode`,查询 `flowKey=portal_auth`。 + +当前节点决定下一步: + +- `authenticate`:进入 SSO; +- `serviceSelection`:账号身份已完成,直接选择运营商; +- `finish`:认证工作流已经完成; +- 其他节点:按未知协议停止,不猜测下一步。 + +### 3.5 还原 SSO 动态表单 + +SSO 页面不是静态表单。程序使用聚合门户上下文构造 `/sam-sso/login` 查询参数,再从页面提取: + +- `login-croypto`:Base64 编码的 128 位本次登录密钥; +- `login-page-flowkey`:动态 `execution`; +- `recaptchaVendor`:验证码提供方; +- `riskSystemSwitch`:风控类型; +- `login-error-code` 和 `login-error-msg`:登录结果信息。 + +如果页面启用第三方 reCAPTCHA、Geetest 一类验证,或 `USTC` 指纹风控,纯 HTTP 客户端会明确停止。当前实现只支持无验证码和门户自身的 `system` 图形验证码。 + +账号密码表单中的关键字段如下: + +| 字段 | 处理方式 | +| --- | --- | +| `username` | 原值提交 | +| `password` | AES-128-ECB、PKCS#7 padding,再 Base64 | +| `execution` | 使用当前页面动态值 | +| `croypto` | 回传当前页面动态密钥字符串 | +| `captcha_payload` | 将紧凑 JSON `{}` 用同一密钥加密 | +| `captcha_code` | 系统图形验证码明文;要求验证码时保持前端的重复字段形态 | +| `_eventId` | 固定为 `submit` | +| `type` | 固定为 `UsernamePassword` | + +表单使用有序的键值对列表而不是字典,原因是验证码分支存在两个同名 `captcha_code` 字段,普通字典会错误地合并它们。 + +登录只在以下响应形态下被判定为成功: + +- 状态码为 `302` 或 `303`; +- 跳转仍在同一 origin; +- 路径严格为 `/portal/assets/auth-success.html`; +- 查询串中恰好有一个非空 `ticket`。 + +程序随后访问该聚合门户回调,让 SSO 结果回到门户会话;回调 GET 必须返回 HTTP 200,否则认证结果按不确定处理。其他重定向、跨 origin 回调或结构不明的 200 页面都不会被误判为成功。 + +### 3.6 验证码策略与会话关系 + +系统验证码由服务端会话决定,图片、Cookie、动态密钥、`execution` 和登录 POST 必须位于同一个 `aiohttp.ClientSession`。对 IP 地址门户启用 `CookieJar(unsafe=True)`,否则 aiohttp 默认会拒绝保存 IP host 下发的会话 Cookie。这里的 `unsafe` 只放宽 IP host Cookie 规则,不代表关闭 TLS 校验。 + +验证码策略查询复现了前端的受保护请求头: + +1. 生成 32 字符随机 `Csrf-Key`; +2. 对它进行 Base64 编码; +3. 在编码串中点插入一份完整编码串; +4. 对组合结果计算 MD5,得到 `Csrf-Value`。 + +首次提交先查询 `DEFAULT_CAPTCHA_SWITCH`;默认策略未要求验证码时,再查询当前用户名。服务端要求验证码后,程序从同一 SSO origin 获取图片。CLI 路径显示原生 Tkinter 窗口,窗口不可用时才退回临时图片文件和终端输入,临时文件在输入结束后删除;GUI 路径通过线程安全事件把图片交给 Tk 主线程,在同一个应用根窗口下显示验证码对话框。两种路径都只回传用户输入的文本,不把图片交给浏览器、OCR 或第三方服务。 + +只有服务端明确返回验证码错误码 `1320007` 时,客户端才刷新页面、策略和图片后继续输入。账号密码错误、未知错误、超时或无法解析的响应都不会触发自动重放。`captcha_attempts=0` 是内部默认值,表示客户端不额外设置次数上限,不代表绕过服务端校验。 + +#### 3.6.1 验证码协议的逆向思路 + +这里的“逆向”是还原浏览器与服务端之间的公开 HTTP 合约,不是反编译服务端。分析时依次回答五个问题: + +1. **谁决定是否出题**:比较默认策略和按用户名查询的 JSON,确认决定来自服务端的 `captchaInvisible`,不是页面本地开关; +2. **题目如何取得**:跟踪 `captchaUrl`、受保护请求头、Cookie 和时间戳,确认图片必须从当前 SSO 会话读取; +3. **答案放在哪里**:检查浏览器等价表单的有序字段,确认答案以明文 `captcha_code` 提交,并且验证码分支保留两个同名字段; +4. **哪些字段参与加密**:用页面提供的已知密钥核对请求构造,确认只有 `password` 和内容为 `{}` 的 `captcha_payload` 使用 AES,用户名和验证码答案不使用该算法; +5. **服务端如何反馈**:比较已有成功、验证码错误和普通登录错误响应,确认只有 `1320007` 是可刷新图片后继续的验证码错误。 + +分析结果可以表示为: + +```text +当前 HTTP 会话 + ├─ Cookie + execution + crypto_key + ├─ captcha policy -> required + captchaUrl + ├─ captcha image -> 当前客户端仅按图像字节处理 + └─ login POST -> captcha_code + 加密密码 + 加密的空 payload + │ + └─ 最终校验由服务端完成,内部存储或派生方式未知 +``` + +在当前观察到的 HTTP 与前端合约中,客户端能看到“题目图片”和“答案输入位置”,但没有观察到独立答案字段、可替代校验令牌或本地校验函数。实现不知道服务端是存储答案还是无状态派生答案。已分析的客户端流程只把动态 AES 密钥用于密码和 payload 的传输,没有发现用它推导图片文字的步骤;当前 `captcha_payload` 解密后是 `{}`,`1320007` 则是提交后的错误结果。 + +#### 3.6.2 常见“破解”假设的证据评估 + +| 假设 | 现有证据与结论 | +| --- | --- | +| 从 `crypto_key` 推出验证码 | 在已分析的前端流程中,密钥只用于 AES 表单字段,验证码以明文独立提交;没有观察到客户端推导步骤 | +| 解密 `captcha_payload` 得到答案 | 当前客户端构造的 payload 明文固定为 `{}`,其中没有字符答案 | +| 把 `captchaInvisible` 改成 `false` | 该值只是服务端策略响应;修改本地判断不会改变登录 POST 的服务端校验 | +| 省略 `captcha_code` 或伪造成功回调 | 服务端仍维护工作流与 ticket;客户端还会严格核对同 origin 回调、节点和在线状态 | +| 复用旧图片、旧答案或旧 ticket | 当前流程把图片、Cookie、`execution`、动态密钥和 ticket 作为具体会话状态使用,持久化复用不构成已知的稳定协议 | +| 不断提交任意答案 | 没有可推导关系,只会产生自动化试错和潜在账号风险;本项目不实现这种行为 | +| OCR 或第三方代答 | 这会把“人工读取服务端挑战”改成自动求解或转交挑战,超出本项目的协议自动化边界,也可能泄露会话相关图片 | + +因此,当前观察到的客户端合约中,未发现除视觉挑战外的机器可读答案来源或可替代校验令牌。项目可以继续自动化验证码前后的全部流程,但不会实现验证码破解、OCR、暴力枚举、第三方代答或伪造校验结果。若学校以后提供正式的无障碍验证、设备授权或可信 API,应优先按其公开协议接入。 + +### 3.7 动态选择运营商 + +配置只保存运营商显示名,例如“中国电信”,不保存某次会话中的内部值。进入 `serviceSelection` 后,程序: + +1. `POST /eportal/network/serviceSelection` 获取本次会话的服务数组; +2. 要求显示名精确匹配且只匹配一项; +3. 取该项动态 `value`; +4. `POST /eportal/network/serviceLogin`; +5. 要求业务结果中的 `authResult` 严格等于 `success`。 + +这避免把内部服务标识误当成长期稳定配置,也避免同名多项时随意选择。 + +### 3.8 双重确认真正在线 + +门户工作流到达 `finish` 后,程序调用 `/eportal/network/userOnline`,但不会只相信这次 POST 的成功文案。最终在最长 30 秒内循环确认两件事: + +1. `/eportal/adaptor/getOnlineUserInfo` 返回在线用户且结果为 `success`; +2. 绑定校园网接口重新访问连通性探测地址,状态码为 200 且响应体精确为 `Microsoft Connect Test`。 + +两者必须在同一次验证窗口内同时成立,才输出“校园网登录完成”。这解决了“门户显示成功,但电脑实际仍不能访问互联网”的假阳性。 + +## 4. 完整请求序列 + +```mermaid +sequenceDiagram + participant C as campus-net + participant P as 连通性探测地址 + participant A as 聚合门户 + participant S as sam-sso + + C->>P: GET(socket 绑定 interface_index) + alt 已在线 + P-->>C: 200 + 精确在线指纹 + else captive + P-->>C: HTML location.href = /eportal/index.jsp?... + C->>A: GET 完整 captive URL + A-->>C: 302 /portal/portal-main?sessionId=... + C->>A: 初始化终端、身份配置和工作流节点 + alt currentNodePath = authenticate + C->>S: GET 动态登录页 + C->>S: GET 验证码策略 + opt 服务端要求系统验证码 + C->>S: GET 同会话验证码图片 + C-->>C: 原生窗口只收集验证码文本 + end + C->>S: POST 动态加密表单 + S-->>C: 同 origin 成功回调 + ticket + C->>A: GET /portal/assets/auth-success.html 回调 + end + C->>A: 查询工作流节点 + opt currentNodePath = serviceSelection + C->>A: 获取服务列表 + C->>A: 用显示名对应的动态 value 提交 serviceLogin + end + C->>A: userOnline + loop 最长 30 秒 + C->>A: getOnlineUserInfo + C->>P: GET 精确在线指纹 + end + end +``` + +## 5. 会话一致性与非幂等请求保护 + +新版流程从第一次 captive 探测到最终在线确认共用一个绑定接口的 `ClientSession`。这保证: + +- Cookie 不跨接口或跨会话丢失; +- 验证码图片与验证码 POST 属于同一服务端会话; +- captive URL、`sessionId`、动态密钥、`execution` 和 ticket 不被持久化复用; +- 所有请求都保持相同接口选择。 + +对可能产生状态变化的请求采用“不确定即不重放”原则: + +- SSO 登录 POST 超时或响应无法解析时,先只读查询当前工作流节点; +- `serviceLogin` 结果不确定时,先检查节点是否已经到 `finish`; +- `userOnline` 结果不确定时,改用只读在线状态和独立探测确认; +- 只有明确的 `1320007` 才重新提交验证码登录表单。 + +这避免网络抖动时重复认证、重复运营商登录或错误消耗服务端状态。 + +## 6. 输入与响应校验 + +实现没有把门户返回值直接拼接到任意请求中,而是逐层收紧边界: + +- captive 响应最多读取 64 KiB,入口 URL 最长 4096 字符; +- SSO 页面和登录响应最多读取 512 KiB,验证码图片最多 2 MiB; +- URL 必须是 HTTP/HTTPS,关键跳转和验证码图片必须同 origin; +- 关键查询参数必须唯一,重复参数会中止流程; +- 只接受预期路径,不跟随任意重定向; +- 门户 JSON 同时校验 HTTP 状态、业务 `code`、`data` 类型和关键字段; +- 正式 `version=1/2` 扁平配置拒绝未知字段,避免拼写错误被静默忽略;无版本历史格式保留原有宽松兼容边界; +- 密码保留原始空白字符,并从配置对象的 `repr` 中隐藏。 + +这些校验既用于发现门户升级造成的协议漂移,也用于避免 captive 页面中的异常内容把客户端引向错误地址。 + +## 7. 配置版本设计 + +公开配置只用 `version` 选择协议,而不是表达 JSON schema 的修订号: + +| `version` | 协议 | 密码形态 | 用途 | +| --- | --- | --- | --- | +| `1` | `legacy-eportal` | 旧门户加密值 `encrypted_password` | 原 `/eportal/InterFace.do?method=login` | +| `2` | `captive-sso-http` | 明文 `password`,提交前按动态密钥加密 | 新版聚合 captive SSO | + +新版只暴露六个稳定字段: + +```json +{ + "version": 2, + "interface_index": 24, + "portal_url": "http://10.71.29.181", + "username": "你的校园网账号", + "password": "你的校园网密码", + "carrier": "中国电信" +} +``` + +User-Agent、探测指纹、入口路径、验证码策略常量、确认间隔和超时由实现维护。它们属于当前协议适配,不要求每个用户跟着门户升级手改配置。 + +兼容策略如下: + +- 新配置必须显式填写 `version=1` 或 `version=2`; +- 无 `version`、带 `adapter: "captive-sso-http"` 的早期嵌套配置继续读取; +- 无 `version` 的历史 Cookie ePortal 配置继续读取; +- 已移除的 `schema_version` 和顶层 `mode` 会给出迁移错误,不做含糊推断; +- 新旧密码字段不能混用。 + +解析和兼容实现见 [`campus_net/config.py`](../campus_net/config.py)。 + +## 8. 模块职责 + +| 文件 | 职责 | +| --- | --- | +| [`main.py`](../main.py) | CLI 配置查找、参数解析和顶层退出码处理 | +| [`gui_main.py`](../gui_main.py) | GUI 参数解析和独立可执行文件入口 | +| [`campus_net/application.py`](../campus_net/application.py) | CLI 与 GUI 共用的协议分派和错误分类 | +| [`campus_net/config.py`](../campus_net/config.py) | `version` 分派、配置校验、历史配置兼容和内部默认值 | +| [`campus_net/config_paths.py`](../campus_net/config_paths.py) | 源码与打包态共用的配置候选路径和 `dist` 父项目目录回退 | +| [`campus_net/config_store.py`](../campus_net/config_store.py) | 正式配置编辑、并发修订检查、校验备份和原子保存 | +| [`campus_net/captive.py`](../campus_net/captive.py) | Windows 接口绑定、captive URL 提取、在线指纹分类 | +| [`campus_net/aggregate.py`](../campus_net/aggregate.py) | 聚合门户工作流、验证码策略、运营商选择和在线确认 | +| [`campus_net/sso.py`](../campus_net/sso.py) | SSO 页面解析、AES 表单、验证码 URL 和登录结果判定 | +| [`campus_net/interactive.py`](../campus_net/interactive.py) | CLI 原生验证码窗口和无 GUI 时的临时文件回退 | +| [`campus_net/runner.py`](../campus_net/runner.py) | 串联新版流程并处理不确定 POST 的只读状态核对 | +| [`campus_net/legacy.py`](../campus_net/legacy.py) | 保留旧版 ePortal 登录流程 | +| [`campus_net/gui_core.py`](../campus_net/gui_core.py) | GUI 后台任务、事件队列、验证码桥接、接口枚举和表单校验 | +| [`campus_net/gui.py`](../campus_net/gui.py) | `ttkbootstrap` 主线程窗口、配置编辑和用户交互 | +| [`campus_net/window_state.py`](../campus_net/window_state.py) | 窗口位置、尺寸、最大化状态的无敏感信息持久化与多显示器校正 | + +### 8.1 GUI 并发与配置保存边界 + +图形界面和 CLI 共用 `application.execute_config()`,不会维护第二套校园网协议实现。Tk/`ttkbootstrap` 控件只在主线程创建和更新;连接任务在单独线程中运行自己的 asyncio 事件循环,通过队列上报日志、结果和验证码图片。验证码答案由请求 ID 关联回当前异步任务,关闭验证码窗口会取消当前连接,而不会提交空答案。取消发生在请求提交附近时,界面会明确提示服务端可能已经处理该请求,不把本地取消等同于服务端回滚。 + +IPv4 接口枚举固定调用 `%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe`,避免当前目录中的同名程序参与 Windows 可执行文件搜索。PowerShell 枚举值在 JSON 中可能是 `0/1` 数字,也可能是状态字符串;解析层统一归一化,再在界面显示为“已连接”“未连接”或“正在认证”。枚举进程超时、失败或后台线程无法启动时都会恢复刷新按钮,用户仍可直接填写接口索引。 + +窗口关闭时把普通窗口的 `x/y`、宽高和最大化状态原子写入 `%LOCALAPPDATA%\Cedarflake\CampusNet\window-state.json`。该独立 UI 状态不含配置或登录数据,读取失败会静默回退默认布局。恢复时按 Windows 各显示器工作区检查负坐标和可见范围;原显示器不存在时回到主屏居中,尺寸同时受最小窗口和当前工作区约束。 + +GUI 只编辑显式带 `version=1` 或 `version=2` 的正式配置。无版本历史配置仍由 CLI 兼容读取,但 GUI 拒绝静默改写。连接和探测使用当前表单的内存副本;只有显式保存才写磁盘。 + +覆盖配置时,`config_store` 执行以下顺序: + +1. 校验当前文件仍与加载时的 SHA-256 修订一致,阻止覆盖外部修改; +2. 把旧文件写入 Windows“文档”目录下的 `CampusNet Backups`; +3. 校验备份文件与旧文件的大小和 SHA-256; +4. 在配置所在目录写入并同步临时文件; +5. 再次核对修订后用同目录原子替换,并校验最终内容。 + +替换前的任何备份、修订或完整性检查失败都会停止保存并保留旧文件。内容未变化时不覆盖文件,也不创建重复备份;新建配置没有旧内容可备份。若原子替换后的目录同步或最终回读校验失败,磁盘状态已经无法由异常本身判断,GUI 会禁用继续保存、标记结果不确定并要求重新加载;已有旧文件时,替换前生成的校验备份仍用于恢复。配置路径不是普通文件、JSON 存在重复键、文件超过大小限制或包含未知正式字段时同样拒绝写入。备份与配置都含账号和密码,必须保持在版本控制之外。 + +打包态继续兼容原有的当前目录查找顺序,并在 EXE 同目录后增加一个受限回退:只有 EXE 目录名为 `dist`,且父目录同时包含 `pyproject.toml` 与 `config.example.json` 项目标记时,才检查父目录的 `config.json`。这覆盖本项目的 PyInstaller 输出布局,又不会对任意安装目录盲目向上搜索。`--print-config-path` 只解析并输出最终路径,不打开配置文件也不发出网络请求;显式 GUI `--config` 路径缺失时不会静默改用其他账号配置。 + +PyInstaller 保留两个独立入口:`build.spec` 生成控制台版 `Auto-Connect-CampusNet.exe` 并嵌入紫色多尺寸 `cli-icon.ico`;`gui.spec` 生成窗口版 `Auto-Connect-CampusNet-GUI.exe`,嵌入蓝色多尺寸 `app-icon.ico`,并只把 `app-icon.png` 作为窗口/Header 品牌素材加入 `datas`。本地 `config.json`、备份和运行时状态均不会嵌入可执行文件。 + +## 9. 退出码和可观察结果 + +| 退出码 | 含义 | +| --- | --- | +| `0` | 已在线、探测成功,或登录后双重在线确认成功 | +| `1` | 配置缺失、JSON 错误或字段校验失败 | +| `2` | 配置的 IPv4 接口不可用、探测结果无法识别,或旧版模式不支持 `--probe-only` | +| `3` | 登录被拒绝、结果不确定、验证码 UI、门户协议、HTTP/网络或超时错误 | +| `4` | 门户状态和独立连通性探测未能同时确认在线 | + +`--probe-only` 不提交账号、密码、验证码或运营商认证。接口处于 captive 状态时,它仍会沿真实入口完成只读门户初始化、终端信息查询和当前工作流节点查询,而不是只发一个探测 GET;这让它可以同时验证接口选择与入口协议是否仍兼容。 + +## 10. 测试与实际验证 + +单元测试按协议边界拆分: + +- `test_captive.py`:真实脚本形态、入口参数、origin/path、歧义响应、在线指纹、Windows socket 绑定和探测异常脱敏; +- `test_runner.py`:配置接口不可达时的退出码、CLI/GUI 共用提示、只使用绑定连接器和禁止其他网络回退; +- `test_sso.py`:动态字段、AES/PKCS#7、验证码策略、风险类型、成功回调和错误码; +- `test_aggregate.py`:完整会话顺序、CSRF 头、验证码刷新、非幂等请求不重放、运营商和双重在线判定; +- `test_captive_config.py`:`version=2` 六字段配置和早期嵌套配置兼容; +- `test_config.py`:`version=1`、历史 Cookie 配置和旧版在线判断; +- `test_config_store.py`:正式配置解析、修订冲突、强制备份、完整性校验和原子保存; +- `test_config_paths.py`:源码和打包态候选顺序、`dist` 父目录、显式路径与符号链接边界; +- `test_gui_core.py`:无 Tk 的表单、后台任务、取消、验证码桥接和接口列表测试; +- `test_window_state.py`:窗口状态原子保存、损坏回退、负坐标多显示器和离屏校正; +- `test_application.py`:CLI/GUI 共用协议分派、错误分类和后续网络异常脱敏。 + +项目级验证命令: + +```powershell +uv sync --group dev +& .\.venv\Scripts\python.exe -m unittest discover -s tests -p "test_*.py" -v +& .\.venv\Scripts\python.exe main.py --probe-only +& .\.venv\Scripts\python.exe -m PyInstaller --clean --noconfirm build.spec +& .\.venv\Scripts\python.exe -m PyInstaller --clean --noconfirm gui.spec +``` + +子项目通过 `[tool.uv.workspace]` 声明独立的项目发现边界,避免 `uv sync` 把仓库根仅包含 Ruff 配置的 `pyproject.toml` 误报为缺少 `[project]` 的项目文件。环境仍由 uv 创建和同步;测试、探测和打包直接调用该环境的 Python,避免后续命令重复执行项目发现。 + +GUI 产物另提供 `--smoke-test`:只创建、更新并销毁主窗口,不读取配置、不枚举网卡也不访问网络,可用于构建后的窗口资源冒烟检查。本次适配还分别用源码入口和重新构建的 CLI EXE 执行了绑定接口探测,并在实际登录测试中确认最终成功条件为“门户在线状态 + 独立 HTTP 在线指纹”。完整测试数量随 GUI 边界用例增长,以全量测试命令的实际输出为准。 + +## 11. 已知限制 + +- 新版接口绑定当前只实现 Windows IPv4;非 Windows 或 IPv6 会明确报错。 +- 只支持当前观察到的聚合门户节点:`authenticate`、`serviceSelection`、`finish`。 +- 只支持无验证码和门户自身的系统图形验证码。 +- 图形验证码需要人工读取;它是服务端认证条件,不是待补的浏览器自动化步骤。 +- 不支持 reCAPTCHA、Geetest 和 USTC 指纹风控。 +- 不修改系统路由、DNS、随机 MAC、sing-box 或 v2rayN 配置。 +- 门户路径、隐藏字段、加密方式、错误码或 CSRF 算法变化后,需要更新实现和对应测试。 + +## 12. 后续门户升级时的排查顺序 + +如果新版再次变化,按以下顺序定位,避免直接修改密码加密或反复重放登录: + +1. 用 `--probe-only` 确认 socket 绑定后的协议响应;如需证明最终物理出口,再使用抓包或系统路由诊断; +2. 检查 captive 响应是否仍给出 `/eportal/index.jsp` 以及四个接入参数; +3. 检查 `portal-main` 跳转参数和 `currentNodePath` 是否变化; +4. 检查 SSO 页的动态字段、验证码提供方和风控开关; +5. 检查验证码策略的 JSON、CSRF 算法和图片路径; +6. 检查登录成功回调路径以及 ticket 形态; +7. 检查服务列表的 `key/value` 结构和工作流节点; +8. 最后检查 `userOnline`、`getOnlineUserInfo` 和独立在线指纹。 + +调试日志和测试夹具必须脱敏。不要记录真实密码、验证码、Cookie、ticket、`sessionId`、IP、MAC 或可复用的完整 captive URL。 diff --git a/workbench/network/campus-net/gui.spec b/workbench/network/campus-net/gui.spec new file mode 100644 index 0000000..ab0302f --- /dev/null +++ b/workbench/network/campus-net/gui.spec @@ -0,0 +1,39 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ["gui_main.py"], + pathex=[], + binaries=[], + datas=[("assets/app-icon.png", "assets")], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name="Auto-Connect-CampusNet-GUI", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + icon="assets/app-icon.ico", + codesign_identity=None, + entitlements_file=None, +) diff --git a/workbench/network/campus-net/gui_main.py b/workbench/network/campus-net/gui_main.py new file mode 100644 index 0000000..712a183 --- /dev/null +++ b/workbench/network/campus-net/gui_main.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import argparse +from collections.abc import Sequence +from pathlib import Path + +from campus_net.gui import launch_gui + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Campus Net 图形界面", + allow_abbrev=False, + ) + parser.add_argument( + "--config", + type=Path, + metavar="PATH", + help="使用指定配置文件;默认按程序内置顺序查找 config.json", + ) + parser.add_argument( + "--smoke-test", + action="store_true", + help="只创建、更新并销毁窗口,不读取配置或访问网络", + ) + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + args = parse_args(argv) + launch_gui(config_path=args.config, smoke_test=args.smoke_test) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/workbench/network/campus-net/main.py b/workbench/network/campus-net/main.py index b15a35b..9624f4a 100644 --- a/workbench/network/campus-net/main.py +++ b/workbench/network/campus-net/main.py @@ -1,203 +1,58 @@ +from __future__ import annotations + +import argparse import asyncio import json -import os import sys -from pathlib import Path from typing import Any -from urllib.parse import urlparse - -import aiohttp - -CONFIG_ENV = "CAMPUSNET_CONFIG" -DEFAULT_CONNECTIVITY_CHECK_URL = "http://www.baidu.com" - - -def config_candidates(): - configured_path = os.getenv(CONFIG_ENV) - if configured_path: - yield Path(configured_path).expanduser() - - yield Path.cwd() / "config.json" - - if getattr(sys, "frozen", False): - yield Path(sys.executable).resolve().parent / "config.json" - - yield Path(__file__).resolve().parent / "config.json" - - bundled_directory = getattr(sys, "_MEIPASS", None) - if bundled_directory: - yield Path(bundled_directory) / "config.json" +from campus_net.application import classify_run_error, execute_config +from campus_net.config_paths import resolve_config_path -def load_config(): - candidates = list(dict.fromkeys(path.resolve() for path in config_candidates())) - config_path = next((path for path in candidates if path.is_file()), None) - if config_path is None: - searched_paths = "\n".join(f"- {path}" for path in candidates) - raise FileNotFoundError( - "未找到 config.json。请创建本地配置或设置 CAMPUSNET_CONFIG。\n" - f"已检查:\n{searched_paths}" - ) +def load_config() -> dict[str, Any]: + config_path = resolve_config_path() with config_path.open("r", encoding="utf-8") as config_file: - return json.load(config_file) - - -def portal_base_url(login_url): - parsed_url = urlparse(login_url) - if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc: - raise ValueError("login_url 必须是完整的 HTTP 或 HTTPS 地址") - return f"{parsed_url.scheme}://{parsed_url.netloc}/" - - -def build_runtime_from_config(cfg: dict[str, Any]): - login_url = str(cfg.get("login_url", "")).strip() - if not login_url: - raise ValueError("config.json 缺少 login_url") - - parsed_login_url = urlparse(login_url) - portal_url = portal_base_url(login_url) - connectivity_check_url = str( - cfg.get("connectivity_check_url", DEFAULT_CONNECTIVITY_CHECK_URL) - ).strip() - portal_base_url(connectivity_check_url) - - default_cookies = { - "EPORTAL_COOKIE_DOMAIN": "false", - "EPORTAL_COOKIE_SAVEPASSWORD": "true", - "EPORTAL_COOKIE_OPERATORPWD": "", - "EPORTAL_COOKIE_NEWV": "true", - } - user_cookies = cfg.get("cookies", {}) - if not isinstance(user_cookies, dict): - raise TypeError("cookies 必须是 JSON 对象") - cookies = {**default_cookies, **user_cookies} - user_group = str(cfg.get("user_group", "")).strip() - if user_group and "EPORTAL_USER_GROUP" not in user_cookies: - cookies["EPORTAL_USER_GROUP"] = user_group - - configured_headers = cfg.get("headers", {}) - if not isinstance(configured_headers, dict): - raise TypeError("headers 必须是 JSON 对象") - user_agent = configured_headers.get( - "User-Agent", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + config = json.load(config_file) + if not isinstance(config, dict): + raise TypeError("config.json 顶层必须是 JSON 对象") + return config + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="连接锐捷校园网门户") + parser.add_argument( + "--probe-only", + action="store_true", + help="仅探测绑定接口的网络状态,不提交认证", ) - headers = { - "Host": parsed_login_url.hostname or "", - "User-Agent": str(user_agent), - } - jsessionid = cookies.get("JSESSIONID") - if jsessionid: - headers["JSESSIONID"] = str(jsessionid) - - service = str(cfg.get("service", cookies.get("EPORTAL_COOKIE_SERVER", ""))) - return login_url, portal_url, connectivity_check_url, headers, cookies, service - - -def is_network_available(status, response_text, redirect_location, portal_host): - if portal_host in response_text or portal_host in redirect_location: - return False - return 200 <= status < 400 - - -async def check_network_status(session, connectivity_check_url, portal_host): - try: - timeout = aiohttp.ClientTimeout(total=8) - async with session.get( - connectivity_check_url, - allow_redirects=False, - timeout=timeout, - ) as response: - status = response.status - response_text = await response.text(errors="ignore") - redirect_location = response.headers.get("Location", "") - - if not is_network_available(status, response_text, redirect_location, portal_host): - print("未连接校园网,需要登录") - return False - print("已连接校园网,无需登录") - return True - except (aiohttp.ClientError, TimeoutError) as error: - print(f"网络状态检查失败:{error}") - return False - - -async def get_query_string(session, portal_url): - timeout = aiohttp.ClientTimeout(total=8) - async with session.get(portal_url, timeout=timeout) as response: - html = await response.text(errors="ignore") - - start = html.find("index.jsp?") - if start == -1: - print("未在门户页面中找到 query_string 入口,请检查 login_url。") - return "" - start += len("index.jsp?") - end = html.find("'", start) - return html[start:] if end == -1 else html[start:end] - + parser.add_argument( + "--print-config-path", + action="store_true", + help="只显示将使用的配置路径,不读取配置或发起网络请求", + ) + return parser.parse_args() -async def do_login(session, login_url, portal_url, headers, cookies, service): - username = str(cookies.get("EPORTAL_COOKIE_USERNAME", "")) - password = str(cookies.get("EPORTAL_COOKIE_PASSWORD", "")) - if not username or not password: - print("登录失败:配置缺少校园网账号或加密密码。") - return - query_string = await get_query_string(session, portal_url) - if not query_string: - return +async def async_main(*, probe_only: bool = False) -> int: + cfg = load_config() + return await execute_config(cfg, probe_only=probe_only) - post_data = { - "userId": username, - "password": password, - "service": service, - "queryString": query_string, - "operatorPwd": "", - "operatorUserId": "", - "validcode": "", - "passwordEncrypt": "true", - } +def main() -> int: + args = parse_args() try: - timeout = aiohttp.ClientTimeout(total=10) - async with session.post( - login_url, - headers=headers, - data=post_data, - cookies=cookies, - allow_redirects=False, - timeout=timeout, - ) as response: - try: - data = await response.json(content_type=None) - except (aiohttp.ContentTypeError, json.JSONDecodeError): - print(f"登录失败:门户返回非 JSON 内容,状态码 {response.status}。") - return - except (aiohttp.ClientError, TimeoutError) as error: - print(f"登录请求失败:{error}") - return - - if data.get("result") == "success": - print("登录成功!") - else: - print("登录失败,原因:", data.get("message", "未知错误")) - - -async def main(): - cfg = load_config() - login_url, portal_url, check_url, headers, cookies, service = build_runtime_from_config(cfg) - portal_host = urlparse(portal_url).hostname or "" - - async with aiohttp.ClientSession(headers=headers, cookies=cookies) as session: - if await check_network_status(session, check_url, portal_host): - print("跳过登录") - return - await do_login(session, login_url, portal_url, headers, cookies, service) + if args.print_config_path: + print(resolve_config_path()) + return 0 + return asyncio.run(async_main(probe_only=args.probe_only)) + except BaseException as error: + error_info = classify_run_error(error) + if error_info is None: + raise + print(f"{error_info.title}:{error_info.message}", file=sys.stderr) + return error_info.exit_code if __name__ == "__main__": - if os.name == "nt": - asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) - asyncio.run(main()) + raise SystemExit(main()) diff --git a/workbench/network/campus-net/pyproject.toml b/workbench/network/campus-net/pyproject.toml index c15c9a8..be4e921 100644 --- a/workbench/network/campus-net/pyproject.toml +++ b/workbench/network/campus-net/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "cedarflake-campus-net" version = "1.0.0" -description = "Log in to a Ruijie campus network portal." +description = "Connect through legacy and captive SSO Ruijie campus portals." readme = "README.md" requires-python = ">=3.11" license = { file = "LICENSE" } @@ -10,6 +10,9 @@ authors = [ ] dependencies = [ "aiohttp>=3.12.0,<4", + "pillow>=11.0.0,<13", + "pycryptodome>=3.21.0,<4", + "ttkbootstrap>=1.10,<2", ] [dependency-groups] @@ -19,3 +22,6 @@ dev = [ [tool.uv] package = false + +[tool.uv.workspace] +members = [] diff --git a/workbench/network/campus-net/tests/test_aggregate.py b/workbench/network/campus-net/tests/test_aggregate.py new file mode 100644 index 0000000..9a66677 --- /dev/null +++ b/workbench/network/campus-net/tests/test_aggregate.py @@ -0,0 +1,569 @@ +import base64 +import hashlib +import json +import unittest +from collections import deque +from http.cookies import SimpleCookie +from unittest.mock import AsyncMock, patch +from urllib.parse import urlencode, urlparse + +import aiohttp +from campus_net.aggregate import ( + AggregatePortalClient, + AuthenticationRejected, + AuthenticationUncertain, + PortalContext, + PortalProtocolError, + _protected_sso_headers, + _read_limited_body, +) +from campus_net.captive import NetworkState, ProbeResult +from campus_net.config import CaptiveHttpConfig, ProbeConfig +from yarl import URL + +PORTAL_ORIGIN = "http://10.71.29.181" +PROBE_URL = "http://connectivity.example/connecttest.txt" +CRYPTO_KEY_1 = base64.b64encode(b"0123456789abcdef").decode("ascii") +CRYPTO_KEY_2 = base64.b64encode(b"fedcba9876543210").decode("ascii") + + +def config(*, verification_timeout: float = 0.05) -> CaptiveHttpConfig: + return CaptiveHttpConfig( + adapter="captive-sso-http", + interface_index=24, + user_agent="test-agent", + probe=ProbeConfig( + url=PROBE_URL, + online_status=200, + online_body="Microsoft Connect Test", + online_location=None, + timeout_seconds=1, + ), + portal_origin=PORTAL_ORIGIN, + portal_entry_path="/eportal/index.jsp", + auth_mode="interactive-system-captcha", + username="example-student", + password="example-password", + service_display_name="中国电信", + captcha_attempts=0, + verification_interval_seconds=0.01, + verification_timeout_seconds=verification_timeout, + ) + + +def captive_url() -> str: + return ( + PORTAL_ORIGIN + + "/eportal/index.jsp?" + + urlencode( + { + "wlanuserip": "user-ip-token", + "wlanacname": "ac-token", + "nasip": "nas-token", + "mac": "mac-token", + } + ) + ) + + +def captive_body() -> bytes: + return f"".encode() + + +def sso_page( + crypto_key: str, + execution: str, + *, + error_code: str = "", +) -> bytes: + return ( + f'

{crypto_key}

' + f'

{execution}

' + '

system

' + '

' + f'

{error_code}

' + '

' + ).encode() + + +class FakeContent: + def __init__(self, body: bytes) -> None: + self.body = body + self.offset = 0 + + async def read(self, size: int = -1) -> bytes: + if self.offset >= len(self.body): + return b"" + end = len(self.body) if size < 0 else self.offset + size + chunk = self.body[self.offset : end] + self.offset += len(chunk) + return chunk + + +class FakeResponse: + def __init__( + self, + *, + status: int = 200, + headers: dict[str, str] | None = None, + body: bytes = b"", + payload: object = None, + ) -> None: + self.status = status + self.headers = headers or {} + if payload is not None and not body: + body = json.dumps(payload).encode() + self.content = FakeContent(body) + self.payload = payload + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, traceback) -> None: + return None + + async def json(self, *, content_type=None): + return self.payload + + +class FakeSession: + def __init__( + self, + *, + gets: list[FakeResponse] | None = None, + posts: list[FakeResponse | Exception] | None = None, + requests: list[FakeResponse | Exception] | None = None, + ) -> None: + self.gets = deque(gets or []) + self.posts = deque(posts or []) + self.requests = deque(requests or []) + self.calls: list[tuple[str, str, dict[str, object]]] = [] + + def get(self, url: str, **kwargs: object): + self.calls.append(("GET", str(url), kwargs)) + return self.gets.popleft() + + def post(self, url: str, **kwargs: object): + self.calls.append(("POST", str(url), kwargs)) + response = self.posts.popleft() + if isinstance(response, Exception): + raise response + return response + + def request(self, method: str, url: str, **kwargs: object): + self.calls.append((method, str(url), kwargs)) + response = self.requests.popleft() + if isinstance(response, Exception): + raise response + return response + + +class TestLimitedBodyRead(unittest.IsolatedAsyncioTestCase): + async def test_reads_all_chunks_until_eof(self): + body = b"a" * (70 * 1024) + response = FakeResponse(body=body) + + result = await _read_limited_body(response, len(body), operation="测试") + + self.assertEqual(result, body) + + async def test_rejects_body_over_limit(self): + response = FakeResponse(body=b"too-large") + + with self.assertRaisesRegex(PortalProtocolError, "响应过大"): + await _read_limited_body(response, 4, operation="测试") + + +class TestProtectedSsoRequests(unittest.IsolatedAsyncioTestCase): + @patch("campus_net.aggregate.secrets.choice", return_value="A") + def test_reproduces_frontend_csrf_header_algorithm(self, choice): + headers = _protected_sso_headers() + csrf_key = "A" * 32 + encoded_key = base64.b64encode(csrf_key.encode("ascii")).decode("ascii") + midpoint = len(encoded_key) // 2 + csrf_source = encoded_key[:midpoint] + encoded_key + encoded_key[midpoint:] + expected_value = hashlib.md5( + csrf_source.encode("ascii"), + usedforsecurity=False, + ).hexdigest() + + self.assertEqual(headers["Csrf-Key"], csrf_key) + self.assertEqual(headers["Csrf-Value"], expected_value) + self.assertEqual(headers["sid-language"], "zh-CN") + self.assertEqual(choice.call_count, 32) + + async def test_rejects_business_unauthorized_captcha_policy(self): + session = FakeSession( + gets=[FakeResponse(payload={"code": 401, "data": {"errorMessage": "unauthorized"}})] + ) + + with self.assertRaisesRegex(PortalProtocolError, "业务状态不是 200:401"): + await AggregatePortalClient(session, config())._get_captcha_requirement( + "example-student" + ) + + _method, url, kwargs = session.calls[0] + self.assertIn("?", url) + self.assertIn("Csrf-Key", kwargs["headers"]) + self.assertIn("Csrf-Value", kwargs["headers"]) + + +class TestPortalInitialization(unittest.IsolatedAsyncioTestCase): + async def test_preserves_exact_captive_url_and_follows_portal_main(self): + portal_main = ( + "/portal/portal-main?sessionId=session-123&customPageId=custom-1" + "&nasIp=nas-token&userIp=user-token&userMac=mac-token" + ) + session = FakeSession( + gets=[ + FakeResponse(body=captive_body()), + FakeResponse(status=302, headers={"Location": portal_main}), + FakeResponse(body=b"portal-spa"), + ], + requests=[ + FakeResponse(payload={"code": 200, "data": {"ipAddr": "10.0.0.2"}}), + FakeResponse(payload={"code": 200, "data": {}}), + FakeResponse(payload={"code": 200, "data": {"currentNodePath": "authenticate"}}), + ], + ) + + result = await AggregatePortalClient(session, config()).initialize() + + self.assertEqual(result.probe.state, NetworkState.CAPTIVE) + self.assertIsNotNone(result.context) + self.assertEqual(result.context.session_id, "session-123") + self.assertEqual(session.calls[1][1], captive_url()) + self.assertEqual(session.calls[2][1], PORTAL_ORIGIN + portal_main) + + +class TestSsoAuthentication(unittest.IsolatedAsyncioTestCase): + async def test_reloads_form_when_captcha_code_is_on_keyless_error_page(self): + session = FakeSession( + gets=[ + FakeResponse(body=sso_page(CRYPTO_KEY_1, "execution-1")), + FakeResponse(payload={"code": 200, "data": {"captchaInvisible": False}}), + FakeResponse(payload={"code": 200, "data": {"captchaInvisible": False}}), + FakeResponse(body=sso_page(CRYPTO_KEY_2, "execution-2")), + FakeResponse( + payload={ + "code": 200, + "data": { + "captchaInvisible": True, + "captchaUrl": "/sam-sso/api/captcha/reloaded", + }, + } + ), + FakeResponse(headers={"Content-Type": "image/png"}, body=b"captcha"), + FakeResponse(body=b"success-callback"), + ], + posts=[ + FakeResponse(body=b'

1320007

'), + FakeResponse( + status=302, + headers={"Location": "/portal/assets/auth-success.html?ticket=ticket-1"}, + ), + ], + ) + + await AggregatePortalClient(session, config()).authenticate( + PortalContext( + session_id="session-123", + custom_page_id="custom-1", + nas_ip="nas-token", + user_ip="user-token", + user_mac="mac-token", + current_node_path="authenticate", + ), + username="example-student", + password="example-password", + captcha_provider=AsyncMock(return_value="abcd"), + ) + + post_forms = [kwargs["data"] for method, _url, kwargs in session.calls if method == "POST"] + self.assertEqual( + [value for key, value in post_forms[1] if key == "execution"], + ["execution-2"], + ) + + async def test_uses_default_then_username_policy_and_retries_only_captcha_error(self): + session = FakeSession( + gets=[ + FakeResponse(body=sso_page(CRYPTO_KEY_1, "execution-1")), + FakeResponse( + payload={ + "code": 200, + "data": {"captchaInvisible": False, "captchaUrl": ""}, + } + ), + FakeResponse( + payload={ + "code": 200, + "data": { + "captchaInvisible": True, + "captchaUrl": "/sam-sso/api/captcha/first", + }, + } + ), + FakeResponse(headers={"Content-Type": "image/png"}, body=b"first-image"), + FakeResponse( + payload={ + "code": 200, + "data": { + "captchaInvisible": True, + "captchaUrl": "/sam-sso/api/captcha/second", + }, + } + ), + FakeResponse(headers={"Content-Type": "image/png"}, body=b"second-image"), + FakeResponse(body=b"success-callback"), + ], + posts=[ + FakeResponse(body=sso_page(CRYPTO_KEY_2, "execution-2", error_code="1320007")), + FakeResponse( + status=302, + headers={"Location": "/portal/assets/auth-success.html?ticket=ticket-1"}, + ), + ], + ) + codes = deque(["wrong", "right"]) + + async def provide_captcha(image: bytes, content_type: str) -> str: + self.assertEqual(content_type, "image/png") + self.assertIn(image, {b"first-image", b"second-image"}) + return codes.popleft() + + await AggregatePortalClient(session, config()).authenticate( + PortalContext( + session_id="session-123", + custom_page_id="custom-1", + nas_ip="nas-token", + user_ip="user-token", + user_mac="mac-token", + current_node_path="authenticate", + ), + username="example-student", + password="example-password", + captcha_provider=provide_captcha, + ) + + policy_urls = [url for method, url, _kwargs in session.calls if "findCaptchaCount" in url] + policy_paths = [urlparse(url).path for url in policy_urls] + self.assertTrue(policy_paths[0].endswith("/DEFAULT_CAPTCHA_SWITCH")) + self.assertTrue(policy_paths[1].endswith("/example-student")) + self.assertTrue(policy_paths[2].endswith("/example-student")) + post_forms = [kwargs["data"] for method, _url, kwargs in session.calls if method == "POST"] + self.assertEqual( + [value for key, value in post_forms[0] if key == "execution"], + ["execution-1"], + ) + self.assertEqual( + [value for key, value in post_forms[1] if key == "execution"], + ["execution-2"], + ) + + async def test_does_not_replay_uncertain_login_post(self): + session = FakeSession( + gets=[ + FakeResponse(body=sso_page(CRYPTO_KEY_1, "execution-1")), + FakeResponse( + payload={ + "code": 200, + "data": {"captchaInvisible": False, "captchaUrl": ""}, + } + ), + FakeResponse( + payload={ + "code": 200, + "data": {"captchaInvisible": False, "captchaUrl": ""}, + } + ), + ], + posts=[aiohttp.ClientConnectionError("connection lost")], + ) + + with self.assertRaisesRegex(RuntimeError, "未自动重放"): + await AggregatePortalClient(session, config()).authenticate( + PortalContext( + session_id="session-123", + custom_page_id="custom-1", + nas_ip="nas-token", + user_ip="user-token", + user_mac="mac-token", + current_node_path="authenticate", + ), + username="example-student", + password="example-password", + captcha_provider=AsyncMock(return_value=""), + ) + + self.assertEqual(sum(method == "POST" for method, _url, _kwargs in session.calls), 1) + + async def test_treats_malformed_post_response_as_uncertain_without_replay(self): + session = FakeSession( + gets=[ + FakeResponse(body=sso_page(CRYPTO_KEY_1, "execution-1")), + FakeResponse( + payload={ + "code": 200, + "data": {"captchaInvisible": False, "captchaUrl": ""}, + } + ), + FakeResponse( + payload={ + "code": 200, + "data": {"captchaInvisible": False, "captchaUrl": ""}, + } + ), + ], + posts=[FakeResponse(body=b"not-an-sso-page")], + ) + + with self.assertRaises(AuthenticationUncertain): + await AggregatePortalClient(session, config()).authenticate( + PortalContext( + session_id="session-123", + custom_page_id="custom-1", + nas_ip="nas-token", + user_ip="user-token", + user_mac="mac-token", + current_node_path="authenticate", + ), + username="example-student", + password="example-password", + captcha_provider=AsyncMock(return_value=""), + ) + + self.assertEqual(sum(method == "POST" for method, _url, _kwargs in session.calls), 1) + + async def test_does_not_submit_without_image_after_explicit_captcha_error(self): + session = FakeSession( + gets=[ + FakeResponse(body=sso_page(CRYPTO_KEY_1, "execution-1")), + FakeResponse( + payload={ + "code": 200, + "data": {"captchaInvisible": False, "captchaUrl": ""}, + } + ), + FakeResponse( + payload={ + "code": 200, + "data": { + "captchaInvisible": True, + "captchaUrl": "/sam-sso/api/captcha/first", + }, + } + ), + FakeResponse(headers={"Content-Type": "image/png"}, body=b"first-image"), + FakeResponse( + payload={ + "code": 200, + "data": {"captchaInvisible": False, "captchaUrl": ""}, + } + ), + FakeResponse( + payload={ + "code": 200, + "data": {"captchaInvisible": False, "captchaUrl": ""}, + } + ), + ], + posts=[FakeResponse(body=sso_page(CRYPTO_KEY_2, "execution-2", error_code="1320007"))], + ) + + with self.assertRaises(PortalProtocolError): + await AggregatePortalClient(session, config()).authenticate( + PortalContext( + session_id="session-123", + custom_page_id="custom-1", + nas_ip="nas-token", + user_ip="user-token", + user_mac="mac-token", + current_node_path="authenticate", + ), + username="example-student", + password="example-password", + captcha_provider=AsyncMock(return_value="wrong"), + ) + + self.assertEqual(sum(method == "POST" for method, _url, _kwargs in session.calls), 1) + + +class TestOnlineVerification(unittest.IsolatedAsyncioTestCase): + async def test_rejects_explicit_user_online_false(self): + session = FakeSession( + requests=[ + FakeResponse(payload={"code": 200, "data": {"online": False}}), + ] + ) + + with self.assertRaises(AuthenticationRejected): + await AggregatePortalClient(session, config()).trigger_online_check("session-123") + + async def test_treats_unstructured_user_online_response_as_uncertain(self): + session = FakeSession(requests=[FakeResponse(payload=None)]) + + with self.assertRaises(AuthenticationUncertain): + await AggregatePortalClient(session, config()).trigger_online_check("session-123") + + self.assertEqual(len(session.calls), 1) + + async def test_requires_portal_and_independent_probe_to_be_online(self): + client = AggregatePortalClient(FakeSession(), config()) + client._is_portal_online = AsyncMock(return_value=True) + captive_probe = ProbeResult(NetworkState.CAPTIVE, captive_url=captive_url()) + + with patch( + "campus_net.aggregate.probe_connectivity", AsyncMock(return_value=captive_probe) + ): + self.assertFalse(await client.verify_online("session-123")) + + online_probe = ProbeResult(NetworkState.ONLINE) + with patch("campus_net.aggregate.probe_connectivity", AsyncMock(return_value=online_probe)): + self.assertTrue(await client.verify_online("session-123")) + + +class TestServiceSelection(unittest.IsolatedAsyncioTestCase): + async def test_does_not_replay_uncertain_service_login(self): + session = FakeSession( + requests=[ + FakeResponse( + payload={ + "code": 200, + "data": [{"key": "中国电信", "value": "dynamic-service"}], + } + ), + aiohttp.ClientConnectionError("connection lost"), + ] + ) + + with self.assertRaises(AuthenticationUncertain): + await AggregatePortalClient(session, config()).select_service( + "session-123", + "中国电信", + ) + + service_calls = [call for call in session.calls if call[1].endswith("/serviceLogin")] + self.assertEqual(len(service_calls), 1) + + +class TestIpCookieJar(unittest.IsolatedAsyncioTestCase): + async def test_unsafe_cookie_jar_keeps_ip_host_session(self): + cookie = SimpleCookie() + cookie.load("SESSION=session-value; Path=/sam-sso; HttpOnly") + safe_jar = aiohttp.CookieJar() + unsafe_jar = aiohttp.CookieJar(unsafe=True) + response_url = URL("http://10.71.29.181/sam-sso/login") + + safe_jar.update_cookies(cookie, response_url=response_url) + unsafe_jar.update_cookies(cookie, response_url=response_url) + + self.assertNotIn("SESSION", safe_jar.filter_cookies(response_url)) + self.assertEqual( + unsafe_jar.filter_cookies(response_url)["SESSION"].value, + "session-value", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/workbench/network/campus-net/tests/test_application.py b/workbench/network/campus-net/tests/test_application.py new file mode 100644 index 0000000..ebd796c --- /dev/null +++ b/workbench/network/campus-net/tests/test_application.py @@ -0,0 +1,185 @@ +import json +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import aiohttp +from campus_net.aggregate import ( + AuthenticationRejected, + AuthenticationUncertain, + PortalProtocolError, +) +from campus_net.application import classify_run_error, execute_config +from campus_net.config import ( + ADAPTER_CAPTIVE_SSO_HTTP, + CONFIG_VERSION_CAPTIVE_SSO, + CONFIG_VERSION_LEGACY_EPORTAL, +) +from campus_net.interactive import CaptchaPromptError +from campus_net.sso import SsoProtocolError + + +def captive_config() -> dict[str, object]: + return { + "version": CONFIG_VERSION_CAPTIVE_SSO, + "interface_index": 24, + "portal_url": "http://10.71.29.181", + "username": "example-student", + "password": "example-password", + "carrier": "中国电信", + } + + +def legacy_config() -> dict[str, object]: + return { + "version": CONFIG_VERSION_LEGACY_EPORTAL, + "login_url": "https://campus.example/eportal/InterFace.do?method=login", + "username": "example-student", + "encrypted_password": "encrypted-password", + "carrier": "carrier", + } + + +class TestExecuteConfig(unittest.IsolatedAsyncioTestCase): + @patch("campus_net.application.run_captive_http", new_callable=AsyncMock) + async def test_dispatches_version_2_to_captive_runner(self, run_captive_http): + run_captive_http.return_value = 4 + captcha_provider = AsyncMock(return_value="abcd") + status_messages: list[str] = [] + status_callback = status_messages.append + + exit_code = await execute_config( + captive_config(), + probe_only=True, + captcha_provider=captcha_provider, + status_callback=status_callback, + ) + + self.assertEqual(exit_code, 4) + run_captive_http.assert_awaited_once() + runtime_config = run_captive_http.await_args.args[0] + self.assertEqual(runtime_config.adapter, ADAPTER_CAPTIVE_SSO_HTTP) + self.assertEqual(runtime_config.interface_index, 24) + self.assertEqual(runtime_config.username, "example-student") + self.assertEqual(runtime_config.password, "example-password") + self.assertTrue(run_captive_http.await_args.kwargs["probe_only"]) + self.assertIs( + run_captive_http.await_args.kwargs["captcha_provider"], + captcha_provider, + ) + self.assertIs( + run_captive_http.await_args.kwargs["status_callback"], + status_callback, + ) + + @patch("campus_net.application.run_legacy", new_callable=AsyncMock) + async def test_dispatches_version_1_to_legacy_runner(self, run_legacy): + run_legacy.return_value = 0 + status_messages: list[str] = [] + status_callback = status_messages.append + config = legacy_config() + + exit_code = await execute_config( + config, + status_callback=status_callback, + ) + + self.assertEqual(exit_code, 0) + run_legacy.assert_awaited_once_with( + config, + status_callback=status_callback, + ) + + @patch("campus_net.application.run_legacy", new_callable=AsyncMock) + async def test_version_1_probe_reports_unsupported_without_running(self, run_legacy): + status_messages: list[str] = [] + + exit_code = await execute_config( + legacy_config(), + probe_only=True, + status_callback=status_messages.append, + ) + + self.assertEqual(exit_code, 2) + self.assertEqual(status_messages, ["legacy-eportal 暂不支持 --probe-only。"]) + run_legacy.assert_not_awaited() + + async def test_rejects_unknown_version_before_dispatch(self): + with self.assertRaisesRegex(ValueError, "不支持的 version"): + await execute_config({"version": 3}) + + +class TestClassifyRunError(unittest.TestCase): + def test_classifies_configuration_errors(self): + errors = ( + FileNotFoundError("config missing"), + json.JSONDecodeError("invalid json", "{", 1), + TypeError("wrong type"), + ValueError("wrong value"), + ) + + for error in errors: + with self.subTest(error_type=type(error).__name__): + info = classify_run_error(error) + + self.assertIsNotNone(info) + self.assertEqual(info.exit_code, 1) + self.assertEqual(info.title, "配置错误") + self.assertEqual(info.message, str(error)) + + def test_classifies_protocol_and_transport_errors(self): + errors = ( + AuthenticationRejected("rejected"), + AuthenticationUncertain("uncertain"), + CaptchaPromptError("captcha failed"), + PortalProtocolError("portal failed"), + SsoProtocolError("sso failed"), + ) + + for error in errors: + with self.subTest(error_type=type(error).__name__): + info = classify_run_error(error) + + self.assertIsNotNone(info) + self.assertEqual(info.exit_code, 3) + self.assertEqual(info.title, "连接失败") + self.assertEqual(info.message, str(error)) + + def test_sanitizes_transport_error_details(self): + errors_and_messages = ( + ( + aiohttp.ClientConnectorError( + SimpleNamespace(host="private.example", port=80, ssl=True), + OSError(10065, "raw socket failure"), + ), + "校园网请求失败,请检查配置的 IPv4 接口和校园网连接。", + ), + ( + TimeoutError("private timeout detail"), + "校园网请求超时,请检查配置的 IPv4 接口和校园网连接。", + ), + ) + + for error, expected_message in errors_and_messages: + with self.subTest(error_type=type(error).__name__): + info = classify_run_error(error) + + self.assertIsNotNone(info) + self.assertEqual(info.exit_code, 3) + self.assertEqual(info.title, "连接失败") + self.assertEqual(info.message, expected_message) + for private_detail in ( + "private.example", + "ssl", + "10065", + "raw socket failure", + "private timeout detail", + ): + self.assertNotIn(private_detail, info.message) + + def test_leaves_unknown_errors_for_the_caller(self): + self.assertIsNone(classify_run_error(RuntimeError("unexpected"))) + + +if __name__ == "__main__": + unittest.main() diff --git a/workbench/network/campus-net/tests/test_captive.py b/workbench/network/campus-net/tests/test_captive.py new file mode 100644 index 0000000..06c1310 --- /dev/null +++ b/workbench/network/campus-net/tests/test_captive.py @@ -0,0 +1,332 @@ +import socket +import struct +import unittest +from types import SimpleNamespace +from unittest.mock import Mock, patch +from urllib.parse import urlencode + +import aiohttp +from campus_net.captive import ( + IP_UNICAST_IF, + MAX_CAPTIVE_URL_LENGTH, + CaptiveParseError, + NetworkState, + ResponseSnapshot, + WindowsInterfaceSocketFactory, + classify_response, + extract_captive_url, + probe_connectivity, +) +from campus_net.config import CaptiveHttpConfig, ProbeConfig + +PORTAL_ORIGIN = "http://10.71.29.181" +ENTRY_PATH = "/eportal/index.jsp" + + +def captive_url( + *, + origin: str = PORTAL_ORIGIN, + path: str = ENTRY_PATH, + parameters: list[tuple[str, str]] | None = None, + suffix: str = "", +) -> str: + if parameters is None: + parameters = [ + ("wlanuserip", "10.0.0.23"), + ("wlanacname", "campus-ac"), + ("ssid", "CampusWiFi"), + ("nasip", "10.0.255.254"), + ("mac", "00-00-00-00-00-00"), + ("url", "http://connectivity.example/connecttest.txt"), + ] + return f"{origin}{path}?{urlencode(parameters)}{suffix}" + + +def script_body(url: str, *, quote: str = '"', target: str = "window.location.href") -> bytes: + return ( + f"" + ).encode() + + +def http_config( + *, + online_body: str | None = "Microsoft Connect Test", + online_location: str | None = None, +) -> CaptiveHttpConfig: + return CaptiveHttpConfig( + adapter="captive-sso-http", + interface_index=24, + user_agent="test-agent", + probe=ProbeConfig( + url="http://connectivity.example/connecttest.txt", + online_status=200, + online_body=online_body, + online_location=online_location, + timeout_seconds=8, + ), + portal_origin=PORTAL_ORIGIN, + portal_entry_path=ENTRY_PATH, + auth_mode="interactive-system-captcha", + username="example-student", + password="example-password", + service_display_name="中国电信", + captcha_attempts=0, + verification_interval_seconds=2, + verification_timeout_seconds=30, + ) + + +class TestCaptiveUrlExtraction(unittest.TestCase): + def test_extracts_real_captive_script_shape_and_preserves_url(self): + expected_url = captive_url() + body = script_body(expected_url, quote="'", target="top.self.location.href") + + actual_url = extract_captive_url(body, PORTAL_ORIGIN, ENTRY_PATH) + + self.assertEqual(actual_url, expected_url) + + def test_accepts_default_port_equivalence_without_rewriting_url(self): + expected_url = captive_url(origin="http://10.71.29.181:80") + + actual_url = extract_captive_url( + script_body(expected_url), + PORTAL_ORIGIN, + ENTRY_PATH, + ) + + self.assertEqual(actual_url, expected_url) + + def test_accepts_single_and_double_quotes(self): + expected_url = captive_url() + for quote in ("'", '"'): + with self.subTest(quote=quote): + self.assertEqual( + extract_captive_url( + script_body(expected_url, quote=quote), + PORTAL_ORIGIN, + ENTRY_PATH, + ), + expected_url, + ) + + def test_unescapes_html_entities(self): + expected_url = captive_url() + encoded_url = expected_url.replace("&", "&") + + actual_url = extract_captive_url( + script_body(encoded_url), + PORTAL_ORIGIN, + ENTRY_PATH, + ) + + self.assertEqual(actual_url, expected_url) + + def test_rejects_wrong_host_path_userinfo_and_fragment(self): + invalid_urls = { + "host": captive_url(origin="http://portal.example"), + "path": captive_url(path="/sam-sso/login"), + "userinfo": captive_url(origin="http://student:secret@10.71.29.181"), + "fragment": captive_url(suffix="#unexpected"), + "params": captive_url(path=ENTRY_PATH + ";unexpected"), + } + for case, invalid_url in invalid_urls.items(): + with self.subTest(case=case): + with self.assertRaises(CaptiveParseError): + extract_captive_url( + script_body(invalid_url), + PORTAL_ORIGIN, + ENTRY_PATH, + ) + + def test_rejects_each_missing_required_parameter(self): + required_parameters = ("wlanuserip", "wlanacname", "nasip", "mac") + base_parameters = [ + ("wlanuserip", "10.0.0.23"), + ("wlanacname", "campus-ac"), + ("nasip", "10.0.255.254"), + ("mac", "00-00-00-00-00-00"), + ] + for missing_key in required_parameters: + with self.subTest(missing_key=missing_key): + parameters = [item for item in base_parameters if item[0] != missing_key] + + with self.assertRaises(CaptiveParseError): + extract_captive_url( + script_body(captive_url(parameters=parameters)), + PORTAL_ORIGIN, + ENTRY_PATH, + ) + + def test_rejects_duplicate_required_parameter(self): + parameters = [ + ("wlanuserip", "10.0.0.23"), + ("wlanuserip", "10.0.0.24"), + ("wlanacname", "campus-ac"), + ("nasip", "10.0.255.254"), + ("mac", "00-00-00-00-00-00"), + ] + + with self.assertRaises(CaptiveParseError): + extract_captive_url( + script_body(captive_url(parameters=parameters)), + PORTAL_ORIGIN, + ENTRY_PATH, + ) + + def test_rejects_multiple_distinct_entries(self): + first_url = captive_url() + second_url = captive_url( + parameters=[ + ("wlanuserip", "10.0.0.24"), + ("wlanacname", "campus-ac"), + ("nasip", "10.0.255.254"), + ("mac", "00-00-00-00-00-00"), + ] + ) + body = ( + f"" + ).encode() + + with self.assertRaises(CaptiveParseError): + extract_captive_url(body, PORTAL_ORIGIN, ENTRY_PATH) + + def test_rejects_overlong_entry(self): + base_url = captive_url() + overlong_url = base_url + "&padding=" + "x" * (MAX_CAPTIVE_URL_LENGTH - len(base_url)) + self.assertGreater(len(overlong_url), MAX_CAPTIVE_URL_LENGTH) + + with self.assertRaises(CaptiveParseError): + extract_captive_url( + script_body(overlong_url), + PORTAL_ORIGIN, + ENTRY_PATH, + ) + + +class TestResponseClassification(unittest.TestCase): + def test_classifies_exact_online_fingerprint(self): + result = classify_response( + ResponseSnapshot( + status=200, + headers={}, + body=b"Microsoft Connect Test", + ), + http_config(), + ) + + self.assertEqual(result.state, NetworkState.ONLINE) + self.assertIsNone(result.captive_url) + + def test_captive_entry_takes_priority_over_online_fingerprint(self): + body = script_body(captive_url()) + result = classify_response( + ResponseSnapshot(status=200, headers={}, body=body), + http_config(online_body=body.decode()), + ) + + self.assertEqual(result.state, NetworkState.CAPTIVE) + self.assertEqual(result.captive_url, captive_url()) + + def test_invalid_captive_entry_takes_priority_over_online_fingerprint(self): + body = script_body(captive_url(origin="http://portal.example")) + result = classify_response( + ResponseSnapshot(status=200, headers={}, body=body), + http_config(online_body=body.decode()), + ) + + self.assertEqual(result.state, NetworkState.UNKNOWN) + self.assertTrue(result.reason) + + def test_classifies_unrecognized_response_as_unknown(self): + result = classify_response( + ResponseSnapshot(status=200, headers={}, body=b"unexpected"), + http_config(), + ) + + self.assertEqual(result.state, NetworkState.UNKNOWN) + self.assertTrue(result.reason) + + +class TestConnectivityProbe(unittest.IsolatedAsyncioTestCase): + async def test_reports_unreachable_bound_interface_without_raw_socket_error(self): + session = Mock() + session.get.side_effect = aiohttp.ClientConnectorError( + SimpleNamespace(host="connectivity.example", port=80, ssl=True), + OSError(10065, "raw socket failure"), + ) + + result = await probe_connectivity(session, http_config()) + + self.assertEqual(result.state, NetworkState.UNKNOWN) + self.assertIsNone(result.captive_url) + self.assertEqual(result.reason, "配置接口未连接,或该接口当前没有可用路由") + for private_detail in ("connectivity.example", "ssl", "10065", "raw socket failure"): + self.assertNotIn(private_detail, result.reason) + session.get.assert_called_once() + _args, kwargs = session.get.call_args + self.assertEqual(kwargs["allow_redirects"], False) + self.assertEqual(kwargs["timeout"].total, 8) + + async def test_reports_probe_timeout_without_raw_exception_text(self): + session = Mock() + session.get.side_effect = TimeoutError("secret transport detail") + + result = await probe_connectivity(session, http_config()) + + self.assertEqual(result.state, NetworkState.UNKNOWN) + self.assertEqual(result.reason, "配置接口访问连通性探测地址超时") + self.assertNotIn("secret transport detail", result.reason) + + +class TestWindowsInterfaceSocketFactory(unittest.TestCase): + def test_binds_socket_with_network_byte_order_interface_index(self): + client_socket = Mock() + address_info = ( + socket.AF_INET, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + "", + ("203.0.113.1", 80), + ) + + with ( + patch("campus_net.captive.sys.platform", "win32"), + patch("campus_net.captive.socket.socket", return_value=client_socket) as constructor, + ): + result = WindowsInterfaceSocketFactory(24)(address_info) + + constructor.assert_called_once_with( + socket.AF_INET, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + ) + client_socket.setsockopt.assert_called_once_with( + socket.IPPROTO_IP, + IP_UNICAST_IF, + struct.pack("!I", 24), + ) + self.assertIs(result, client_socket) + + def test_closes_socket_when_binding_fails(self): + client_socket = Mock() + client_socket.setsockopt.side_effect = OSError("binding failed") + address_info = ( + socket.AF_INET, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + "", + ("203.0.113.1", 80), + ) + + with ( + patch("campus_net.captive.sys.platform", "win32"), + patch("campus_net.captive.socket.socket", return_value=client_socket), + self.assertRaises(OSError), + ): + WindowsInterfaceSocketFactory(24)(address_info) + + client_socket.close.assert_called_once_with() + + +if __name__ == "__main__": + unittest.main() diff --git a/workbench/network/campus-net/tests/test_captive_config.py b/workbench/network/campus-net/tests/test_captive_config.py new file mode 100644 index 0000000..996b9a8 --- /dev/null +++ b/workbench/network/campus-net/tests/test_captive_config.py @@ -0,0 +1,201 @@ +import json +import unittest +from pathlib import Path + +from campus_net.config import ( + ADAPTER_CAPTIVE_SSO_HTTP, + CONFIG_VERSION_CAPTIVE_SSO, + DEFAULT_AUTH_MODE, + DEFAULT_CAPTIVE_PROBE_BODY, + DEFAULT_CAPTIVE_PROBE_URL, + DEFAULT_PORTAL_ENTRY_PATH, + DEFAULT_USER_AGENT, + build_captive_http_config, +) + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def valid_config() -> dict[str, object]: + return { + "version": CONFIG_VERSION_CAPTIVE_SSO, + "interface_index": 24, + "portal_url": "http://10.71.29.181", + "username": "example-student", + "password": "example-password", + "carrier": "中国电信", + } + + +def historical_nested_captive_config() -> dict[str, object]: + return { + "adapter": ADAPTER_CAPTIVE_SSO_HTTP, + "network": { + "interface_index": 24, + "probe": { + "url": "http://connectivity.example/connecttest.txt", + "online_status": 200, + "online_body": "Legacy Connect Test", + }, + }, + "portal": { + "origin": "http://10.71.29.181", + "entry_path": "/eportal/index.jsp", + }, + "auth": { + "mode": "interactive-system-captcha", + "username": "example-student", + "password": "example-password", + "service_display_name": "中国电信", + }, + } + + +class TestCaptiveHttpConfig(unittest.TestCase): + def test_builds_version_2_config_with_internal_defaults(self): + config = build_captive_http_config(valid_config()) + + self.assertEqual(config.adapter, ADAPTER_CAPTIVE_SSO_HTTP) + self.assertEqual(config.interface_index, 24) + self.assertEqual(config.user_agent, DEFAULT_USER_AGENT) + self.assertEqual(config.probe.url, DEFAULT_CAPTIVE_PROBE_URL) + self.assertEqual(config.probe.online_status, 200) + self.assertEqual(config.probe.online_body, DEFAULT_CAPTIVE_PROBE_BODY) + self.assertIsNone(config.probe.online_location) + self.assertEqual(config.probe.timeout_seconds, 8) + self.assertEqual(config.portal_origin, "http://10.71.29.181") + self.assertEqual(config.portal_entry_path, DEFAULT_PORTAL_ENTRY_PATH) + self.assertEqual(config.auth_mode, DEFAULT_AUTH_MODE) + self.assertEqual(config.username, "example-student") + self.assertEqual(config.password, "example-password") + self.assertEqual(config.service_display_name, "中国电信") + self.assertEqual(config.captcha_attempts, 0) + self.assertEqual(config.verification_interval_seconds, 2) + self.assertEqual(config.verification_timeout_seconds, 30) + + def test_captive_example_matches_parser(self): + raw_config = json.loads((PROJECT_ROOT / "config.example.json").read_text(encoding="utf-8")) + + config = build_captive_http_config(raw_config) + + self.assertEqual(config.interface_index, 24) + self.assertEqual(config.portal_origin, "http://10.71.29.181") + self.assertEqual(config.service_display_name, "中国电信") + + def test_keeps_historical_nested_captive_config_compatible(self): + config = build_captive_http_config(historical_nested_captive_config()) + + self.assertEqual( + config.probe.url, + "http://connectivity.example/connecttest.txt", + ) + self.assertEqual(config.probe.online_body, "Legacy Connect Test") + self.assertEqual(config.username, "example-student") + self.assertEqual(config.password, "example-password") + + def test_version_2_rejects_unknown_or_legacy_fields(self): + for field, value in { + "adapter": ADAPTER_CAPTIVE_SSO_HTTP, + "network": {}, + "portal": {}, + "auth": {}, + "timeout_seconds": 30, + "schema_version": 2, + "mode": "captive-sso", + }.items(): + with self.subTest(field=field): + raw_config = valid_config() + raw_config[field] = value + + with self.assertRaisesRegex(ValueError, field): + build_captive_http_config(raw_config) + + def test_flat_config_requires_version(self): + raw_config = valid_config() + del raw_config["version"] + + with self.assertRaisesRegex(ValueError, "必须提供 version"): + build_captive_http_config(raw_config) + + def test_rejects_invalid_or_unknown_version(self): + for version in (True, 0, 3, "2"): + with self.subTest(version=version): + raw_config = valid_config() + raw_config["version"] = version + + with self.assertRaises((TypeError, ValueError)): + build_captive_http_config(raw_config) + + def test_version_2_rejects_legacy_password_field(self): + raw_config = valid_config() + del raw_config["password"] + raw_config["encrypted_password"] = "legacy-password" + + with self.assertRaisesRegex(ValueError, "encrypted_password"): + build_captive_http_config(raw_config) + + def test_rejects_boolean_interface_index(self): + raw_config = valid_config() + raw_config["interface_index"] = True + + with self.assertRaises(TypeError): + build_captive_http_config(raw_config) + + def test_rejects_interface_index_outside_windows_range(self): + for interface_index in (0, 0x1_0000_0000): + with self.subTest(interface_index=interface_index): + raw_config = valid_config() + raw_config["interface_index"] = interface_index + + with self.assertRaises(ValueError): + build_captive_http_config(raw_config) + + def test_version_2_rejects_deep_portal_url(self): + raw_config = valid_config() + raw_config["portal_url"] = "http://10.71.29.181/sam-sso/login" + + with self.assertRaisesRegex(ValueError, "portal_url"): + build_captive_http_config(raw_config) + + def test_password_preserves_whitespace_without_exposing_repr(self): + raw_config = valid_config() + raw_config["password"] = " example-password " + + config = build_captive_http_config(raw_config) + + self.assertEqual(config.password, " example-password ") + self.assertNotIn("example-password", repr(config)) + + def test_v1_rejects_https_probe(self): + raw_config = historical_nested_captive_config() + raw_config["network"]["probe"]["url"] = "https://connectivity.example/connecttest.txt" + + with self.assertRaises(ValueError): + build_captive_http_config(raw_config) + + def test_v1_rejects_unknown_auth_mode(self): + raw_config = historical_nested_captive_config() + raw_config["auth"]["mode"] = "automatic" + + with self.assertRaises(ValueError): + build_captive_http_config(raw_config) + + def test_v1_zero_captcha_attempts_means_no_client_limit(self): + raw_config = historical_nested_captive_config() + raw_config["auth"]["captcha_attempts"] = 0 + + config = build_captive_http_config(raw_config) + + self.assertEqual(config.captcha_attempts, 0) + + def test_v1_rejects_removed_password_environment_field(self): + raw_config = historical_nested_captive_config() + del raw_config["auth"]["password"] + raw_config["auth"]["password_env"] = "CAMPUSNET_PASSWORD" + + with self.assertRaisesRegex(ValueError, "auth.password_env"): + build_captive_http_config(raw_config) + + +if __name__ == "__main__": + unittest.main() diff --git a/workbench/network/campus-net/tests/test_config.py b/workbench/network/campus-net/tests/test_config.py index 525ec38..8ec0171 100644 --- a/workbench/network/campus-net/tests/test_config.py +++ b/workbench/network/campus-net/tests/test_config.py @@ -1,6 +1,20 @@ +import json import unittest +from pathlib import Path -from main import build_runtime_from_config, is_network_available, portal_base_url +from campus_net.config import ( + ADAPTER_CAPTIVE_SSO_HTTP, + ADAPTER_LEGACY_EPORTAL, + CONFIG_VERSION_CAPTIVE_SSO, + CONFIG_VERSION_LEGACY_EPORTAL, + DEFAULT_CONNECTIVITY_CHECK_URL, + build_legacy_runtime_from_config, + get_adapter, + portal_base_url, +) +from campus_net.legacy import is_network_available + +PROJECT_ROOT = Path(__file__).resolve().parents[1] class TestConfig(unittest.TestCase): @@ -11,9 +25,9 @@ def test_portal_base_url_uses_configured_origin(self): ) def test_build_runtime_uses_configured_portal(self): - runtime = build_runtime_from_config( + runtime = build_legacy_runtime_from_config( { - "login_url": "https://campus.example/eportal/login", + "login_url": ("https://campus.example/eportal/InterFace.do?method=login"), "connectivity_check_url": "https://connectivity.example/status", "service": "carrier", "cookies": { @@ -25,7 +39,10 @@ def test_build_runtime_uses_configured_portal(self): ) login_url, portal_url, check_url, headers, cookies, service = runtime - self.assertEqual(login_url, "https://campus.example/eportal/login") + self.assertEqual( + login_url, + "https://campus.example/eportal/InterFace.do?method=login", + ) self.assertEqual(portal_url, "https://campus.example/") self.assertEqual(check_url, "https://connectivity.example/status") self.assertEqual(headers["Host"], "campus.example") @@ -33,12 +50,149 @@ def test_build_runtime_uses_configured_portal(self): self.assertEqual(cookies["EPORTAL_COOKIE_USERNAME"], "example-student") self.assertEqual(service, "carrier") + def test_builds_version_1_legacy_runtime(self): + runtime = build_legacy_runtime_from_config( + { + "version": CONFIG_VERSION_LEGACY_EPORTAL, + "login_url": ("https://campus.example/eportal/InterFace.do?method=login"), + "username": "example-student", + "encrypted_password": " encrypted-password ", + "carrier": "%E4%B8%AD%E5%9B%BD%E7%94%B5%E4%BF%A1", + "user_group": "student", + "session_id": "example-session", + } + ) + + login_url, portal_url, check_url, headers, cookies, service = runtime + self.assertEqual( + login_url, + "https://campus.example/eportal/InterFace.do?method=login", + ) + self.assertEqual(portal_url, "https://campus.example/") + self.assertEqual(check_url, DEFAULT_CONNECTIVITY_CHECK_URL) + self.assertEqual(headers["Host"], "campus.example") + self.assertEqual(headers["JSESSIONID"], "example-session") + self.assertEqual(cookies["EPORTAL_COOKIE_USERNAME"], "example-student") + self.assertEqual( + cookies["EPORTAL_COOKIE_PASSWORD"], + " encrypted-password ", + ) + self.assertEqual(cookies["EPORTAL_USER_GROUP"], "student") + self.assertEqual(cookies["JSESSIONID"], "example-session") + self.assertEqual(service, "%E4%B8%AD%E5%9B%BD%E7%94%B5%E4%BF%A1") + + def test_version_1_legacy_optional_fields_can_be_omitted(self): + runtime = build_legacy_runtime_from_config( + { + "version": CONFIG_VERSION_LEGACY_EPORTAL, + "login_url": ("https://campus.example/eportal/InterFace.do?method=login"), + "username": "example-student", + "encrypted_password": "encrypted-password", + "carrier": "carrier", + } + ) + + headers = runtime[3] + cookies = runtime[4] + self.assertNotIn("JSESSIONID", headers) + self.assertNotIn("JSESSIONID", cookies) + self.assertNotIn("EPORTAL_USER_GROUP", cookies) + + def test_version_maps_to_internal_adapter(self): + self.assertEqual( + get_adapter( + { + "version": CONFIG_VERSION_CAPTIVE_SSO, + } + ), + ADAPTER_CAPTIVE_SSO_HTTP, + ) + self.assertEqual( + get_adapter( + { + "version": CONFIG_VERSION_LEGACY_EPORTAL, + } + ), + ADAPTER_LEGACY_EPORTAL, + ) + + def test_config_without_adapter_remains_legacy(self): + self.assertEqual(get_adapter({}), ADAPTER_LEGACY_EPORTAL) + + def test_version_1_legacy_rejects_unknown_fields(self): + raw_config = { + "version": CONFIG_VERSION_LEGACY_EPORTAL, + "login_url": "https://campus.example/eportal/InterFace.do?method=login", + "username": "example-student", + "encrypted_password": "encrypted-password", + "carrier": "carrier", + "headers": {}, + } + + with self.assertRaisesRegex(ValueError, "headers"): + build_legacy_runtime_from_config(raw_config) + + def test_version_1_legacy_rejects_plain_password_field(self): + raw_config = { + "version": CONFIG_VERSION_LEGACY_EPORTAL, + "login_url": "https://campus.example/eportal/InterFace.do?method=login", + "username": "example-student", + "password": "plain-password", + "carrier": "carrier", + } + + with self.assertRaisesRegex(ValueError, "encrypted_password"): + build_legacy_runtime_from_config(raw_config) + + def test_version_1_legacy_rejects_control_characters_in_session_id(self): + for session_id in ("unsafe\r\nsession", "unsafe-session\r\n"): + with self.subTest(session_id=repr(session_id)): + raw_config = { + "version": CONFIG_VERSION_LEGACY_EPORTAL, + "login_url": ("https://campus.example/eportal/InterFace.do?method=login"), + "username": "example-student", + "encrypted_password": "encrypted-password", + "carrier": "carrier", + "session_id": session_id, + } + + with self.assertRaisesRegex(ValueError, "控制字符"): + build_legacy_runtime_from_config(raw_config) + + def test_version_1_legacy_requires_eportal_login_endpoint(self): + for login_url in ( + "https://campus.example/not-eportal", + "https://campus.example/eportal/InterFace.do?method=logout", + "https://campus.example/eportal/InterFace.do", + ): + with self.subTest(login_url=login_url): + raw_config = { + "version": CONFIG_VERSION_LEGACY_EPORTAL, + "login_url": login_url, + "username": "example-student", + "encrypted_password": "encrypted-password", + "carrier": "carrier", + } + + with self.assertRaisesRegex(ValueError, "InterFace.do"): + build_legacy_runtime_from_config(raw_config) + + def test_legacy_example_matches_parser(self): + raw_config = json.loads( + (PROJECT_ROOT / "config.legacy.example.json").read_text(encoding="utf-8") + ) + + runtime = build_legacy_runtime_from_config(raw_config) + + self.assertEqual(runtime[0], raw_config["login_url"]) + self.assertEqual(runtime[4]["EPORTAL_COOKIE_USERNAME"], raw_config["username"]) + def test_rejects_incomplete_login_url(self): with self.assertRaises(ValueError): portal_base_url("campus.example/eportal/login") def test_build_runtime_uses_configured_user_group(self): - runtime = build_runtime_from_config( + runtime = build_legacy_runtime_from_config( { "login_url": "https://campus.example/eportal/login", "user_group": "student", @@ -49,7 +203,7 @@ def test_build_runtime_uses_configured_user_group(self): self.assertEqual(runtime[4]["EPORTAL_USER_GROUP"], "student") def test_explicit_cookie_user_group_takes_precedence(self): - runtime = build_runtime_from_config( + runtime = build_legacy_runtime_from_config( { "login_url": "https://campus.example/eportal/login", "user_group": "student", diff --git a/workbench/network/campus-net/tests/test_config_paths.py b/workbench/network/campus-net/tests/test_config_paths.py new file mode 100644 index 0000000..f952c64 --- /dev/null +++ b/workbench/network/campus-net/tests/test_config_paths.py @@ -0,0 +1,273 @@ +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +from campus_net.config_paths import ( + config_candidates, + preferred_config_path, + resolve_config_path, +) + + +class TestConfigCandidates(unittest.TestCase): + def test_source_mode_uses_explicit_cwd_source_and_bundle_order(self): + with TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + explicit = root / "chosen.json" + cwd = root / "working" + source_root = root / "source" + bundle = root / "bundle" + + candidates = list( + config_candidates( + configured_path=explicit, + frozen=False, + executable=root / "ignored" / "CampusNet.exe", + cwd=cwd, + source_root=source_root, + bundled_directory=bundle, + ) + ) + + self.assertEqual( + candidates, + [ + explicit.absolute(), + (cwd / "config.json").absolute(), + (source_root / "config.json").absolute(), + (bundle / "config.json").absolute(), + ], + ) + + def test_frozen_dist_mode_includes_executable_and_project_directories(self): + with TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + explicit = root / "chosen.json" + executable = root / "project" / "dist" / "CampusNet.exe" + cwd = root / "working" + bundle = root / "bundle" + executable.parent.mkdir(parents=True) + (executable.parent.parent / "pyproject.toml").write_text( + "[project]\n", + encoding="utf-8", + ) + (executable.parent.parent / "config.example.json").write_text( + "{}\n", + encoding="utf-8", + ) + + candidates = list( + config_candidates( + configured_path=explicit, + frozen=True, + executable=executable, + cwd=cwd, + source_root=root / "ignored-source", + bundled_directory=bundle, + ) + ) + + self.assertEqual( + candidates, + [ + explicit.absolute(), + (cwd / "config.json").absolute(), + (executable.parent / "config.json").absolute(), + (executable.parent.parent / "config.json").absolute(), + (bundle / "config.json").absolute(), + ], + ) + + def test_frozen_non_dist_mode_does_not_guess_executable_parent_parent(self): + with TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + executable = root / "installed" / "CampusNet.exe" + + candidates = list( + config_candidates( + configured_path="", + frozen=True, + executable=executable, + cwd=root / "working", + source_root=root / "ignored-source", + bundled_directory=None, + ) + ) + + self.assertEqual( + candidates, + [ + (root / "working" / "config.json").absolute(), + (executable.parent / "config.json").absolute(), + ], + ) + self.assertNotIn((root / "config.json").absolute(), candidates) + + def test_deduplicates_candidates_without_changing_first_seen_order(self): + with TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + dist = root / "dist" + config_path = dist / "config.json" + dist.mkdir() + (root / "pyproject.toml").write_text("[project]\n", encoding="utf-8") + (root / "config.example.json").write_text("{}\n", encoding="utf-8") + + candidates = list( + config_candidates( + configured_path=config_path, + frozen=True, + executable=dist / "CampusNet.exe", + cwd=dist, + source_root=root, + bundled_directory=dist, + ) + ) + + self.assertEqual( + candidates, + [ + config_path.absolute(), + (root / "config.json").absolute(), + ], + ) + + def test_frozen_dist_mode_does_not_search_parent_without_both_project_markers(self): + for present_marker in (None, "pyproject.toml", "config.example.json"): + with self.subTest(present_marker=present_marker): + with TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + dist = root / "dist" + dist.mkdir() + if present_marker is not None: + (root / present_marker).write_text("marker\n", encoding="utf-8") + + candidates = list( + config_candidates( + configured_path="", + frozen=True, + executable=dist / "CampusNet.exe", + cwd=root / "working", + source_root=root / "ignored-source", + bundled_directory=None, + ) + ) + + self.assertEqual( + candidates, + [ + (root / "working" / "config.json").absolute(), + (dist / "config.json").absolute(), + ], + ) + self.assertNotIn((root / "config.json").absolute(), candidates) + + def test_does_not_dereference_symlink_candidate(self): + with TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + target = root / "target.json" + symlink = root / "linked-config.json" + target.write_text("{}\n", encoding="utf-8") + try: + symlink.symlink_to(target) + except OSError as error: + self.skipTest(f"当前系统不允许创建测试符号链接:{error}") + + candidates = list( + config_candidates( + configured_path=symlink, + frozen=False, + executable=root / "ignored" / "CampusNet.exe", + cwd=root / "working", + source_root=root / "source", + bundled_directory=None, + ) + ) + + self.assertEqual(candidates[0], symlink.absolute()) + self.assertNotEqual(candidates[0], target.resolve()) + + +class TestResolveConfigPath(unittest.TestCase): + def test_returns_first_existing_file_in_candidate_order(self): + with TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + cwd = root / "working" + source_root = root / "source" + cwd.mkdir() + source_root.mkdir() + expected = cwd / "config.json" + expected.write_text("{}\n", encoding="utf-8") + (source_root / "config.json").write_text("{}\n", encoding="utf-8") + + resolved = resolve_config_path( + configured_path="", + frozen=False, + executable=root / "ignored" / "CampusNet.exe", + cwd=cwd, + source_root=source_root, + bundled_directory=None, + ) + + self.assertEqual(resolved, expected.absolute()) + + def test_explicit_missing_path_does_not_fall_back(self): + with TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + explicit = root / "missing-explicit.json" + cwd = root / "working" + cwd.mkdir() + (cwd / "config.json").write_text("{}\n", encoding="utf-8") + + with self.assertRaisesRegex(FileNotFoundError, "指定的配置文件不存在"): + resolve_config_path( + configured_path=explicit, + frozen=False, + executable=root / "ignored" / "CampusNet.exe", + cwd=cwd, + source_root=root / "source", + bundled_directory=None, + ) + + def test_preferred_path_preserves_missing_explicit_path(self): + with TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + explicit = root / "missing-explicit.json" + cwd = root / "working" + cwd.mkdir() + (cwd / "config.json").write_text("{}\n", encoding="utf-8") + + preferred = preferred_config_path( + configured_path=explicit, + frozen=False, + executable=root / "ignored" / "CampusNet.exe", + cwd=cwd, + source_root=root / "source", + bundled_directory=None, + ) + + self.assertEqual(preferred, explicit.absolute()) + + def test_skips_directory_named_config_json(self): + with TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + cwd = root / "working" + source_root = root / "source" + (cwd / "config.json").mkdir(parents=True) + source_root.mkdir() + expected = source_root / "config.json" + expected.write_text("{}\n", encoding="utf-8") + + resolved = resolve_config_path( + configured_path="", + frozen=False, + executable=root / "ignored" / "CampusNet.exe", + cwd=cwd, + source_root=source_root, + bundled_directory=None, + ) + + self.assertEqual(resolved, expected.absolute()) + + +if __name__ == "__main__": + unittest.main() diff --git a/workbench/network/campus-net/tests/test_config_store.py b/workbench/network/campus-net/tests/test_config_store.py new file mode 100644 index 0000000..2c29ad5 --- /dev/null +++ b/workbench/network/campus-net/tests/test_config_store.py @@ -0,0 +1,429 @@ +import hashlib +import json +import os +import traceback +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest import mock + +from campus_net import config_store +from campus_net.config import ( + build_captive_http_config, + build_legacy_runtime_from_config, +) +from campus_net.config_store import ( + MAX_CONFIG_BYTES, + CaptiveConfigV2, + ConfigCommitUncertainError, + ConfigFormatError, + ConfigIntegrityError, + ConfigRevision, + ConfigRevisionError, + ConfigTooLargeError, + LegacyConfigV1, + load_editable_config, + save_editable_config, +) + + +class TestConfigStore(unittest.TestCase): + def setUp(self): + temporary_directory = TemporaryDirectory() + self.addCleanup(temporary_directory.cleanup) + self.root = Path(temporary_directory.name) + self.config_path = self.root / "配置 文件.json" + self.backup_directory = self.root / "CampusNet Backups" + + def test_round_trips_formal_version_2_without_trimming_password(self): + config = self._version_2(password=" secret 密码 ") + + receipt = self._create(config) + loaded = load_editable_config(self.config_path) + runtime_config = build_captive_http_config( + json.loads(self.config_path.read_text(encoding="utf-8")) + ) + + self.assertTrue(receipt.changed) + self.assertIsNone(receipt.backup) + self.assertEqual(loaded.config, config) + self.assertEqual(runtime_config.password, " secret 密码 ") + self.assertTrue(self.config_path.read_bytes().endswith(b"\n")) + + def test_round_trips_formal_version_1_and_optional_fields(self): + config = LegacyConfigV1( + login_url="https://campus.example/eportal/InterFace.do?method=login", + username="example-student", + encrypted_password=" encrypted secret ", + carrier="carrier", + user_group="student", + session_id="session-secret", + ) + + self._create(config) + loaded = load_editable_config(self.config_path) + raw_config = json.loads(self.config_path.read_text(encoding="utf-8")) + runtime = build_legacy_runtime_from_config(raw_config) + + self.assertEqual(loaded.config, config) + self.assertEqual(runtime[4]["EPORTAL_COOKIE_PASSWORD"], " encrypted secret ") + self.assertEqual(runtime[4]["EPORTAL_USER_GROUP"], "student") + self.assertEqual(runtime[4]["JSESSIONID"], "session-secret") + + def test_rejects_duplicate_json_keys(self): + self.config_path.write_text( + """{ + "version": 2, + "interface_index": 24, + "portal_url": "http://10.71.29.181", + "username": "student", + "password": "first-secret", + "password": "second-secret", + "carrier": "中国电信" +} +""", + encoding="utf-8", + ) + + with self.assertRaisesRegex(ConfigFormatError, "重复字段") as context: + load_editable_config(self.config_path) + + self.assertNotIn("first-secret", str(context.exception)) + self.assertNotIn("second-secret", str(context.exception)) + + def test_rejects_versionless_historical_config(self): + self.config_path.write_text( + json.dumps( + { + "adapter": "captive-sso-http", + "auth": {"password": "historical-secret"}, + } + ), + encoding="utf-8", + ) + + with self.assertRaisesRegex(ConfigFormatError, "version=1"): + load_editable_config(self.config_path) + + def test_rejects_config_over_size_limit(self): + self.config_path.write_bytes(b" " * (MAX_CONFIG_BYTES + 1)) + + with self.assertRaises(ConfigTooLargeError): + load_editable_config(self.config_path) + + def test_models_and_loaded_result_hide_secrets_from_repr(self): + legacy = LegacyConfigV1( + login_url="https://campus.example/eportal/InterFace.do?method=login", + username="private-student", + encrypted_password="encrypted-private-secret", + carrier="carrier", + session_id="private-session", + ) + captive = self._version_2( + username="private-student", + password="plain-private-secret", + ) + + self._create(captive) + loaded = load_editable_config(self.config_path) + representations = "\n".join((repr(legacy), repr(captive), repr(loaded))) + + for secret in ( + "private-student", + "encrypted-private-secret", + "private-session", + "plain-private-secret", + ): + self.assertNotIn(secret, representations) + + def test_syntax_error_traceback_does_not_include_password(self): + password = "traceback-private-secret" + self.config_path.write_text( + '{"version": 2, "password": "' + password + '", "carrier": "中国电信",}', + encoding="utf-8", + ) + + try: + load_editable_config(self.config_path) + except ConfigFormatError as error: + rendered_traceback = "".join( + traceback.format_exception(type(error), error, error.__traceback__) + ) + else: + self.fail("invalid JSON unexpectedly loaded") + + self.assertNotIn(password, rendered_traceback) + + def test_overwrite_creates_verified_backup_before_replacement(self): + first_config = self._version_2(password="first-secret") + first_receipt = self._create(first_config) + original_content = self.config_path.read_bytes() + second_config = self._version_2(password="second-secret") + + second_receipt = save_editable_config( + second_config, + path=self.config_path, + backup_dir=self.backup_directory, + expected_revision=first_receipt.revision, + ) + + self.assertTrue(second_receipt.changed) + self.assertIsNotNone(second_receipt.backup) + backup = second_receipt.backup + if backup is None: + self.fail("overwrite did not return a backup receipt") + self.assertEqual(backup.path.parent, self.backup_directory) + self.assertEqual(backup.path.read_bytes(), original_content) + self.assertEqual(backup.size, len(original_content)) + self.assertEqual( + backup.sha256, + hashlib.sha256(original_content).hexdigest(), + ) + self.assertEqual( + load_editable_config(self.config_path).config, + second_config, + ) + + def test_identical_serialized_content_is_no_op(self): + config = self._version_2() + first_receipt = self._create(config) + original_content = self.config_path.read_bytes() + + second_receipt = save_editable_config( + config, + path=self.config_path, + backup_dir=self.backup_directory, + expected_revision=first_receipt.revision, + ) + + self.assertFalse(second_receipt.changed) + self.assertIsNone(second_receipt.backup) + self.assertEqual(self.config_path.read_bytes(), original_content) + self.assertFalse(self.backup_directory.exists()) + + def test_revision_mismatch_never_overwrites_external_change(self): + first_receipt = self._create(self._version_2(password="first-secret")) + external_content = b"external editor content" + self.config_path.write_bytes(external_content) + + with self.assertRaises(ConfigRevisionError): + save_editable_config( + self._version_2(password="second-secret"), + path=self.config_path, + backup_dir=self.backup_directory, + expected_revision=first_receipt.revision, + ) + + self.assertEqual(self.config_path.read_bytes(), external_content) + self.assertFalse(self.backup_directory.exists()) + + def test_backup_hash_failure_leaves_original_untouched(self): + first_receipt = self._create(self._version_2(password="first-secret")) + original_content = self.config_path.read_bytes() + + with mock.patch.object(config_store, "_sha256_file", return_value="0" * 64): + with self.assertRaises(ConfigIntegrityError): + save_editable_config( + self._version_2(password="second-secret"), + path=self.config_path, + backup_dir=self.backup_directory, + expected_revision=first_receipt.revision, + ) + + self.assertEqual(self.config_path.read_bytes(), original_content) + self.assertEqual(list(self.backup_directory.iterdir()), []) + + def test_target_temp_hash_failure_before_replace_keeps_original(self): + first_receipt = self._create(self._version_2(password="first-secret")) + original_content = self.config_path.read_bytes() + original_hash = hashlib.sha256(original_content).hexdigest() + file_hashes = iter((original_hash, original_hash, "0" * 64)) + + with mock.patch.object( + config_store, + "_sha256_file", + side_effect=lambda _path: next(file_hashes), + ): + with self.assertRaises(ConfigIntegrityError): + save_editable_config( + self._version_2(password="second-secret"), + path=self.config_path, + backup_dir=self.backup_directory, + expected_revision=first_receipt.revision, + ) + + self.assertEqual(self.config_path.read_bytes(), original_content) + backups = list(self.backup_directory.glob("*.backup.json")) + self.assertEqual(len(backups), 1) + self.assertEqual(backups[0].read_bytes(), original_content) + + def test_target_directory_fsync_failure_reports_uncertain_commit(self): + first_receipt = self._create(self._version_2(password="first-secret")) + original_content = self.config_path.read_bytes() + second_config = self._version_2(password="second-secret") + real_fsync_directory = config_store._fsync_directory + fsync_calls = 0 + + def fail_after_target_replace(directory): + nonlocal fsync_calls + fsync_calls += 1 + if fsync_calls == 1: + return real_fsync_directory(directory) + raise OSError("simulated target directory fsync failure") + + with mock.patch.object( + config_store, + "_fsync_directory", + side_effect=fail_after_target_replace, + ): + with self.assertRaises(ConfigCommitUncertainError) as context: + save_editable_config( + second_config, + path=self.config_path, + backup_dir=self.backup_directory, + expected_revision=first_receipt.revision, + ) + + backup = context.exception.backup + self.assertIsNotNone(backup) + if backup is None: + self.fail("uncertain commit did not expose its verified backup") + self.assertEqual(backup.path.read_bytes(), original_content) + self.assertEqual(backup.sha256, hashlib.sha256(original_content).hexdigest()) + self.assertEqual(load_editable_config(self.config_path).config, second_config) + + def test_final_read_failure_reports_uncertain_commit(self): + first_receipt = self._create(self._version_2(password="first-secret")) + original_content = self.config_path.read_bytes() + second_config = self._version_2(password="second-secret") + real_snapshot = config_store._snapshot + snapshot_calls = 0 + + def fail_final_snapshot(*args, **kwargs): + nonlocal snapshot_calls + snapshot_calls += 1 + if snapshot_calls == 3: + raise OSError("simulated final config read failure") + return real_snapshot(*args, **kwargs) + + with mock.patch.object( + config_store, + "_snapshot", + side_effect=fail_final_snapshot, + ): + with self.assertRaises(ConfigCommitUncertainError) as context: + save_editable_config( + second_config, + path=self.config_path, + backup_dir=self.backup_directory, + expected_revision=first_receipt.revision, + ) + + backup = context.exception.backup + self.assertIsNotNone(backup) + if backup is None: + self.fail("uncertain commit did not expose its verified backup") + self.assertEqual(backup.path.read_bytes(), original_content) + self.assertEqual(backup.sha256, hashlib.sha256(original_content).hexdigest()) + self.assertEqual(load_editable_config(self.config_path).config, second_config) + + def test_target_replace_failure_keeps_original_and_verified_backup(self): + first_receipt = self._create(self._version_2(password="first-secret")) + original_content = self.config_path.read_bytes() + real_replace = os.replace + replace_calls = 0 + + def fail_second_replace(source, destination): + nonlocal replace_calls + replace_calls += 1 + if replace_calls == 1: + return real_replace(source, destination) + raise OSError("simulated target replace failure") + + with mock.patch.object(config_store.os, "replace", side_effect=fail_second_replace): + with self.assertRaisesRegex(OSError, "simulated"): + save_editable_config( + self._version_2(password="second-secret"), + path=self.config_path, + backup_dir=self.backup_directory, + expected_revision=first_receipt.revision, + ) + + self.assertEqual(self.config_path.read_bytes(), original_content) + backups = list(self.backup_directory.glob("*.backup.json")) + self.assertEqual(len(backups), 1) + self.assertEqual(backups[0].read_bytes(), original_content) + self.assertEqual(list(self.root.glob(".*.tmp")), []) + self.assertEqual(list(self.backup_directory.glob("*.tmp")), []) + + def test_change_after_backup_is_detected_before_target_replace(self): + first_receipt = self._create(self._version_2(password="first-secret")) + original_content = self.config_path.read_bytes() + external_content = b"external change after backup" + real_backup = config_store._create_verified_backup + + def backup_then_change(**kwargs): + receipt = real_backup(**kwargs) + self.config_path.write_bytes(external_content) + return receipt + + with mock.patch.object( + config_store, + "_create_verified_backup", + side_effect=backup_then_change, + ): + with self.assertRaises(ConfigRevisionError): + save_editable_config( + self._version_2(password="second-secret"), + path=self.config_path, + backup_dir=self.backup_directory, + expected_revision=first_receipt.revision, + ) + + self.assertEqual(self.config_path.read_bytes(), external_content) + backups = list(self.backup_directory.glob("*.backup.json")) + self.assertEqual(len(backups), 1) + self.assertEqual(backups[0].read_bytes(), original_content) + + def test_new_file_revision_does_not_overwrite_file_that_appeared(self): + self.config_path.write_bytes(b"appeared externally") + + with self.assertRaises(ConfigRevisionError): + save_editable_config( + self._version_2(), + path=self.config_path, + backup_dir=self.backup_directory, + expected_revision=ConfigRevision.absent(), + ) + + self.assertEqual(self.config_path.read_bytes(), b"appeared externally") + + def test_rejects_non_sha256_revision(self): + with self.assertRaises(ValueError): + ConfigRevision(exists=True, sha256="not-a-sha256") + + def _create(self, config): + return save_editable_config( + config, + path=self.config_path, + backup_dir=self.backup_directory, + expected_revision=ConfigRevision.absent(), + ) + + @staticmethod + def _version_2( + *, + username="example-student", + password="example-password", + ): + return CaptiveConfigV2( + interface_index=24, + portal_url="http://10.71.29.181", + username=username, + password=password, + carrier="中国电信", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/workbench/network/campus-net/tests/test_gui_core.py b/workbench/network/campus-net/tests/test_gui_core.py new file mode 100644 index 0000000..546dd87 --- /dev/null +++ b/workbench/network/campus-net/tests/test_gui_core.py @@ -0,0 +1,552 @@ +import asyncio +import json +import threading +import time +import unittest +from collections.abc import Callable +from pathlib import Path +from queue import Queue +from tempfile import TemporaryDirectory +from typing import Any + +from campus_net.config import ( + CONFIG_VERSION_CAPTIVE_SSO, + CONFIG_VERSION_LEGACY_EPORTAL, +) +from campus_net.config_store import ( + CaptiveConfigV2, + ConfigRevision, + LegacyConfigV1, + config_to_mapping, + load_editable_config, + save_editable_config, +) +from campus_net.gui_core import ( + CaptchaBroker, + CaptchaRequested, + FailedEvent, + FinishedEvent, + GuiEvent, + LogEvent, + NetworkInterface, + OperationController, + _PendingCaptcha, + build_config_from_form, + form_values_from_config, + parse_windows_ipv4_interfaces, +) + + +def captive_values() -> dict[str, str]: + return { + "interface_index": "24", + "portal_url": "http://10.71.29.181", + "username": "example-student", + "password": "example-password", + "carrier": "中国电信", + } + + +def legacy_values() -> dict[str, str]: + return { + "login_url": "https://campus.example/eportal/InterFace.do?method=login", + "username": "example-student", + "encrypted_password": "encrypted-password", + "carrier": "carrier", + "user_group": "student", + "session_id": "example-session", + } + + +def captive_model() -> CaptiveConfigV2: + return CaptiveConfigV2( + interface_index=24, + portal_url="http://10.71.29.181", + username="example-student", + password="example-password", + carrier="中国电信", + ) + + +class ControllerTestCase(unittest.TestCase): + def wait_for_event( + self, + controller: OperationController, + predicate: Callable[[GuiEvent], bool], + *, + timeout: float = 3, + ) -> tuple[GuiEvent, list[GuiEvent]]: + deadline = time.monotonic() + timeout + observed: list[GuiEvent] = [] + while time.monotonic() < deadline: + observed.extend(controller.drain_events()) + for event in observed: + if predicate(event): + return event, observed + time.sleep(0.01) + self.fail(f"等待 GUI 事件超时,已收到:{observed!r}") + + def wait_until_stopped( + self, + controller: OperationController, + *, + timeout: float = 3, + ) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if not controller.is_running: + return + time.sleep(0.01) + self.fail("GUI 后台任务未按时停止") + + +class TestCaptchaBroker(unittest.TestCase): + def test_late_answer_and_cancel_tolerate_closed_event_loop(self): + loop = asyncio.new_event_loop() + answer_future = loop.create_future() + cancel_future = loop.create_future() + loop.close() + broker = CaptchaBroker(1, Queue()) + broker._pending[1] = _PendingCaptcha(loop=loop, future=answer_future) + + self.assertFalse(broker.answer(1, "late-code")) + self.assertFalse(broker.answer(1, "duplicate")) + + broker._pending[2] = _PendingCaptcha(loop=loop, future=cancel_future) + self.assertFalse(broker.cancel_pending()) + self.assertFalse(broker.cancel_pending()) + + +class TestOperationController(ControllerTestCase): + def test_saved_config_round_trip_can_start_runner(self): + received: dict[str, Any] = {} + + async def runner(config: dict[str, Any], **kwargs: Any) -> int: + received.update(config) + return 0 + + with TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + config_path = root / "config.json" + backup_directory = root / "backups" + initial = captive_model() + first_save = save_editable_config( + initial, + path=config_path, + backup_dir=backup_directory, + expected_revision=ConfigRevision.absent(), + ) + loaded = load_editable_config(config_path) + version, values = form_values_from_config(loaded.config) + values["carrier"] = "updated-carrier" + edited = build_config_from_form(version, values) + second_save = save_editable_config( + edited, + path=config_path, + backup_dir=backup_directory, + expected_revision=loaded.revision, + ) + reloaded = load_editable_config(config_path) + controller = OperationController(runner) + + controller.start(reloaded.config, probe_only=False) + terminal, _events = self.wait_for_event( + controller, + lambda event: isinstance(event, FinishedEvent), + ) + self.wait_until_stopped(controller) + + self.assertTrue(first_save.changed) + self.assertIsNone(first_save.backup) + self.assertTrue(second_save.changed) + self.assertIsNotNone(second_save.backup) + self.assertEqual(received, config_to_mapping(edited)) + self.assertEqual(received["carrier"], "updated-carrier") + self.assertEqual(terminal.exit_code, 0) + + def test_forwards_runner_inputs_and_emits_log_and_finished_events(self): + received: dict[str, Any] = {} + + async def runner(config: dict[str, Any], **kwargs: Any) -> int: + received["config"] = config + received["probe_only"] = kwargs["probe_only"] + received["captcha_provider"] = kwargs["captcha_provider"] + kwargs["status_callback"]("正在测试") + return 4 + + source_config = captive_model() + controller = OperationController(runner) + + operation_id = controller.start(source_config, probe_only=True) + terminal, events = self.wait_for_event( + controller, + lambda event: isinstance(event, FinishedEvent), + ) + self.wait_until_stopped(controller) + + self.assertEqual(operation_id, 1) + self.assertEqual(received["config"], config_to_mapping(source_config)) + self.assertTrue(received["probe_only"]) + self.assertTrue(callable(received["captcha_provider"])) + self.assertIn(LogEvent(operation_id, "正在测试"), events) + self.assertIsInstance(terminal, FinishedEvent) + self.assertEqual(terminal.exit_code, 4) + self.assertEqual( + terminal.message, + "门户状态与独立连通性探测未能同时确认在线。", + ) + self.assertFalse(terminal.cancelled) + + def test_reports_interface_selection_guidance_for_unknown_network_state(self): + async def runner(_config: dict[str, Any], **_kwargs: Any) -> int: + return 2 + + controller = OperationController(runner) + controller.start(captive_model(), probe_only=False) + terminal, _events = self.wait_for_event( + controller, + lambda event: isinstance(event, FinishedEvent), + ) + self.wait_until_stopped(controller) + + self.assertEqual(terminal.exit_code, 2) + self.assertEqual( + terminal.message, + "配置的 IPv4 接口不可用或状态无法识别,请检查实际承载校园网的 IPv4 接口。", + ) + + def test_rejects_concurrent_start_and_cancels_active_task(self): + started = threading.Event() + + async def runner(config: dict[str, Any], **kwargs: Any) -> int: + started.set() + await asyncio.Event().wait() + return 0 + + controller = OperationController(runner) + operation_id = controller.start(captive_model(), probe_only=False) + self.assertTrue(started.wait(1)) + + try: + with self.assertRaisesRegex(RuntimeError, "已有连接任务"): + controller.start(captive_model(), probe_only=False) + self.assertTrue(controller.cancel()) + terminal, _events = self.wait_for_event( + controller, + lambda event: isinstance(event, FinishedEvent), + ) + self.wait_until_stopped(controller) + finally: + controller.cancel() + + self.assertEqual(terminal.operation_id, operation_id) + self.assertTrue(terminal.cancelled) + self.assertEqual(terminal.exit_code, 3) + self.assertIn("服务端可能已经处理", terminal.message) + self.assertFalse(controller.cancel()) + + def test_bridges_captcha_request_and_answer_by_operation_and_request_id(self): + received: dict[str, str] = {} + + async def runner(config: dict[str, Any], **kwargs: Any) -> int: + received["code"] = await kwargs["captcha_provider"]( + b"captcha-image", + "image/png", + ) + return 0 + + controller = OperationController(runner) + operation_id = controller.start(captive_model(), probe_only=False) + requested, _events = self.wait_for_event( + controller, + lambda event: isinstance(event, CaptchaRequested), + ) + self.assertIsInstance(requested, CaptchaRequested) + + self.assertEqual(requested.operation_id, operation_id) + self.assertEqual(requested.image_bytes, b"captcha-image") + self.assertEqual(requested.content_type, "image/png") + self.assertFalse( + controller.answer_captcha( + operation_id + 1, + requested.request_id, + "stale", + ) + ) + self.assertTrue( + controller.answer_captcha( + operation_id, + requested.request_id, + "a1b2", + ) + ) + self.assertFalse( + controller.answer_captcha( + operation_id, + requested.request_id, + "duplicate", + ) + ) + terminal, _events = self.wait_for_event( + controller, + lambda event: isinstance(event, FinishedEvent), + ) + self.wait_until_stopped(controller) + + self.assertEqual(received["code"], "a1b2") + self.assertEqual(terminal.exit_code, 0) + self.assertEqual(terminal.message, "操作完成。") + + def test_cancel_while_waiting_for_captcha_emits_one_cancelled_terminal_event(self): + async def runner(config: dict[str, Any], **kwargs: Any) -> int: + await kwargs["captcha_provider"](b"captcha-image", "image/png") + return 0 + + controller = OperationController(runner) + operation_id = controller.start(captive_model(), probe_only=False) + requested, _events = self.wait_for_event( + controller, + lambda event: isinstance(event, CaptchaRequested), + ) + + self.assertTrue(controller.cancel()) + terminal, events = self.wait_for_event( + controller, + lambda event: isinstance(event, FinishedEvent), + ) + self.wait_until_stopped(controller) + events.extend(controller.drain_events()) + + self.assertTrue(terminal.cancelled) + self.assertFalse( + controller.answer_captcha( + operation_id, + requested.request_id, + "too-late", + ) + ) + terminal_events = [event for event in events if isinstance(event, FinishedEvent)] + self.assertEqual(len(terminal_events), 1) + + def test_classifies_known_runner_error(self): + async def runner(config: dict[str, Any], **kwargs: Any) -> int: + raise TypeError("interface_index 必须是整数") + + controller = OperationController(runner) + operation_id = controller.start(captive_model(), probe_only=False) + terminal, _events = self.wait_for_event( + controller, + lambda event: isinstance(event, FailedEvent), + ) + self.wait_until_stopped(controller) + + self.assertEqual(terminal.operation_id, operation_id) + self.assertEqual(terminal.exit_code, 1) + self.assertEqual(terminal.title, "配置错误") + self.assertEqual(terminal.message, "interface_index 必须是整数") + + def test_redacts_unknown_runner_error_message(self): + async def runner(config: dict[str, Any], **kwargs: Any) -> int: + raise RuntimeError("secret diagnostic") + + controller = OperationController(runner) + controller.start(captive_model(), probe_only=False) + terminal, _events = self.wait_for_event( + controller, + lambda event: isinstance(event, FailedEvent), + ) + self.wait_until_stopped(controller) + + self.assertEqual(terminal.exit_code, 3) + self.assertEqual(terminal.title, "内部错误") + self.assertEqual(terminal.message, "发生未处理的 RuntimeError") + self.assertNotIn("secret diagnostic", terminal.message) + + def test_terminal_event_allows_immediate_restart_with_distinct_id(self): + async def runner(config: dict[str, Any], **kwargs: Any) -> int: + return 0 + + controller = OperationController(runner) + + first_id = controller.start(captive_model(), probe_only=False) + self.wait_for_event(controller, lambda event: isinstance(event, FinishedEvent)) + self.assertFalse(controller.is_running) + second_id = controller.start(captive_model(), probe_only=False) + self.wait_for_event(controller, lambda event: isinstance(event, FinishedEvent)) + self.wait_until_stopped(controller) + + self.assertEqual((first_id, second_id), (1, 2)) + + +class TestGuiFormConversion(unittest.TestCase): + def test_builds_and_round_trips_version_2_form(self): + config = build_config_from_form(CONFIG_VERSION_CAPTIVE_SSO, captive_values()) + + self.assertIsInstance(config, CaptiveConfigV2) + self.assertEqual( + config_to_mapping(config), + { + "version": 2, + "interface_index": 24, + "portal_url": "http://10.71.29.181", + "username": "example-student", + "password": "example-password", + "carrier": "中国电信", + }, + ) + version, values = form_values_from_config(config) + self.assertEqual(version, CONFIG_VERSION_CAPTIVE_SSO) + self.assertEqual(values, captive_values()) + + def test_builds_version_1_and_omits_blank_optional_fields(self): + values = legacy_values() + values["encrypted_password"] = " encrypted-password " + values["user_group"] = " " + values["session_id"] = "" + + config = build_config_from_form(CONFIG_VERSION_LEGACY_EPORTAL, values) + + self.assertIsInstance(config, LegacyConfigV1) + self.assertEqual(config.version, CONFIG_VERSION_LEGACY_EPORTAL) + self.assertEqual(config.encrypted_password, " encrypted-password ") + self.assertIsNone(config.user_group) + self.assertIsNone(config.session_id) + + def test_round_trips_version_1_optional_fields(self): + config = build_config_from_form(CONFIG_VERSION_LEGACY_EPORTAL, legacy_values()) + + version, values = form_values_from_config(config) + + self.assertEqual(version, CONFIG_VERSION_LEGACY_EPORTAL) + self.assertEqual(values, legacy_values()) + + def test_rejects_invalid_form_version_or_interface_index(self): + with self.assertRaisesRegex(ValueError, "version=1 或 version=2"): + build_config_from_form(3, {}) + with self.assertRaisesRegex(TypeError, "interface_index 必须是整数"): + build_config_from_form( + CONFIG_VERSION_CAPTIVE_SSO, + {**captive_values(), "interface_index": "not-a-number"}, + ) + + def test_rejects_historical_config_without_formal_version(self): + with self.assertRaisesRegex(TypeError, "正式"): + form_values_from_config( + { + "login_url": "https://campus.example/eportal/InterFace.do?method=login", + } + ) + + +class TestWindowsInterfaceParsing(unittest.TestCase): + def test_parses_single_interface_object(self): + payload = json.dumps( + { + "InterfaceIndex": 24, + "InterfaceAlias": " 以太网 ", + "ConnectionState": "Connected", + "InterfaceMetric": 5, + } + ) + + interfaces = parse_windows_ipv4_interfaces(payload) + + self.assertEqual(interfaces, [NetworkInterface(24, "以太网", "Connected", 5)]) + self.assertEqual(interfaces[0].display_name, "24 · 以太网 · 已连接") + + def test_sorts_connected_interfaces_by_metric_then_index(self): + payload = json.dumps( + [ + { + "InterfaceIndex": 9, + "InterfaceAlias": "Disconnected", + "ConnectionState": "Disconnected", + "InterfaceMetric": 1, + }, + { + "InterfaceIndex": 24, + "InterfaceAlias": "Wi-Fi", + "ConnectionState": "Connected", + "InterfaceMetric": 50, + }, + { + "InterfaceIndex": 12, + "InterfaceAlias": "Ethernet", + "ConnectionState": "connected", + "InterfaceMetric": 5, + }, + ] + ) + + interfaces = parse_windows_ipv4_interfaces(payload) + + self.assertEqual([item.index for item in interfaces], [12, 24, 9]) + self.assertEqual(interfaces[0].state, "connected") + self.assertEqual(interfaces[0].display_name, "12 · Ethernet · 已连接") + self.assertEqual(interfaces[-1].display_name, "9 · Disconnected · 未连接") + + def test_maps_numeric_connection_state_values_from_powershell(self): + payload = json.dumps( + [ + { + "InterfaceIndex": 8, + "InterfaceAlias": "Offline", + "ConnectionState": 0, + "InterfaceMetric": 1, + }, + { + "InterfaceIndex": 9, + "InterfaceAlias": "Online", + "ConnectionState": 1, + "InterfaceMetric": 50, + }, + ] + ) + + interfaces = parse_windows_ipv4_interfaces(payload) + + self.assertEqual( + interfaces, + [ + NetworkInterface(9, "Online", "Connected", 50), + NetworkInterface(8, "Offline", "Disconnected", 1), + ], + ) + self.assertEqual(interfaces[0].display_name, "9 · Online · 已连接") + self.assertEqual(interfaces[1].display_name, "8 · Offline · 未连接") + + def test_skips_malformed_items_and_normalizes_invalid_metric_and_state(self): + payload = json.dumps( + [ + None, + {"InterfaceIndex": True, "InterfaceAlias": "invalid"}, + {"InterfaceIndex": 10, "InterfaceAlias": " "}, + { + "InterfaceIndex": 7, + "InterfaceAlias": "Loopback", + "ConnectionState": True, + "InterfaceMetric": True, + }, + ] + ) + + interfaces = parse_windows_ipv4_interfaces(payload) + + self.assertEqual(interfaces, [NetworkInterface(7, "Loopback", "", 0)]) + self.assertEqual(interfaces[0].display_name, "7 · Loopback · 未知状态") + + def test_rejects_invalid_json_or_payload_shape(self): + for payload in ("not-json", "null", '"interface"', "123"): + with self.subTest(payload=payload): + with self.assertRaisesRegex(ValueError, "接口列表"): + parse_windows_ipv4_interfaces(payload) + + def test_rejects_collection_without_valid_interfaces(self): + with self.assertRaisesRegex(ValueError, "没有找到"): + parse_windows_ipv4_interfaces( + json.dumps([{"InterfaceIndex": "24", "InterfaceAlias": "Wi-Fi"}]) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/workbench/network/campus-net/tests/test_gui_entry.py b/workbench/network/campus-net/tests/test_gui_entry.py new file mode 100644 index 0000000..f406339 --- /dev/null +++ b/workbench/network/campus-net/tests/test_gui_entry.py @@ -0,0 +1,167 @@ +import unittest +from contextlib import ExitStack +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +import gui_main +import ttkbootstrap as ttk +from campus_net.gui import ( + CARRIERS, + COMBOBOX_WHEEL_GUARD_TAG, + CampusNetApp, + app_icon_path, + launch_gui, +) +from ttkbootstrap import Style +from ttkbootstrap.widgets.scrolled import ScrolledFrame + + +class TestGuiEntry(unittest.TestCase): + def tearDown(self): + Style.instance = None + + def test_main_forwards_explicit_config_and_smoke_test(self): + with TemporaryDirectory() as temporary_directory: + config_path = Path(temporary_directory) / "never-created.json" + + with patch("gui_main.launch_gui") as mocked_launch: + exit_code = gui_main.main( + [ + "--smoke-test", + "--config", + str(config_path), + ] + ) + + self.assertEqual(exit_code, 0) + mocked_launch.assert_called_once_with( + config_path=config_path, + smoke_test=True, + ) + self.assertFalse(config_path.exists()) + + def test_smoke_test_with_explicit_config_has_no_external_side_effects(self): + forbidden_targets = ( + "campus_net.gui.load_editable_config", + "campus_net.gui.save_editable_config", + "campus_net.gui.resolve_config_path", + "campus_net.gui.list_windows_ipv4_interfaces", + "campus_net.gui.load_window_state", + "campus_net.gui.save_window_state", + "campus_net.gui.OperationController.start", + ) + with TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + config_path = root / "never-created.json" + backup_directory = root / "backups" + window_state_path = root / "window-state.json" + forbidden_mocks = [] + + with ExitStack() as stack: + for target in forbidden_targets: + forbidden_mocks.append( + stack.enter_context( + patch( + target, + side_effect=AssertionError(f"smoke test must not call {target}"), + ) + ) + ) + stack.enter_context( + patch( + "campus_net.gui.default_backup_directory", + return_value=backup_directory, + ) + ) + stack.enter_context( + patch( + "campus_net.gui.default_window_state_path", + return_value=window_state_path, + ) + ) + + launch_gui(config_path=config_path, smoke_test=True) + + for forbidden_mock in forbidden_mocks: + forbidden_mock.assert_not_called() + self.assertFalse(config_path.exists()) + self.assertEqual(list(root.iterdir()), []) + + def test_layout_scrolls_and_aligns_square_secret_toggle(self): + with TemporaryDirectory() as temporary_directory: + config_path = Path(temporary_directory) / "never-created.json" + root = ttk.Window(themename="darkly") + root.withdraw() + app = None + try: + app = CampusNetApp( + root, + config_path=config_path, + initialize_runtime=False, + ) + root.update_idletasks() + + self.assertIsInstance(app.form_stack, ScrolledFrame) + self.assertEqual(app.form_stack.vscroll.winfo_manager(), "pack") + self.assertTrue(app_icon_path().is_file()) + self.assertIsNotNone(app._header_logo_photo) + + toggle_borders = [ + widget + for widget in app.v2_frame.winfo_children() + for widget in widget.winfo_children() + if widget.winfo_class() == "TFrame" + and widget.cget("style") == "ToggleBorder.TFrame" + ] + self.assertEqual(len(toggle_borders), 1) + toggle_border = toggle_borders[0] + toggle_control = toggle_border.winfo_children()[0] + toggle = toggle_control.winfo_children()[0] + self.assertEqual(toggle.cget("style"), "primary.Square.Toggle") + self.assertEqual(tuple(app.v2_carrier_combo.cget("values")), CARRIERS) + self.assertEqual(str(app.v2_carrier_combo.cget("state")), "readonly") + self.assertEqual(app.v2_vars["carrier"].get(), "中国电信") + for combobox in (app.interface_combo, app.v2_carrier_combo): + bindtags = combobox.bindtags() + self.assertLess( + bindtags.index(COMBOBOX_WHEEL_GUARD_TAG), + bindtags.index("TCombobox"), + ) + app.form_stack._add_scroll_binding(app.form_stack) + app.v2_carrier_combo.event_generate("", delta=-120) + root.update() + self.assertEqual(app.v2_vars["carrier"].get(), "中国电信") + self.assertLessEqual( + abs(toggle_border.winfo_reqheight() - app.v2_carrier_combo.winfo_reqheight()), + 3, + ) + v1_entries = [ + widget + for field in app.v1_frame.winfo_children() + for widget in field.winfo_children() + if widget.winfo_class() == "TEntry" + ] + self.assertEqual(len(v1_entries), 6) + + app._set_status("操作完成。", "success") + self.assertEqual(app.status_icon_var.get(), "✓") + self.assertEqual(app.header_status_var.get(), "操作完成。") + + app.theme_display_var.set("浅色") + app._change_theme() + self.assertEqual( + root.style.lookup("Success.Interface.TLabel", "foreground"), + "#0f766e", + ) + finally: + if app is None: + root.destroy() + else: + root.update_idletasks() + root.update() + app._destroy_root() + + +if __name__ == "__main__": + unittest.main() diff --git a/workbench/network/campus-net/tests/test_legacy.py b/workbench/network/campus-net/tests/test_legacy.py new file mode 100644 index 0000000..6783e5f --- /dev/null +++ b/workbench/network/campus-net/tests/test_legacy.py @@ -0,0 +1,30 @@ +import unittest + +from campus_net.legacy import _sanitize_portal_message + + +class TestPortalMessageSanitization(unittest.TestCase): + def test_redacts_account_password_and_session(self): + message = "account student-01 password cipher-value session session-token" + + sanitized = _sanitize_portal_message( + message, + "student-01", + "cipher-value", + "session-token", + ) + + self.assertNotIn("student-01", sanitized) + self.assertNotIn("cipher-value", sanitized) + self.assertNotIn("session-token", sanitized) + self.assertEqual(sanitized.count("[已隐藏]"), 3) + + def test_normalizes_whitespace_and_limits_length(self): + sanitized = _sanitize_portal_message(" line one\n" + "x" * 300) + + self.assertTrue(sanitized.startswith("line one ")) + self.assertEqual(len(sanitized), 240) + + +if __name__ == "__main__": + unittest.main() diff --git a/workbench/network/campus-net/tests/test_runner.py b/workbench/network/campus-net/tests/test_runner.py new file mode 100644 index 0000000..22f6eaf --- /dev/null +++ b/workbench/network/campus-net/tests/test_runner.py @@ -0,0 +1,150 @@ +import unittest +from unittest.mock import AsyncMock, patch, sentinel + +from campus_net.aggregate import InitializationResult +from campus_net.captive import NetworkState, ProbeResult +from campus_net.config import CaptiveHttpConfig, ProbeConfig +from campus_net.runner import run_captive_http + + +def http_config() -> CaptiveHttpConfig: + return CaptiveHttpConfig( + adapter="captive-sso-http", + interface_index=24, + user_agent="test-agent", + probe=ProbeConfig( + url="http://connectivity.example/connecttest.txt", + online_status=200, + online_body="Microsoft Connect Test", + online_location=None, + timeout_seconds=8, + ), + portal_origin="http://10.71.29.181", + portal_entry_path="/eportal/index.jsp", + auth_mode="interactive-system-captcha", + username="example-student", + password="example-password", + service_display_name="中国电信", + captcha_attempts=0, + verification_interval_seconds=2, + verification_timeout_seconds=30, + ) + + +class FakeSessionContext: + async def __aenter__(self): + return sentinel.session + + async def __aexit__(self, _error_type, _error, _traceback): + return False + + +class TestCaptiveRunner(unittest.IsolatedAsyncioTestCase): + async def test_online_selected_interface_does_not_claim_campus_identity(self): + config = http_config() + status_messages: list[str] = [] + + with ( + patch( + "campus_net.runner.create_bound_connector", + return_value=sentinel.connector, + ), + patch("campus_net.runner.aiohttp.CookieJar", return_value=sentinel.cookie_jar), + patch( + "campus_net.runner.aiohttp.ClientSession", + return_value=FakeSessionContext(), + ), + patch("campus_net.runner.AggregatePortalClient") as client_type, + ): + client = client_type.return_value + client.initialize = AsyncMock( + return_value=InitializationResult( + probe=ProbeResult(NetworkState.ONLINE), + ) + ) + client.authenticate = AsyncMock() + + exit_code = await run_captive_http( + config, + status_callback=status_messages.append, + ) + + self.assertEqual(exit_code, 0) + self.assertEqual( + status_messages, + [ + "正在通过配置的 IPv4 接口探测校园网状态…", + "配置的 IPv4 接口 24 已通过独立连通性探测,无需登录;" + "如果校园网实际位于另一接口,请重新选择对应的 IPv4 接口。", + ], + ) + client.authenticate.assert_not_awaited() + + async def test_unreachable_interface_stops_without_falling_back_to_other_networks(self): + config = http_config() + for probe_only in (False, True): + with self.subTest(probe_only=probe_only): + status_messages: list[str] = [] + + with ( + patch( + "campus_net.runner.create_bound_connector", + return_value=sentinel.connector, + ) as create_connector, + patch( + "campus_net.runner.aiohttp.CookieJar", + return_value=sentinel.cookie_jar, + ), + patch( + "campus_net.runner.aiohttp.ClientSession", + return_value=FakeSessionContext(), + ) as session_type, + patch("campus_net.runner.AggregatePortalClient") as client_type, + ): + client = client_type.return_value + client.initialize = AsyncMock( + return_value=InitializationResult( + probe=ProbeResult( + NetworkState.UNKNOWN, + reason="配置接口未连接,或该接口当前没有可用路由", + ) + ) + ) + client.authenticate = AsyncMock() + client.select_service = AsyncMock() + client.trigger_online_check = AsyncMock() + client.verify_online = AsyncMock() + + exit_code = await run_captive_http( + config, + probe_only=probe_only, + status_callback=status_messages.append, + ) + + self.assertEqual(exit_code, 2) + self.assertEqual( + status_messages, + [ + "正在通过配置的 IPv4 接口探测校园网状态…", + "无法确认配置的 IPv4 接口 24 的网络状态:配置接口未连接,或该接口当前没有可用路由。" + "请确认该接口已接入校园网,或重新选择实际承载校园网的 IPv4 接口;" + "已停止,未改用其他网络接口。", + ], + ) + for private_detail in ("host", "ssl", "WinError", "Cannot connect"): + self.assertNotIn(private_detail, "\n".join(status_messages)) + create_connector.assert_called_once_with(24) + session_type.assert_called_once() + self.assertIs( + session_type.call_args.kwargs["connector"], + sentinel.connector, + ) + client.initialize.assert_awaited_once_with() + client.authenticate.assert_not_awaited() + client.select_service.assert_not_awaited() + client.trigger_online_check.assert_not_awaited() + client.verify_online.assert_not_awaited() + + +if __name__ == "__main__": + unittest.main() diff --git a/workbench/network/campus-net/tests/test_sso.py b/workbench/network/campus-net/tests/test_sso.py new file mode 100644 index 0000000..884e8e4 --- /dev/null +++ b/workbench/network/campus-net/tests/test_sso.py @@ -0,0 +1,226 @@ +import base64 +import unittest +from urllib.parse import parse_qs, urlparse + +from campus_net.sso import ( + LoginResultType, + SsoProtocolError, + UnsupportedSsoChallenge, + aes_encrypt, + build_login_form, + build_sso_action_url, + ensure_supported_challenge, + parse_captcha_requirement, + parse_login_response, + parse_sso_page, + resolve_captcha_url, +) +from Crypto.Cipher import AES + +PAGE_URL = "http://10.71.29.181/sam-sso/login?flowSessionId=session-123&empty=" +CRYPTO_KEY = base64.b64encode(b"0123456789abcdef").decode("ascii") + + +def login_page( + *, + crypto_key: str = CRYPTO_KEY, + execution: str = "flow-key-1", + vendor: str = "system", + risk_switch: str = "", + error_code: str = "", + error_message: str = "", +) -> bytes: + return ( + "" + f'

{crypto_key}

' + f'

{execution}

' + f'

{vendor}

' + f'

{risk_switch}

' + f'

{error_code}

' + f'

{error_message}

' + "" + ).encode() + + +def decrypt_field(ciphertext: str) -> str: + key = base64.b64decode(CRYPTO_KEY) + padded = AES.new(key, AES.MODE_ECB).decrypt(base64.b64decode(ciphertext)) + padding_length = padded[-1] + return padded[:-padding_length].decode("utf-8") + + +class TestSsoPage(unittest.TestCase): + def test_parses_dynamic_fields_and_preserves_blank_query(self): + page = parse_sso_page(login_page(), PAGE_URL) + + self.assertEqual(page.crypto_key, CRYPTO_KEY) + self.assertEqual(page.execution, "flow-key-1") + self.assertEqual(page.recaptcha_vendor, "system") + action_query = parse_qs(urlparse(page.action_url).query, keep_blank_values=True) + self.assertEqual(action_query["flowSessionId"], ["session-123"]) + self.assertEqual(action_query["empty"], [""]) + self.assertEqual(action_query["accept-language"], ["zh-CN"]) + + def test_rejects_missing_invalid_and_duplicate_crypto_metadata(self): + invalid_pages = { + "missing": b'

flow

', + "invalid-base64": login_page(crypto_key="not-base64"), + "wrong-size": login_page(crypto_key=base64.b64encode(b"too-short").decode("ascii")), + "duplicate": login_page() + f'

{CRYPTO_KEY}

'.encode(), + } + for name, body in invalid_pages.items(): + with self.subTest(name=name), self.assertRaises(SsoProtocolError): + parse_sso_page(body, PAGE_URL) + + def test_rejects_third_party_and_fingerprint_challenges(self): + for vendor, risk_switch in (("geetest", ""), ("google", ""), ("system", "USTC")): + with self.subTest(vendor=vendor, risk_switch=risk_switch): + page = parse_sso_page( + login_page(vendor=vendor, risk_switch=risk_switch), + PAGE_URL, + ) + with self.assertRaises(UnsupportedSsoChallenge): + ensure_supported_challenge(page) + + +class TestCaptchaRelationship(unittest.TestCase): + def test_parses_required_system_captcha(self): + requirement = parse_captcha_requirement( + { + "data": { + "captchaInvisible": True, + "captchaUrl": "/sam-sso/api/captcha/current", + } + } + ) + + self.assertTrue(requirement.required) + self.assertEqual(requirement.image_path, "/sam-sso/api/captcha/current") + + def test_rejects_required_captcha_without_image(self): + with self.assertRaises(SsoProtocolError): + parse_captcha_requirement({"data": {"captchaInvisible": True}}) + + def test_treats_empty_default_policy_as_not_required(self): + self.assertFalse(parse_captcha_requirement({"data": {}}).required) + self.assertFalse(parse_captcha_requirement({}).required) + + def test_resolves_only_same_origin_sam_sso_image(self): + self.assertEqual( + resolve_captcha_url(PAGE_URL, "/sam-sso/api/captcha/current"), + "http://10.71.29.181/sam-sso/api/captcha/current", + ) + self.assertEqual( + resolve_captcha_url(PAGE_URL, "api/captcha/current"), + "http://10.71.29.181/sam-sso/api/captcha/current", + ) + self.assertEqual( + resolve_captcha_url(PAGE_URL, "/sso/api/captcha/current"), + "http://10.71.29.181/sso/api/captcha/current", + ) + for image_path in ( + "http://example.test/sam-sso/api/captcha/current", + "/ssoevil/api/captcha/current", + "/portal/other", + ): + with self.subTest(image_path=image_path), self.assertRaises(SsoProtocolError): + resolve_captcha_url(PAGE_URL, image_path) + + def test_form_keeps_code_plain_and_encrypts_only_payload_and_password(self): + page = parse_sso_page(login_page(), PAGE_URL) + + form = build_login_form( + page, + username="example-student", + password="example-password", + captcha_code="a1b2", + captcha_required=True, + ) + + self.assertEqual([value for key, value in form if key == "captcha_code"], ["a1b2", "a1b2"]) + encrypted_password = next(value for key, value in form if key == "password") + encrypted_payload = next(value for key, value in form if key == "captcha_payload") + self.assertEqual(decrypt_field(encrypted_password), "example-password") + self.assertEqual(decrypt_field(encrypted_payload), "{}") + self.assertEqual(next(value for key, value in form if key == "croypto"), CRYPTO_KEY) + + def test_aes_uses_pkcs7_even_for_full_block(self): + ciphertext = aes_encrypt(CRYPTO_KEY, "1234567890abcdef") + + self.assertEqual(decrypt_field(ciphertext), "1234567890abcdef") + + +class TestLoginResponse(unittest.TestCase): + def test_accepts_exact_success_callback_with_default_port_equivalence(self): + result = parse_login_response( + status=302, + headers={ + "Location": "http://10.71.29.181:80/portal/assets/auth-success.html?ticket=t-1" + }, + body=b"", + page_url=PAGE_URL, + ) + + self.assertEqual(result.result_type, LoginResultType.SUCCESS) + self.assertIsNotNone(result.redirect_url) + + def test_rejects_ambiguous_or_cross_origin_success_callback(self): + locations = ( + "/portal/assets/auth-success.html?ticket=one&ticket=two", + "http://example.test/portal/assets/auth-success.html?ticket=one", + "/portal/assets/auth-success.html?ticket=one#fragment", + "/portal/assets/auth-success.html;unexpected?ticket=one", + ) + for location in locations: + with self.subTest(location=location): + result = parse_login_response( + status=302, + headers={"Location": location}, + body=b"", + page_url=PAGE_URL, + ) + self.assertEqual(result.result_type, LoginResultType.UNKNOWN) + + def test_recognizes_only_explicit_captcha_error_as_retryable(self): + captcha_error = parse_login_response( + status=200, + headers={}, + body=login_page(error_code="1320007", error_message="captcha"), + page_url=PAGE_URL, + ) + credentials_error = parse_login_response( + status=200, + headers={}, + body=login_page(error_code="other", error_message="credentials"), + page_url=PAGE_URL, + ) + + self.assertEqual(captcha_error.result_type, LoginResultType.CAPTCHA_ERROR) + self.assertEqual(credentials_error.result_type, LoginResultType.FAILURE) + self.assertEqual(credentials_error.error_message, "credentials") + + def test_recognizes_captcha_code_in_message_without_next_form(self): + result = parse_login_response( + status=200, + headers={}, + body=b'

1320007

', + page_url=PAGE_URL, + ) + + self.assertEqual(result.result_type, LoginResultType.CAPTCHA_ERROR) + self.assertIsNone(result.next_page) + + +class TestActionUrl(unittest.TestCase): + def test_replaces_existing_language_once(self): + action_url = build_sso_action_url( + PAGE_URL + "&accept-language=en-US", + language="zh-CN", + ) + + query = parse_qs(urlparse(action_url).query, keep_blank_values=True) + self.assertEqual(query["accept-language"], ["zh-CN"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/workbench/network/campus-net/tests/test_window_state.py b/workbench/network/campus-net/tests/test_window_state.py new file mode 100644 index 0000000..6ae5d9c --- /dev/null +++ b/workbench/network/campus-net/tests/test_window_state.py @@ -0,0 +1,184 @@ +import json +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +from campus_net.window_state import ( + ScreenRect, + WindowState, + clamp_window_state, + load_window_state, + save_window_state, +) + + +class TestWindowStatePersistence(unittest.TestCase): + def test_round_trips_window_state(self): + with TemporaryDirectory() as temporary_directory: + path = Path(temporary_directory) / "window-state.json" + state = WindowState( + width=1100, + height=720, + x=-1450, + y=85, + maximized=True, + ) + + save_window_state(state, path) + + self.assertEqual(load_window_state(path), state) + + def test_missing_or_corrupt_state_is_ignored(self): + with TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + missing_path = root / "missing.json" + self.assertIsNone(load_window_state(missing_path)) + + corrupt_payloads = ( + b"{not-json", + b"[]", + b'"window"', + json.dumps( + { + "height": 720, + "x": 20, + "y": 30, + "maximized": False, + } + ).encode("utf-8"), + ) + for index, payload in enumerate(corrupt_payloads): + with self.subTest(index=index): + path = root / f"corrupt-{index}.json" + path.write_bytes(payload) + self.assertIsNone(load_window_state(path)) + + def test_oversized_state_file_is_ignored(self): + with TemporaryDirectory() as temporary_directory: + path = Path(temporary_directory) / "oversized.json" + path.write_bytes(b" " * (1024 * 1024)) + + self.assertIsNone(load_window_state(path)) + + def test_rejects_booleans_for_integer_fields_and_integer_for_boolean(self): + with TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + base_payload = { + "version": 1, + "width": 1100, + "height": 720, + "x": 20, + "y": 30, + "maximized": False, + } + invalid_values = { + "width": True, + "height": False, + "x": True, + "y": False, + "maximized": 1, + } + + for index, (field, value) in enumerate(invalid_values.items()): + with self.subTest(field=field): + payload = {**base_payload, field: value} + path = root / f"invalid-{index}.json" + path.write_text(json.dumps(payload), encoding="utf-8") + self.assertIsNone(load_window_state(path)) + + def test_save_rejects_invalid_window_state(self): + with TemporaryDirectory() as temporary_directory: + path = Path(temporary_directory) / "window-state.json" + + with self.assertRaises(ValueError): + save_window_state(WindowState(True, 720, 20, 30), path) + + self.assertFalse(path.exists()) + + def test_failed_atomic_replace_preserves_previous_state(self): + with TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + path = root / "window-state.json" + original = WindowState(1100, 720, 50, 60) + replacement = WindowState(1280, 800, 70, 80, maximized=True) + save_window_state(original, path) + + with patch( + "campus_net.window_state.os.replace", + side_effect=OSError("replace failed"), + ): + with self.assertRaisesRegex(OSError, "replace failed"): + save_window_state(replacement, path) + + self.assertEqual(load_window_state(path), original) + self.assertEqual(list(root.iterdir()), [path]) + + +class TestClampWindowState(unittest.TestCase): + def test_preserves_negative_coordinates_on_connected_left_monitor(self): + state = WindowState(1200, 700, -1800, 120, maximized=True) + work_areas = [ + ScreenRect(-1920, 0, 0, 1040), + ScreenRect(0, 0, 1920, 1040), + ] + + clamped = clamp_window_state(state, work_areas) + + self.assertEqual(clamped, state) + + def test_centers_disconnected_monitor_state_on_primary_work_area(self): + state = WindowState(1000, 700, 2500, 140) + primary = ScreenRect(0, 0, 1920, 1040) + + clamped = clamp_window_state( + state, + [primary, ScreenRect(-1920, 0, 0, 1040)], + ) + + self.assertEqual( + clamped, + WindowState( + width=1100, + height=720, + x=410, + y=160, + ), + ) + + def test_resets_too_small_size_to_safe_defaults(self): + state = WindowState(300, 200, 40, 50, maximized=True) + + clamped = clamp_window_state( + state, + [ScreenRect(0, 0, 1920, 1040)], + min_width=940, + min_height=620, + default_width=1000, + default_height=680, + ) + + self.assertEqual((clamped.width, clamped.height), (940, 620)) + self.assertEqual((clamped.x, clamped.y), (40, 50)) + self.assertTrue(clamped.maximized) + + def test_shrinks_too_large_visible_state_to_its_work_area(self): + state = WindowState(5000, 4000, 0, 0) + primary = ScreenRect(0, 0, 1920, 1040) + + clamped = clamp_window_state( + state, + [primary], + default_width=1100, + default_height=720, + ) + + self.assertEqual((clamped.width, clamped.height), (1920, 1040)) + self.assertGreaterEqual(clamped.x, primary.left) + self.assertGreaterEqual(clamped.y, primary.top) + self.assertLessEqual(clamped.x + clamped.width, primary.right) + self.assertLessEqual(clamped.y + clamped.height, primary.bottom) + + +if __name__ == "__main__": + unittest.main() diff --git a/workbench/network/campus-net/uv.lock b/workbench/network/campus-net/uv.lock deleted file mode 100644 index ac3becc..0000000 --- a/workbench/network/campus-net/uv.lock +++ /dev/null @@ -1,718 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.11" - -[[package]] -name = "aiohappyeyeballs" -version = "2.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, -] - -[[package]] -name = "aiohttp" -version = "3.14.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohappyeyeballs" }, - { name = "aiosignal" }, - { name = "attrs" }, - { name = "frozenlist" }, - { name = "multidict" }, - { name = "propcache" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, - { name = "yarl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/26/dd/bf526e6f0a1120dd6f2df2e97bacfe4d358f13d17a0ff5847301a1375a51/aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2", size = 765225, upload-time = "2026-06-07T21:06:07.957Z" }, - { url = "https://files.pythonhosted.org/packages/8f/e1/a2872aa55495a70f61310d411541c6ee23812d9a884e000c716e1bc3edbf/aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f", size = 518743, upload-time = "2026-06-07T21:06:09.749Z" }, - { url = "https://files.pythonhosted.org/packages/5b/e7/c60c7b209e509cc787de3cea0550a518538cfc08003e1c1e14c1c63fff71/aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8", size = 514139, upload-time = "2026-06-07T21:06:11.26Z" }, - { url = "https://files.pythonhosted.org/packages/5b/8d/614ace2f579702c9840ab1e1447fd8509e35b0b904f7196418fa2f57b25d/aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04", size = 1784088, upload-time = "2026-06-07T21:06:12.887Z" }, - { url = "https://files.pythonhosted.org/packages/49/e0/726e90f99542bf292f81a96a12cc4847deb86f3ccf62c6f4014a201f4d33/aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8", size = 1737835, upload-time = "2026-06-07T21:06:14.564Z" }, - { url = "https://files.pythonhosted.org/packages/0b/4b/d176d5c4db9d33dacf0543102ea59503bc1d528af4cfd0b719949ca49389/aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6", size = 1842801, upload-time = "2026-06-07T21:06:16.228Z" }, - { url = "https://files.pythonhosted.org/packages/dc/d6/5a99b563690ea0cbed912ae94a2ce33993a5709a651a3a4fe761e7dd973a/aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af", size = 1929992, upload-time = "2026-06-07T21:06:17.947Z" }, - { url = "https://files.pythonhosted.org/packages/76/7f/a987b14a3859094b3cea3f4825219c3e5536242564af6e3f9c2f6c994eb2/aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730", size = 1786989, upload-time = "2026-06-07T21:06:19.677Z" }, - { url = "https://files.pythonhosted.org/packages/f1/1a/420e5c85a3e73349372ed22ce0b6af86bfa6ce16a4b20a64a2e94608c781/aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621", size = 1640129, upload-time = "2026-06-07T21:06:22.558Z" }, - { url = "https://files.pythonhosted.org/packages/a7/80/18a592ed3be0a402cc03670bd72ee1f8563ddbe1d8d5542dbf868f274136/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee", size = 1756576, upload-time = "2026-06-07T21:06:24.8Z" }, - { url = "https://files.pythonhosted.org/packages/ec/0b/8b3d5713373858ff71a617daf6e3b0e81ad63e79d09a3cf2f6b6b983939c/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573", size = 1754668, upload-time = "2026-06-07T21:06:26.528Z" }, - { url = "https://files.pythonhosted.org/packages/9f/49/fd564575cf225821d7ba5a117cb8bc27213d8a7e1811162afb43ae077039/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7", size = 1817019, upload-time = "2026-06-07T21:06:28.297Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1b/e850c9ae6fc91356552ae668bb6c51e93fa29c8aef13398a10b56678557f/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf", size = 1631638, upload-time = "2026-06-07T21:06:30.242Z" }, - { url = "https://files.pythonhosted.org/packages/eb/94/3c337ba72451a89806ace6f75bddc92bafc5b8d53d90115a512858024b63/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85", size = 1835660, upload-time = "2026-06-07T21:06:31.943Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9c/9c18cf367a0498212d9ba7daf990b504a5e8ae064cda4b504e2647c89c03/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3", size = 1775698, upload-time = "2026-06-07T21:06:33.72Z" }, - { url = "https://files.pythonhosted.org/packages/b5/63/a251a9d2a6cb45065b2ddc0bde2b3dd10108740a9a42f632c66405a761a2/aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126", size = 458386, upload-time = "2026-06-07T21:06:35.279Z" }, - { url = "https://files.pythonhosted.org/packages/17/ca/69274c51dcd6e8947d77b2806cf47a4a15f2c846e2cbeb1882547d3da283/aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5", size = 483406, upload-time = "2026-06-07T21:06:36.824Z" }, - { url = "https://files.pythonhosted.org/packages/2c/8a/c25904f77690c3688ec140f87591ef11a0cfe36bf3d5c0f1f38056fb62b3/aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b", size = 452987, upload-time = "2026-06-07T21:06:38.371Z" }, - { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" }, - { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" }, - { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" }, - { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" }, - { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" }, - { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" }, - { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" }, - { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" }, - { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" }, - { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" }, - { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" }, - { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" }, - { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" }, - { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" }, - { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" }, - { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" }, - { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" }, - { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" }, - { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" }, - { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" }, - { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" }, - { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" }, - { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" }, - { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" }, - { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" }, - { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" }, - { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" }, - { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" }, - { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" }, - { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" }, - { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" }, - { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" }, - { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" }, - { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" }, - { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" }, - { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, - { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, - { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, - { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, - { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, - { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, - { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, - { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, - { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, - { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, - { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, - { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, - { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, - { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, - { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, - { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, - { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, - { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, - { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, - { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, - { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, - { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, - { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, - { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, - { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, - { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, - { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, - { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, - { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, - { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, - { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, - { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, - { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, - { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, - { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, - { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, - { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, - { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, -] - -[[package]] -name = "aiosignal" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "frozenlist" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, -] - -[[package]] -name = "altgraph" -version = "0.17.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428, upload-time = "2025-11-21T20:35:50.583Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" }, -] - -[[package]] -name = "attrs" -version = "26.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, -] - -[[package]] -name = "cedarflake-campus-net" -version = "1.0.0" -source = { virtual = "." } -dependencies = [ - { name = "aiohttp" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pyinstaller" }, -] - -[package.metadata] -requires-dist = [{ name = "aiohttp", specifier = ">=3.12.0,<4" }] - -[package.metadata.requires-dev] -dev = [{ name = "pyinstaller", specifier = ">=6.11,<7" }] - -[[package]] -name = "frozenlist" -version = "1.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, - { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, - { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, - { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, - { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, - { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, - { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, - { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, - { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, - { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, - { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, - { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, - { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, - { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, - { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, - { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, - { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, - { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, - { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, - { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, - { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, - { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, - { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, - { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, - { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, - { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, - { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, - { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, - { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, - { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, - { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, - { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, - { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, - { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, - { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, - { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, - { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, - { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, - { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, - { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, - { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, - { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, - { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, - { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, - { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, - { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, - { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, - { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, - { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, - { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, - { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, - { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, - { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, - { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, - { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, - { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, - { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, - { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, - { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, - { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, - { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, - { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, - { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, - { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, - { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, - { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, - { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, - { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, - { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, - { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, - { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, - { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, - { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, - { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, - { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, - { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, - { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, - { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, - { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, - { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, - { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, - { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, - { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, - { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, - { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, - { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, - { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, - { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, - { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, - { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, - { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, - { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, - { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, - { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, - { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, -] - -[[package]] -name = "idna" -version = "3.18" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, -] - -[[package]] -name = "macholib" -version = "1.16.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "altgraph" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" }, -] - -[[package]] -name = "multidict" -version = "6.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, - { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, - { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, - { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, - { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, - { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, - { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, - { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, - { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, - { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, - { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, - { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, - { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, - { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, - { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, - { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, - { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, - { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, - { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, - { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, - { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, - { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, - { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, - { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, - { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, - { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, - { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, - { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, - { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, - { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, - { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, - { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, - { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, - { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, - { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, - { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, - { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, - { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, - { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, - { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, - { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, - { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, - { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, - { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, - { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, - { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, - { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, - { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, - { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, - { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, - { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, - { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, - { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, - { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, - { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, - { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, - { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, - { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, - { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, - { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, - { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, - { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, - { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, - { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, - { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, - { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, - { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, - { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, - { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, - { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, - { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, - { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, - { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, - { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, - { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, - { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, - { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, - { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, - { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, - { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, - { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, - { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, - { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, - { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, - { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, - { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, - { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, - { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, - { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, - { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, - { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, - { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, - { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, - { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, - { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, - { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, - { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, - { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, - { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, - { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, - { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, - { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, -] - -[[package]] -name = "packaging" -version = "26.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, -] - -[[package]] -name = "pefile" -version = "2024.8.26" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/03/4f/2750f7f6f025a1507cd3b7218691671eecfd0bbebebe8b39aa0fe1d360b8/pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632", size = 76008, upload-time = "2024-08-26T20:58:38.155Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" }, -] - -[[package]] -name = "propcache" -version = "0.5.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/f1/8a8cc1c2c7e7934ab77e0163414f736fadbc0f5e8dd9673b952355ac175b/propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78", size = 90744, upload-time = "2026-05-08T20:59:45.799Z" }, - { url = "https://files.pythonhosted.org/packages/c2/f4/651b1225e976bd1a2ba5cfba0c29d096581c2636b437e3a9a7ab6276270a/propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959", size = 52033, upload-time = "2026-05-08T20:59:47.408Z" }, - { url = "https://files.pythonhosted.org/packages/15/a8/8ede85d6aa1f79fc7dc2f8fd2c8d65920b8272c3892903c8a1affde48cfb/propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7", size = 52754, upload-time = "2026-05-08T20:59:49.202Z" }, - { url = "https://files.pythonhosted.org/packages/7d/fe/b3551b41bbc2f5b5bb088fc6920567cd43101253e68fbaa261339eb96fe1/propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511", size = 57573, upload-time = "2026-05-08T20:59:50.778Z" }, - { url = "https://files.pythonhosted.org/packages/83/27/ab851ebd1b7172e3e161f5f8d39e315d54a91bea246f01f4d872d3376aef/propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660", size = 60645, upload-time = "2026-05-08T20:59:52.227Z" }, - { url = "https://files.pythonhosted.org/packages/95/7d/466b3d18022e9897cbda9c735c493c5bd747d7a4c6f5ea1480b4cec434b6/propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66", size = 61563, upload-time = "2026-05-08T20:59:53.866Z" }, - { url = "https://files.pythonhosted.org/packages/27/1b/16ab7f2cf2041da2f60d156ba64c2484eadf9168075b4ff43c3ef60045af/propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b", size = 58888, upload-time = "2026-05-08T20:59:55.457Z" }, - { url = "https://files.pythonhosted.org/packages/0a/67/bb777ffd907633563bf35fd859c4ce97b0512c32f4633cf5d1eb7c33512b/propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67", size = 59253, upload-time = "2026-05-08T20:59:57.075Z" }, - { url = "https://files.pythonhosted.org/packages/b9/42/64f8d90b73fd9cdc1499b48057ff6d9cd2a98a25734c9bb62ecf07e87061/propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f", size = 57558, upload-time = "2026-05-08T20:59:58.602Z" }, - { url = "https://files.pythonhosted.org/packages/eb/02/dba5bc03c9041f2092ea55a449caf5dfe68352c6654511b29ba0654ddb69/propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c", size = 55007, upload-time = "2026-05-08T20:59:59.837Z" }, - { url = "https://files.pythonhosted.org/packages/14/c0/43f649c7aa2a77a3b100d84e9dea3a483120ecb608bfe36ce49eaff517fe/propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0", size = 60355, upload-time = "2026-05-08T21:00:01.144Z" }, - { url = "https://files.pythonhosted.org/packages/83/c0/435dafd27f1cb4a495381dae60e25883ccfe4020bb72818e8184c1678092/propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6", size = 59057, upload-time = "2026-05-08T21:00:02.401Z" }, - { url = "https://files.pythonhosted.org/packages/53/ae/6e292df9135d659944e96cb3389258e4a663e5b2b5f6c217ef0ddc8d2f73/propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27", size = 61938, upload-time = "2026-05-08T21:00:03.638Z" }, - { url = "https://files.pythonhosted.org/packages/0b/42/314ebc50d8159055411fd6b0bda322ff510e4b1f7d2e4927940ad0f6af20/propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f", size = 59731, upload-time = "2026-05-08T21:00:04.881Z" }, - { url = "https://files.pythonhosted.org/packages/b8/9b/2da6dee38871c3c8772fabc2758325a5c9077d6d18c597737dc04dd884cd/propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0", size = 38966, upload-time = "2026-05-08T21:00:06.511Z" }, - { url = "https://files.pythonhosted.org/packages/42/4e/f17363fb58c0afe05b067361cb6d86ed2d29de6506779a27547c4d183075/propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82", size = 42135, upload-time = "2026-05-08T21:00:08.088Z" }, - { url = "https://files.pythonhosted.org/packages/c6/eb/6af6685077d22e8b33358d3c548e3282706a0b3cd85044ffba4e5dd08e3b/propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab", size = 38381, upload-time = "2026-05-08T21:00:09.692Z" }, - { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, - { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, - { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, - { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, - { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, - { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, - { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, - { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, - { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, - { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, - { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, - { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, - { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, - { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, - { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, - { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, - { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, - { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, - { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, - { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, - { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, - { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, - { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, - { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, - { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, - { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, - { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, - { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, - { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, - { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, - { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, - { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, - { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, - { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, - { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, - { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, - { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, - { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, - { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, - { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, - { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, - { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, - { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, - { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, - { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, - { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, - { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, - { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, - { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, - { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, - { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, - { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, - { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, - { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, - { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, - { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, - { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, - { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, - { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, - { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, - { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, - { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, - { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, - { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, - { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, - { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, - { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, - { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, - { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, - { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, - { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, - { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, - { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, - { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, - { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, - { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, - { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, - { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, - { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, - { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, - { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, - { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, - { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, -] - -[[package]] -name = "pyinstaller" -version = "6.21.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "altgraph" }, - { name = "macholib", marker = "sys_platform == 'darwin'" }, - { name = "packaging" }, - { name = "pefile", marker = "sys_platform == 'win32'" }, - { name = "pyinstaller-hooks-contrib" }, - { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, - { name = "setuptools" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d5/4d/ec706c3fcf39e26888c35b39615ff4d5865d184069666c47492cff1fbe50/pyinstaller-6.21.0.tar.gz", hash = "sha256:bb9fab705983e393a2d1cac77d6972513057ad800215fd861dc15ff5272e98fd", size = 4061519, upload-time = "2026-06-13T14:15:06.25Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/4a/53cf98bf66daed012dc9cd78c8203f19a675d696f2fc12afcf8c5049a0e0/pyinstaller-6.21.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:327d132389f37912609e01be62810cf96b5aa95b613903e4b8692e0d12fb0eda", size = 1052350, upload-time = "2026-06-13T14:13:55.88Z" }, - { url = "https://files.pythonhosted.org/packages/30/83/b591295c352ef464c50b4c6ffff1c4f771d875c9e833f578d1b9f564f6b3/pyinstaller-6.21.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7071d4b094d5b40deeef5fa3d3b98a1b846087f7562b49209663d5f9281fe251", size = 748477, upload-time = "2026-06-13T14:14:00.327Z" }, - { url = "https://files.pythonhosted.org/packages/3d/8f/88fff4e403873b1e22286911350e75ff00db014aa08e57045da9d4328993/pyinstaller-6.21.0-py3-none-manylinux2014_i686.whl", hash = "sha256:6b6374d652107dd4a2eeece903ff82bb4045bb5e1006c5a158a6dcdbefe84bf2", size = 760877, upload-time = "2026-06-13T14:14:04.836Z" }, - { url = "https://files.pythonhosted.org/packages/8a/13/f0e48fbdfd1d05d948157121cea8b1b823dcb89efe6934b71fdd8bdb3f0f/pyinstaller-6.21.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:4e3108b3f02384560da70e39b8bf22b0ad597d02bd68a40d76ea91c1cfa00cad", size = 759194, upload-time = "2026-06-13T14:14:10.61Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d5/ea7878cf9924ed30d946d8288777424e6d069d94f5bde56b4d0890069664/pyinstaller-6.21.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:697532279f535ad572bda613db4f821540e235c7854ca6da4d3bf0373f4415ee", size = 754979, upload-time = "2026-06-13T14:14:15.226Z" }, - { url = "https://files.pythonhosted.org/packages/9f/09/51b8905714b733bac66dbc041a7821372d70d888d273ae474c4037d4202d/pyinstaller-6.21.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:605169523a6b5ace39f13dfbff21add9f2bc43df99c7daf9394fefb2c45e8b6f", size = 754812, upload-time = "2026-06-13T14:14:20.264Z" }, - { url = "https://files.pythonhosted.org/packages/4b/43/d77779439d8c6c2e27a77bcfbd1d5cc0f568ebb611bb472b11af81b5f177/pyinstaller-6.21.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:5fa56746c1e76f93634d018502301378a2d0c382553d37d8c3c34ff436c12dd1", size = 753887, upload-time = "2026-06-13T14:14:25.268Z" }, - { url = "https://files.pythonhosted.org/packages/51/8f/c22df1f6837784ac349057ba693f08e7b1ca7a0e06f9c33c63bc6280007b/pyinstaller-6.21.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:42395ec76df8e8120c36b13339d9db8cab83e316a12839ee303cc00fc941bb74", size = 753779, upload-time = "2026-06-13T14:14:29.445Z" }, - { url = "https://files.pythonhosted.org/packages/c9/76/1ce8a27ce62ba8cf3a87c9ce6d575610f4e55d7cb0123e7512fc3f4b921a/pyinstaller-6.21.0-py3-none-win32.whl", hash = "sha256:c6b28d30d8fd99ce162ff3aab5013ed44dbfb747566b1f01b9bed7964d7c14e9", size = 1336462, upload-time = "2026-06-13T14:14:35.785Z" }, - { url = "https://files.pythonhosted.org/packages/c1/fa/ca1d7e5257dd8566a9dfc0dfb02f8a8075eeb53d4b2d3c579f1276759042/pyinstaller-6.21.0-py3-none-win_amd64.whl", hash = "sha256:7fae06c494ce0ebfe6bd3055c0e409def884f63af2e3705d06bd431ad9237fc7", size = 1397487, upload-time = "2026-06-13T14:14:42.328Z" }, - { url = "https://files.pythonhosted.org/packages/dc/75/21b51523ce8d96629b71311775a0a65f5f5a872124ab0de33e5c848f8bff/pyinstaller-6.21.0-py3-none-win_arm64.whl", hash = "sha256:f13c95c9c03fb567217135919f93815c305813126780b0ed6e0123cb8acaf025", size = 1346094, upload-time = "2026-06-13T14:14:48.914Z" }, -] - -[[package]] -name = "pyinstaller-hooks-contrib" -version = "2026.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/94/5b/c9fe0db5e83ee1c39b2258fa21d23b15e1a60786b6c5990ee5074ead8bb6/pyinstaller_hooks_contrib-2026.6.tar.gz", hash = "sha256:bef5002c32f4f50bd55b005da12cff64eca8783e7eaf86a06a62410164bab725", size = 173354, upload-time = "2026-06-08T22:37:16.152Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/31/f2d7343d8ed5f7c4678377886f6ce533e6eaaa131b252ce950114c2a7efa/pyinstaller_hooks_contrib-2026.6-py3-none-any.whl", hash = "sha256:fd13b8ac126b35361175edacd41a0d97080b75dd5f4b594ecefefff969509dd3", size = 457159, upload-time = "2026-06-08T22:37:14.722Z" }, -] - -[[package]] -name = "pywin32-ctypes" -version = "0.2.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, -] - -[[package]] -name = "setuptools" -version = "83.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, -] - -[[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 = "yarl" -version = "1.24.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "multidict" }, - { name = "propcache" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/c5/1ce244152ff2839645e7cae92f90e7bafcb2c52bea7ff586ac714f14f5df/yarl-1.24.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:36348bebb147b83818b9d7e673ea4debc75970afc6ffdc7e3975ad05ce5a58c1", size = 128971, upload-time = "2026-05-19T21:28:20.543Z" }, - { url = "https://files.pythonhosted.org/packages/87/5a/00f36967203ed89cb3acd2c8ed526cc3fed9418eb70ce128160a911c8499/yarl-1.24.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a97e42c8a2233f2f279ecadd9e4a037bcb5d813b78435e8eedd4db5a9e9708c", size = 91507, upload-time = "2026-05-19T21:28:22.556Z" }, - { url = "https://files.pythonhosted.org/packages/31/d0/1fb0c1cd27288f39f6974da4318c32768d72c9890984541fdf1e2e32a51d/yarl-1.24.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8d027d56f1035e339d1001ac33eceab5b2ec8e42e449787bb75e289fb9a5cd1d", size = 91343, upload-time = "2026-05-19T21:28:24.092Z" }, - { url = "https://files.pythonhosted.org/packages/03/ce/d4a646508bed2f8dec6435b40166fe9308dd191262033d3f307b2bbcaecd/yarl-1.24.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a6377060e7927187a42b7eb202090cbe2b34933a4eeaf90e3bd9e33432e5cae", size = 105704, upload-time = "2026-05-19T21:28:25.872Z" }, - { url = "https://files.pythonhosted.org/packages/4b/07/b3278e82d8bc41485bcf6d856cd0433262593de615b1d3dc43bd3f5bead4/yarl-1.24.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:17076578bce0049a5ce57d14ad1bded391b68a3b213e9b81b0097b090244999a", size = 97281, upload-time = "2026-05-19T21:28:27.352Z" }, - { url = "https://files.pythonhosted.org/packages/17/5b/4cee6e7c92e487bebe7afc797da0aa54a248ab4e776a68fe369ec29665a5/yarl-1.24.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:50713f1d4d6be6375bb178bb43d140ee1acb8abe589cd723320b7925a275be1e", size = 114020, upload-time = "2026-05-19T21:28:29.458Z" }, - { url = "https://files.pythonhosted.org/packages/5c/82/111076571545a7d4f9cca3fbd5c6f40615af58642be09f12328f48022468/yarl-1.24.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:34263e2fa8fb5bb63a0d97706cda38edbad62fddb58c7f12d6acbc092812aa50", size = 111450, upload-time = "2026-05-19T21:28:31.262Z" }, - { url = "https://files.pythonhosted.org/packages/b6/ec/08f671f69a444d704aeecebf92af659b67b97a869942411d0a578b08c334/yarl-1.24.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49016d82f032b1bd1e10b01078a7d29ae71bf468eeae0ea22df8bab691e60003", size = 106384, upload-time = "2026-05-19T21:28:32.856Z" }, - { url = "https://files.pythonhosted.org/packages/e5/86/ce41e7a7a199340b2330d52b60f25c4074b6636dd0e60b1a80d31a9db042/yarl-1.24.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3f6d2c216318f8f32038ca3f72501ba08536f0fd18a36e858836b121b2deed9f", size = 106153, upload-time = "2026-05-19T21:28:35.222Z" }, - { url = "https://files.pythonhosted.org/packages/c4/5d/31be8a729531ab3e55ac3e7e5c800be8c89ea98947f418b2f6ea259fb6ee/yarl-1.24.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:08d3a33218e0c64393e7610284e770409a9c31c429b078bcb24096ed0a783b8f", size = 105322, upload-time = "2026-05-19T21:28:36.642Z" }, - { url = "https://files.pythonhosted.org/packages/47/9b/b57afb22b386ae87ac9940f09878b98d8c333f89113e6fc96fcf4ca9eb64/yarl-1.24.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5d699376c4ca3cba49bbfae3a05b5b70ded572937171ce1e0b8d87118e2ba294", size = 99057, upload-time = "2026-05-19T21:28:38.386Z" }, - { url = "https://files.pythonhosted.org/packages/a3/4f/06348c27c8389256c313e8a57d796808fc0264c915dd5e7cfd3c0e314dc7/yarl-1.24.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a1cab588b4fa14bea2e55ebea27478adfb05372f47573738e1acc4a36c0b05d2", size = 113502, upload-time = "2026-05-19T21:28:40.091Z" }, - { url = "https://files.pythonhosted.org/packages/5f/1c/284f307b298e4a17b7943b07d9d7ecc4151537f8d137ba51f3bb6c31ca20/yarl-1.24.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ec87ccc31bd21db7ad009d8572c127c1000f268517618a4cc09adba3c2a7f21c", size = 105253, upload-time = "2026-05-19T21:28:41.987Z" }, - { url = "https://files.pythonhosted.org/packages/c8/bf/0de123bec8619e45c80cbded9085f61b5b4a9eddb8abe6d25d28ee1ec866/yarl-1.24.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d1dd47a22843b212baa8d74f37796815d43bd046b42a0f41e9da433386c3136b", size = 111345, upload-time = "2026-05-19T21:28:43.93Z" }, - { url = "https://files.pythonhosted.org/packages/90/af/0248eb065e51129d2a9b2436cd1b5c772c19a6b04e5b6a186955671e3319/yarl-1.24.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7b54b9c67c2b06bd7b9a77253d242124b9c95d2c02def5a1144001ee547dd9d5", size = 106558, upload-time = "2026-05-19T21:28:45.806Z" }, - { url = "https://files.pythonhosted.org/packages/21/3c/f960d7a65ef97d8ba9b424fb5128796a4bc710fc6df2ddbbd7dfdc3bbd20/yarl-1.24.2-cp311-cp311-win_amd64.whl", hash = "sha256:f8fdbcff8b2c7c9284e60c196f693588598ddcee31e11c18e14949ce44519d45", size = 92808, upload-time = "2026-05-19T21:28:48.465Z" }, - { url = "https://files.pythonhosted.org/packages/03/1a/49fb03750e4de4d2284cd5b885a383133c34eef45bd59631b2bb8b7e81e8/yarl-1.24.2-cp311-cp311-win_arm64.whl", hash = "sha256:b32c37a7a337e90822c45797bf3d79d60875cfcccd3ecc80e9f453d87026c122", size = 87610, upload-time = "2026-05-19T21:28:50.07Z" }, - { url = "https://files.pythonhosted.org/packages/f0/da/866bcb01076ba49d2b42b309867bed3826421f1c479655eb7a607b44f20b/yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8", size = 129957, upload-time = "2026-05-19T21:28:51.695Z" }, - { url = "https://files.pythonhosted.org/packages/bf/1d/fcefb70922ea2268a8971d8e5874d9a8218644200fb8465f1dcad55e6851/yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2", size = 92164, upload-time = "2026-05-19T21:28:53.242Z" }, - { url = "https://files.pythonhosted.org/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d", size = 91688, upload-time = "2026-05-19T21:28:54.865Z" }, - { url = "https://files.pythonhosted.org/packages/fe/a5/c9f655d5553ea0b99fdac9d6a99ad3f9b3e73b8e5758bb46f58c9831f74c/yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035", size = 102902, upload-time = "2026-05-19T21:28:56.963Z" }, - { url = "https://files.pythonhosted.org/packages/5d/bc/6b9664d815d79af4ee553337f9d606c56bbf269186ada9172de45f1b5f60/yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576", size = 97931, upload-time = "2026-05-19T21:28:58.56Z" }, - { url = "https://files.pythonhosted.org/packages/98/ec/32ba48acae30fecd60928f5791188b80a9d6ee3840507ffda29fecd37b71/yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8", size = 111030, upload-time = "2026-05-19T21:29:00.148Z" }, - { url = "https://files.pythonhosted.org/packages/82/5a/6f4cd081e5f4934d2ae3a8ef4abe3afacc010d26f0035ee91b35cd7d7c37/yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7", size = 110392, upload-time = "2026-05-19T21:29:02.155Z" }, - { url = "https://files.pythonhosted.org/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c", size = 105612, upload-time = "2026-05-19T21:29:04.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/80/264ab684f181e1a876389374519ff05d10248725535ae2ac4e8ac4e563d6/yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d", size = 104487, upload-time = "2026-05-19T21:29:06.491Z" }, - { url = "https://files.pythonhosted.org/packages/41/07/efabe5df87e96d7ad5959760b888344be48cd6884db127b407c6b5503adc/yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db", size = 102333, upload-time = "2026-05-19T21:29:08.267Z" }, - { url = "https://files.pythonhosted.org/packages/44/0c/bcf7c42603e1009295f586d8890f2ba032c8b53310e815adf0a202c73d9f/yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712", size = 99025, upload-time = "2026-05-19T21:29:10.682Z" }, - { url = "https://files.pythonhosted.org/packages/4f/82/84482ab1a57a0f21a08afe6a7004c61d741f8f2ecc3b05c321577c612164/yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996", size = 110507, upload-time = "2026-05-19T21:29:12.954Z" }, - { url = "https://files.pythonhosted.org/packages/c4/8d/a546ba1dfe1b0f290e05fef145cd07614c0f15df1a707195e512d1e39d1d/yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b", size = 103719, upload-time = "2026-05-19T21:29:14.893Z" }, - { url = "https://files.pythonhosted.org/packages/1a/b6/267f2a09213138473adfce6b8a6e17791d7fee70bd4d9003218e4dec58b0/yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c", size = 110438, upload-time = "2026-05-19T21:29:16.485Z" }, - { url = "https://files.pythonhosted.org/packages/48/2d/1c8d89c7c5f9cad9fb2902445d94e2ab1d7aa35de029afbb8ae95c42d00f/yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1", size = 105719, upload-time = "2026-05-19T21:29:18.367Z" }, - { url = "https://files.pythonhosted.org/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad", size = 92901, upload-time = "2026-05-19T21:29:20.014Z" }, - { url = "https://files.pythonhosted.org/packages/39/47/4486ccfb674c04854a1ef8aa77868b6a6f765feaf69633409d7ca4f02cb8/yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30", size = 87229, upload-time = "2026-05-19T21:29:22.1Z" }, - { url = "https://files.pythonhosted.org/packages/82/62/fcf0ce677f17e5c471c06311dd25964be38a4c586993632910d2e75278bc/yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536", size = 128978, upload-time = "2026-05-19T21:29:23.83Z" }, - { url = "https://files.pythonhosted.org/packages/d3/58/8e63299bb71ed61a834121d9d3fe6c9fcf2a6a5d09754ff4f20f2d20baf5/yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607", size = 91733, upload-time = "2026-05-19T21:29:25.375Z" }, - { url = "https://files.pythonhosted.org/packages/c1/24/16748d5dab6daec8b0ed81ccec639a1cded0f18dcc62a4f696b4fe366c37/yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1", size = 91113, upload-time = "2026-05-19T21:29:26.928Z" }, - { url = "https://files.pythonhosted.org/packages/1b/66/b63fff7b71211e866624b21432d5943cbb633eb0c2872d9ee3070648f22c/yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986", size = 103899, upload-time = "2026-05-19T21:29:28.842Z" }, - { url = "https://files.pythonhosted.org/packages/9d/ac/ba1974b8533909636f7733fe86cf677e3619527c3c2fa913e0ea89c48757/yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488", size = 97862, upload-time = "2026-05-19T21:29:31.086Z" }, - { url = "https://files.pythonhosted.org/packages/1b/a5/123ac993b5c2ba6f554a140305620cb8f150fa543711bbc49be3ec0a65a4/yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b", size = 111060, upload-time = "2026-05-19T21:29:32.657Z" }, - { url = "https://files.pythonhosted.org/packages/23/37/c472d3af3509688392134a88a825276770a187f1daa4de3f6dc0a327a751/yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592", size = 110613, upload-time = "2026-05-19T21:29:34.379Z" }, - { url = "https://files.pythonhosted.org/packages/df/88/09c28dad91e662ccfaa1b78f1c57badde74fc9d0b23e74aef644750ecd73/yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617", size = 107012, upload-time = "2026-05-19T21:29:36.216Z" }, - { url = "https://files.pythonhosted.org/packages/07/ab/9d4f69d571a94f4d112fa7e2e007200f5a54d319f58c82ac7b7baa61f5c6/yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92", size = 105887, upload-time = "2026-05-19T21:29:38.746Z" }, - { url = "https://files.pythonhosted.org/packages/8e/9a/000b2b66c0d772a499fc531d21dab92dfeb73b640a12eed6ba89f49bb2d0/yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a", size = 103620, upload-time = "2026-05-19T21:29:40.368Z" }, - { url = "https://files.pythonhosted.org/packages/41/7c/7c1050f73450fbdaa3f0c72017059f00ce5e13366692f3dba25275a1083d/yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44", size = 100599, upload-time = "2026-05-19T21:29:42.66Z" }, - { url = "https://files.pythonhosted.org/packages/ec/b1/29e5756b3926705f5f6089bd5b9f50a56eaac550da6e260bf713ead44d04/yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a", size = 110604, upload-time = "2026-05-19T21:29:44.632Z" }, - { url = "https://files.pythonhosted.org/packages/a3/4b/8415bc96e9b150cde942fbac9a8182985e58f40ce5c54c34ed015407d3ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf", size = 105161, upload-time = "2026-05-19T21:29:46.755Z" }, - { url = "https://files.pythonhosted.org/packages/8b/d4/cde059abfa229553b7298a2eadde2752e723d50aeedaef86ce59da2718ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056", size = 110619, upload-time = "2026-05-19T21:29:48.972Z" }, - { url = "https://files.pythonhosted.org/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992", size = 107362, upload-time = "2026-05-19T21:29:51Z" }, - { url = "https://files.pythonhosted.org/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656", size = 92667, upload-time = "2026-05-19T21:29:52.743Z" }, - { url = "https://files.pythonhosted.org/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461", size = 87069, upload-time = "2026-05-19T21:29:54.442Z" }, - { url = "https://files.pythonhosted.org/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2", size = 129670, upload-time = "2026-05-19T21:29:56.631Z" }, - { url = "https://files.pythonhosted.org/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630", size = 91916, upload-time = "2026-05-19T21:29:58.645Z" }, - { url = "https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8", size = 91625, upload-time = "2026-05-19T21:30:00.412Z" }, - { url = "https://files.pythonhosted.org/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14", size = 104574, upload-time = "2026-05-19T21:30:02.544Z" }, - { url = "https://files.pythonhosted.org/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535", size = 97534, upload-time = "2026-05-19T21:30:04.319Z" }, - { url = "https://files.pythonhosted.org/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14", size = 111481, upload-time = "2026-05-19T21:30:05.988Z" }, - { url = "https://files.pythonhosted.org/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3", size = 111529, upload-time = "2026-05-19T21:30:07.738Z" }, - { url = "https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208", size = 107338, upload-time = "2026-05-19T21:30:09.713Z" }, - { url = "https://files.pythonhosted.org/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50", size = 106147, upload-time = "2026-05-19T21:30:11.365Z" }, - { url = "https://files.pythonhosted.org/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd", size = 104272, upload-time = "2026-05-19T21:30:12.978Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67", size = 99962, upload-time = "2026-05-19T21:30:15.001Z" }, - { url = "https://files.pythonhosted.org/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1", size = 111063, upload-time = "2026-05-19T21:30:16.683Z" }, - { url = "https://files.pythonhosted.org/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1", size = 105438, upload-time = "2026-05-19T21:30:18.769Z" }, - { url = "https://files.pythonhosted.org/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b", size = 111458, upload-time = "2026-05-19T21:30:21.024Z" }, - { url = "https://files.pythonhosted.org/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8", size = 107589, upload-time = "2026-05-19T21:30:23.412Z" }, - { url = "https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0", size = 94424, upload-time = "2026-05-19T21:30:25.425Z" }, - { url = "https://files.pythonhosted.org/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57", size = 88690, upload-time = "2026-05-19T21:30:27.623Z" }, - { url = "https://files.pythonhosted.org/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b", size = 136248, upload-time = "2026-05-19T21:30:29.297Z" }, - { url = "https://files.pythonhosted.org/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761", size = 95084, upload-time = "2026-05-19T21:30:31.031Z" }, - { url = "https://files.pythonhosted.org/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8", size = 95272, upload-time = "2026-05-19T21:30:33.062Z" }, - { url = "https://files.pythonhosted.org/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed", size = 101497, upload-time = "2026-05-19T21:30:34.842Z" }, - { url = "https://files.pythonhosted.org/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543", size = 94002, upload-time = "2026-05-19T21:30:37.724Z" }, - { url = "https://files.pythonhosted.org/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0", size = 106524, upload-time = "2026-05-19T21:30:40.196Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024", size = 106165, upload-time = "2026-05-19T21:30:41.888Z" }, - { url = "https://files.pythonhosted.org/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf", size = 103010, upload-time = "2026-05-19T21:30:43.985Z" }, - { url = "https://files.pythonhosted.org/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc", size = 101128, upload-time = "2026-05-19T21:30:46.291Z" }, - { url = "https://files.pythonhosted.org/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb", size = 101382, upload-time = "2026-05-19T21:30:48.085Z" }, - { url = "https://files.pythonhosted.org/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420", size = 95964, upload-time = "2026-05-19T21:30:49.785Z" }, - { url = "https://files.pythonhosted.org/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f", size = 106204, upload-time = "2026-05-19T21:30:51.862Z" }, - { url = "https://files.pythonhosted.org/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa", size = 101510, upload-time = "2026-05-19T21:30:53.62Z" }, - { url = "https://files.pythonhosted.org/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe", size = 105584, upload-time = "2026-05-19T21:30:55.962Z" }, - { url = "https://files.pythonhosted.org/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd", size = 103410, upload-time = "2026-05-19T21:30:57.962Z" }, - { url = "https://files.pythonhosted.org/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215", size = 102980, upload-time = "2026-05-19T21:30:59.735Z" }, - { url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219, upload-time = "2026-05-19T21:31:01.934Z" }, - { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, -] diff --git a/workbench/network/campus-net/workflows/build.example.yml b/workbench/network/campus-net/workflows/build.example.yml index 01f4a3b..e323e27 100644 --- a/workbench/network/campus-net/workflows/build.example.yml +++ b/workbench/network/campus-net/workflows/build.example.yml @@ -1,110 +1,83 @@ -name: Build and Release EXE +name: "[Project] Campus Net Build Example" on: workflow_dispatch: - inputs: - version: - description: 'Release version (e.g. 1.2.3 or v1.2.3)' - required: true - default: '1.0.0' permissions: - contents: write + contents: read jobs: - build-and-release: + build: runs-on: windows-latest + timeout-minutes: 20 defaults: run: working-directory: workbench/network/campus-net + steps: - - name: Checkout repository (fetch tags) - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: - fetch-depth: 0 - persist-credentials: true + persist-credentials: false - - name: Setup Python - uses: actions/setup-python@v4 + - name: Setup uv and Python + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: - python-version: '3.11' + enable-cache: true + cache-dependency-glob: workbench/network/campus-net/pyproject.toml + python-version: "3.13" - - name: Install uv and project dependencies - shell: pwsh - run: | - python -m pip install uv - uv sync --group dev + - name: Install project dependencies + run: uv sync --group dev - - name: Build with PyInstaller (uses build.spec at repo root) - shell: pwsh - run: | - uv run pyinstaller --clean --noconfirm build.spec + - name: Run tests + run: '& .\.venv\Scripts\python.exe -m unittest discover -s tests -p "test_*.py" -v' - - name: Find built .exe - id: find_exe - shell: pwsh + - name: Build CLI and GUI run: | - $exe = Get-ChildItem -Path dist -Recurse -Filter *.exe -File | Select-Object -First 1 - if (-not $exe) { - Write-Error "No .exe found in dist directory. Build may have failed." - exit 1 - } - echo "exe_path=$($exe.FullName)" >> $env:GITHUB_OUTPUT - echo "exe_name=$($exe.Name)" >> $env:GITHUB_OUTPUT - Write-Host "Found exe: $($exe.FullName)" + & .\.venv\Scripts\python.exe -m PyInstaller --clean --noconfirm build.spec + & .\.venv\Scripts\python.exe -m PyInstaller --clean --noconfirm gui.spec - - name: Compose tag name (prepend v if needed) - id: compose_tag + - name: Verify executables and GUI resources shell: pwsh run: | - $input = "${{ github.event.inputs.version }}" - if ($input.StartsWith('v')) { - $tag = $input - } else { - $tag = "v$input" + $cli = Resolve-Path -LiteralPath "dist/Auto-Connect-CampusNet.exe" -ErrorAction Stop + $gui = Resolve-Path -LiteralPath "dist/Auto-Connect-CampusNet-GUI.exe" -ErrorAction Stop + $smokeConfig = Join-Path $env:RUNNER_TEMP "campus-net-gui-smoke.json" + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $gui.Path + $startInfo.Arguments = "--smoke-test --config=`"$smokeConfig`"" + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $process = [Diagnostics.Process]::Start($startInfo) + if (-not $process.WaitForExit(60000)) { + $process.Kill() + $process.WaitForExit() + throw "GUI smoke test timed out" } - echo "TAG=$tag" >> $env:GITHUB_ENV - Write-Host "Computed TAG: $tag" - - - name: Ensure tag does not already exist && create & push tag - shell: pwsh - env: - TAG: ${{ env.TAG }} - run: | - Write-Host "Checking if tag $env:TAG exists on remote..." - $exists = git ls-remote --tags origin $env:TAG - if ($exists) { - Write-Error "Tag $env:TAG already exists on remote. Aborting to avoid overwrite." - exit 1 + $exitCode = $process.ExitCode + $process.Dispose() + if ($exitCode -ne 0) { + throw "GUI smoke test failed with exit code $exitCode" } - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git tag -a $env:TAG -m "Release $env:TAG" - git push origin $env:TAG - Write-Host "Tag $env:TAG created and pushed." - - - name: Authenticate gh CLI (using GITHUB_TOKEN) and create release - shell: pwsh - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG: ${{ env.TAG }} - run: | - # gh is preinstalled on GH-hosted runners; authenticate with token - echo $env:GITHUB_TOKEN | gh auth login --with-token - # create a release for the tag - gh release create $env:TAG --title "$env:TAG" --notes "Release $env:TAG" - Write-Host "Created release $env:TAG" - - - name: Upload EXE as release asset (gh) - shell: pwsh - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - EXE_PATH: ${{ steps.find_exe.outputs.exe_path }} - TAG: ${{ env.TAG }} - run: | - if (-not (Test-Path $env:EXE_PATH)) { - Write-Error "EXE not found: $env:EXE_PATH" - exit 1 + if (Test-Path -LiteralPath $smokeConfig) { + throw "GUI smoke test unexpectedly created $smokeConfig" + } + Start-Sleep -Milliseconds 500 + $remaining = @(Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $gui.Path }) + if ($remaining.Count -ne 0) { + throw "GUI smoke test left $($remaining.Count) process(es) running" } - gh release upload $env:TAG $env:EXE_PATH - Write-Host "Uploaded asset: $env:EXE_PATH" + Get-FileHash -Algorithm SHA256 -LiteralPath $cli, $gui | + ForEach-Object { "$($_.Hash.ToLowerInvariant()) $([IO.Path]::GetFileName($_.Path))" } | + Set-Content -LiteralPath "dist/SHA256SUMS.txt" -Encoding utf8NoBOM + + - name: Upload validated build artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: campus-net-windows-${{ github.sha }} + if-no-files-found: error + path: | + workbench/network/campus-net/dist/Auto-Connect-CampusNet.exe + workbench/network/campus-net/dist/Auto-Connect-CampusNet-GUI.exe + workbench/network/campus-net/dist/SHA256SUMS.txt