From 8b0f7d7c1b0360e8e932b57a2532a68b38e05a00 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 23 Jul 2026 12:37:49 -0700 Subject: [PATCH 01/15] feat: bundle OpenAdapt Flow 1.20.0 --- docs/BETA_NATIVE_INSTALLERS.md | 2 +- engine/main.py | 2 +- pyproject.toml | 8 +- scripts/smoke_test_native_installer.py | 26 ++- scripts/verify_build_artifact.py | 18 +- tests/test_native_installer_smoke.py | 8 +- tests/test_public_metadata.py | 19 ++- uv.lock | 221 +++++++++++++++++++++++-- 8 files changed, 278 insertions(+), 26 deletions(-) diff --git a/docs/BETA_NATIVE_INSTALLERS.md b/docs/BETA_NATIVE_INSTALLERS.md index 71a72cb..98539bc 100644 --- a/docs/BETA_NATIVE_INSTALLERS.md +++ b/docs/BETA_NATIVE_INSTALLERS.md @@ -10,7 +10,7 @@ only the fixed `openadapt://connect` action and forwards it to the sidecar's strict, transactional pairing flow. The canonical compiler and governed runtime remain in `openadapt-flow`. Each -native installer freezes the exact `openadapt-flow==1.19.0` runtime and its +native installer freezes the exact `openadapt-flow==1.20.0` runtime and its `playwright==1.61.0` browser automation dependency into the Desktop sidecar. Compile, replay, run, and teach therefore work without a separate Python, `openadapt-flow`, or `playwright` installation on `PATH`. The first browser diff --git a/engine/main.py b/engine/main.py index b004797..9241d41 100644 --- a/engine/main.py +++ b/engine/main.py @@ -55,7 +55,7 @@ def _normalize_flow_auto_scrub_capability() -> None: Desktop includes ``openadapt-privacy`` for its local review pipeline, but its heavyweight Presidio/spaCy extra is intentionally optional. Merely - importing the provider makes Flow 1.19 think the capability is ready; the + importing the provider makes Flow think the capability is ready; the first scrub then crashes. In ``auto`` mode only, treat an incomplete extra exactly like an absent extra (local plaintext, as Flow documents). Explicit ``SCRUB=on`` is never changed and therefore still fails closed. diff --git a/pyproject.toml b/pyproject.toml index f5b794f..ed177ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ authors = [ ] keywords = ["desktop", "recording", "workflow-authoring", "human-in-the-loop", "automation"] classifiers = [ - "Development Status :: 2 - Pre-Alpha", + "Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", @@ -25,8 +25,8 @@ classifiers = [ ] dependencies = [ - "openadapt-capture>=0.3.0", - "openadapt-privacy>=0.1.0", + "openadapt-capture>=1.0.1", + "openadapt-privacy>=1.0.0", "pydantic>=2.0", "pydantic-settings>=2.0", "httpx>=0.27", @@ -46,7 +46,7 @@ build = [ # The native installer freezes this exact canonical runtime into the # engine sidecar. Keep it exact: Desktop and Flow can release # independently, while each installer remains reproducible and rollbackable. - "openadapt-flow==1.19.0", + "openadapt-flow==1.20.0", # ONNX Runtime 1.27 dropped macOS Intel wheels. Flow's RapidOCR dependency # accepts this universal2 release, which retains Python 3.12 Intel support. "onnxruntime==1.20.1; sys_platform == 'darwin' and platform_machine == 'x86_64'", diff --git a/scripts/smoke_test_native_installer.py b/scripts/smoke_test_native_installer.py index d9e1376..14b3b98 100644 --- a/scripts/smoke_test_native_installer.py +++ b/scripts/smoke_test_native_installer.py @@ -30,10 +30,13 @@ import sys import tempfile import time +import tomllib from dataclasses import dataclass from pathlib import Path from typing import Callable, Iterable, Sequence +ROOT = Path(__file__).resolve().parents[1] + SIGNING_MODES = ( "unsigned", "adhoc", @@ -43,6 +46,27 @@ ) +def bundled_flow_version(root: Path = ROOT) -> str: + """Return the single exact Flow version frozen into native installers.""" + + pyproject = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8")) + dependencies = pyproject["project"]["optional-dependencies"]["build"] + pins = [ + dependency.removeprefix("openadapt-flow==") + for dependency in dependencies + if dependency.startswith("openadapt-flow==") + ] + if len(pins) != 1 or not re.fullmatch(r"\d+\.\d+\.\d+", pins[0]): + raise SmokeTestError(f"expected one exact openadapt-flow build pin, found: {pins}") + return pins[0] + + +def bundled_flow_banner(root: Path = ROOT) -> str: + """Return the exact CLI version banner expected from the frozen runtime.""" + + return f"openadapt-flow {bundled_flow_version(root)}" + + class SmokeTestError(RuntimeError): """Raised when an installer does not satisfy the lifecycle contract.""" @@ -215,7 +239,7 @@ def _verify_macos_embedded_flow_runtime(app_path: Path, *, timeout: float) -> No timeout=max(timeout, 60.0), ) output = _combined_output(result) - if "openadapt-flow 1.19.0" not in output: + if bundled_flow_banner() not in output: raise SmokeTestError(f"installed engine has the wrong Flow runtime: {_tail(output)}") diff --git a/scripts/verify_build_artifact.py b/scripts/verify_build_artifact.py index ea41bdc..96fddba 100644 --- a/scripts/verify_build_artifact.py +++ b/scripts/verify_build_artifact.py @@ -8,6 +8,7 @@ import subprocess import sys import tarfile +import tomllib import zipfile from pathlib import Path @@ -27,6 +28,21 @@ ) +def bundled_flow_banner(root: Path = ROOT) -> str: + """Return the CLI version banner for the exact configured Flow build pin.""" + + pyproject = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8")) + dependencies = pyproject["project"]["optional-dependencies"]["build"] + pins = [ + dependency.removeprefix("openadapt-flow==") + for dependency in dependencies + if dependency.startswith("openadapt-flow==") + ] + if len(pins) != 1 or not re.fullmatch(r"\d+\.\d+\.\d+", pins[0]): + raise ValueError(f"expected one exact openadapt-flow build pin, found: {pins}") + return f"openadapt-flow {pins[0]}" + + def normalized_inventory(value: str) -> str: """Use one member separator for PyInstaller inventories on every OS.""" @@ -108,7 +124,7 @@ def main() -> int: check=False, ) flow_output = flow.stdout + flow.stderr - if flow.returncode != 0 or "openadapt-flow 1.19.0" not in flow_output: + if flow.returncode != 0 or bundled_flow_banner(args.root) not in flow_output: parser.error( "bundled Flow runtime smoke test failed with exit " f"{flow.returncode}: {flow_output[-1000:]}" diff --git a/tests/test_native_installer_smoke.py b/tests/test_native_installer_smoke.py index 3356a9e..132e0db 100644 --- a/tests/test_native_installer_smoke.py +++ b/tests/test_native_installer_smoke.py @@ -85,7 +85,7 @@ def fake_run( elif command[0] == "ditto": shutil.copytree(command[1], command[2]) elif command[0].endswith("openadapt-engine"): - return _completed(command, stdout="openadapt-flow 1.19.0\n") + return _completed(command, stdout=f"{smoke.bundled_flow_banner()}\n") return _completed(command) monkeypatch.setattr(smoke, "run_command", fake_run) @@ -117,7 +117,7 @@ def fake_run( if command[:3] == ["codesign", "--display", "--verbose=4"]: return _completed(command, stderr="Signature=adhoc\nTeamIdentifier=not set\n") if command[0].endswith("openadapt-engine"): - return _completed(command, stdout="openadapt-flow 1.19.0\n") + return _completed(command, stdout=f"{smoke.bundled_flow_banner()}\n") return _completed(command) monkeypatch.setattr(smoke, "run_command", fake_run) @@ -159,7 +159,7 @@ def fake_run( ), ) if command[0].endswith("openadapt-engine"): - return _completed(command, stdout="openadapt-flow 1.19.0\n") + return _completed(command, stdout=f"{smoke.bundled_flow_banner()}\n") return _completed(command) monkeypatch.setattr(smoke, "run_command", fake_run) @@ -559,7 +559,7 @@ def fake_run( elif command[0] == "ditto": shutil.copytree(command[1], command[2]) elif command[0].endswith("openadapt-engine"): - return _completed(command, stdout="openadapt-flow 1.19.0\n") + return _completed(command, stdout=f"{smoke.bundled_flow_banner()}\n") return _completed(command) def fake_probe( diff --git a/tests/test_public_metadata.py b/tests/test_public_metadata.py index db6b0cc..f4d4f81 100644 --- a/tests/test_public_metadata.py +++ b/tests/test_public_metadata.py @@ -6,6 +6,8 @@ from pathlib import Path from scripts.check_release_consistency import release_versions, sync_lock_version +from scripts.smoke_test_native_installer import bundled_flow_version +from scripts.verify_build_artifact import bundled_flow_banner ROOT = Path(__file__).resolve().parents[1] @@ -147,13 +149,26 @@ def test_beta_release_notes_describe_the_bundled_flow_runtime() -> None: lock = (ROOT / "uv.lock").read_text() native_release = (ROOT / ".github/workflows/native-release.yml").read_text() build_dependencies = pyproject["project"]["optional-dependencies"]["build"] + dependencies = pyproject["project"]["dependencies"] + classifiers = pyproject["project"]["classifiers"] assert not (ROOT / "docs/EXPERIMENTAL_NATIVE_INSTALLERS.md").exists() assert native_release.count("--notes-file docs/BETA_NATIVE_INSTALLERS.md") == 2 assert "EXPERIMENTAL_NATIVE_INSTALLERS" not in native_release - assert "openadapt-flow==1.19.0" in build_dependencies + flow_dependencies = [ + dependency + for dependency in build_dependencies + if dependency.startswith("openadapt-flow==") + ] + assert flow_dependencies == ["openadapt-flow==1.20.0"] + assert "openadapt-capture>=1.0.1" in dependencies + assert "openadapt-privacy>=1.0.0" in dependencies + assert "Development Status :: 4 - Beta" in classifiers + assert "Development Status :: 2 - Pre-Alpha" not in classifiers + assert bundled_flow_version() == "1.20.0" + assert bundled_flow_banner() == "openadapt-flow 1.20.0" assert 'name = "playwright"\nversion = "1.61.0"' in lock - assert "openadapt-flow==1.19.0" in notes + assert flow_dependencies[0] in notes assert "playwright==1.61.0" in notes assert "without a separate Python" in notes assert "not frozen into these installers" not in notes diff --git a/uv.lock b/uv.lock index cfcbd3e..21fcab0 100644 --- a/uv.lock +++ b/uv.lock @@ -8,6 +8,20 @@ resolution-markers = [ "(python_full_version >= '3.12' and platform_machine != 'x86_64') or (python_full_version >= '3.12' and sys_platform != 'darwin')", ] +[[package]] +name = "alembic" +version = "1.18.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/cc/ac0bed8e562e7407fe55c3ba85a4dce86e6dbd8730887bd1e406a6c5c18a/alembic-1.18.5.tar.gz", hash = "sha256:1554982221dd17e9a749b53902407578eb305e453f71999e8c7f0a48389fff8e", size = 2060480, upload-time = "2026-06-25T15:20:54.888Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/78/5fe6dc3a3a5b2f5a2a4faef8bfe336d5fa049a38884ab3172e0098160c01/alembic-1.18.5-py3-none-any.whl", hash = "sha256:06d8ba9d04558022f5395e9317de03d270f3dced49cee01f89fe7a13c26f14bc", size = 264664, upload-time = "2026-06-25T15:20:56.673Z" }, +] + [[package]] name = "altgraph" version = "0.17.5" @@ -420,6 +434,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, ] +[[package]] +name = "future" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/b2/4140c69c6a66432916b26158687e821ba631a4c9273c474343badf84d3ba/future-1.0.0.tar.gz", hash = "sha256:bd2968309307861edae1458a4f8a4f3598c03be43b97521076aebf5d94c07b05", size = 1228490, upload-time = "2024-02-21T11:52:38.461Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/71/ae30dadffc90b9006d77af76b393cb9dfbfc9629f339fc1574a1c52e6806/future-1.0.0-py3-none-any.whl", hash = "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216", size = 491326, upload-time = "2024-02-21T11:52:35.956Z" }, +] + [[package]] name = "gitdb" version = "4.0.12" @@ -815,6 +838,18 @@ 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 = "mako" +version = "1.3.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -1016,6 +1051,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/e5/b7d20451657664b07986c2f6e3be564433f5dcaf3482d68eaecd79afaf03/numpy-2.4.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be71bf1edb48ebbbf7f6337b5bfd2f895d1902f6335a5830b20141fc126ffba0", size = 12502577, upload-time = "2026-01-31T23:13:07.08Z" }, ] +[[package]] +name = "oa-atomacos" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "future" }, + { name = "pyautogui" }, + { name = "pygetwindow" }, + { name = "pyobjc-core" }, + { name = "pyobjc-framework-applicationservices" }, + { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-framework-coretext" }, + { name = "pyobjc-framework-quartz" }, + { name = "pyscreeze" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/fc/6af1e93224d042059b70586f228da82c0135da5f6c1dee5832c7d339ac66/oa_atomacos-3.2.0.tar.gz", hash = "sha256:6516fa7ad818fa990c2b678966f0821b808696ef2e756a35060b15d1b4e17578", size = 37702, upload-time = "2023-08-04T22:45:17.703Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/8d/664b0a60140afcc289df2a1e9aa2c01c0d6b0ec9f5a060a173316c4f8ffb/oa_atomacos-3.2.0-py2.py3-none-any.whl", hash = "sha256:76b9b78344afd313e8e456ca446f89560eb036342fbb6b68adaa53c4548411ac", size = 27211, upload-time = "2023-08-04T22:45:15.597Z" }, +] + [[package]] name = "onnxruntime" version = "1.20.1" @@ -1081,24 +1136,32 @@ wheels = [ [[package]] name = "openadapt-capture" -version = "0.3.0" +version = "1.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "alembic" }, { name = "av" }, { name = "fire" }, + { name = "loguru" }, { name = "mss" }, + { name = "numpy" }, + { name = "oa-atomacos", marker = "sys_platform == 'darwin'" }, { name = "openai" }, { name = "pillow" }, + { name = "psutil" }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "pympler" }, { name = "pynput" }, { name = "sounddevice" }, { name = "soundfile" }, + { name = "sqlalchemy" }, + { name = "tqdm" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8c/98/4bb20e7b11e48ddb9f0d022188f889344997e50fdfc151fee3d16bcde48a/openadapt_capture-0.3.0.tar.gz", hash = "sha256:5f04cb816f1e22b17e0f82f405ea4f00ec04642707161490aae1f3a8ff28286e", size = 10883594, upload-time = "2026-02-06T22:03:02.491Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/a7/2f084a8b807119c7d56c90b50261f6fa933f28d79496330c7b96c7bc38b4/openadapt_capture-1.0.1.tar.gz", hash = "sha256:51f5b47e7e7e06c03476f67dbab4f434fbd6b2e2fad3b0a5759059105aef647c", size = 11915649, upload-time = "2026-07-23T19:04:58.67Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/f6/ed19451455843e427bad1b81fe0c45fa241bf48e98780d15f00d5d2e5773/openadapt_capture-0.3.0-py3-none-any.whl", hash = "sha256:c58f663355510e9497af47c89a185d0e5cd7844481d0bff816ecd5e7039aeba1", size = 71644, upload-time = "2026-02-06T22:03:00.877Z" }, + { url = "https://files.pythonhosted.org/packages/88/a2/a30a5dd98f106a012d44072c6387934e4fbe9bd31bdb06171165f29c3daf/openadapt_capture-1.0.1-py3-none-any.whl", hash = "sha256:b0b73171ad619c534c24f61836a4c6fb3d0b91799e57e26c42b273d9b72039fe", size = 149087, upload-time = "2026-07-23T19:04:56.656Z" }, ] [[package]] @@ -1145,10 +1208,10 @@ requires-dist = [ { name = "loguru" }, { name = "onnxruntime", marker = "platform_machine == 'x86_64' and sys_platform == 'darwin' and extra == 'build'", specifier = "==1.20.1" }, { name = "onnxruntime", marker = "(platform_machine != 'x86_64' and extra == 'build') or (sys_platform != 'darwin' and extra == 'build')", specifier = "==1.27.0" }, - { name = "openadapt-capture", specifier = ">=0.3.0" }, + { name = "openadapt-capture", specifier = ">=1.0.1" }, { name = "openadapt-desktop", extras = ["enterprise"], marker = "extra == 'full'" }, - { name = "openadapt-flow", marker = "extra == 'build'", specifier = "==1.19.0" }, - { name = "openadapt-privacy", specifier = ">=0.1.0" }, + { name = "openadapt-flow", marker = "extra == 'build'", specifier = "==1.20.0" }, + { name = "openadapt-privacy", specifier = ">=1.0.0" }, { name = "psutil" }, { name = "pydantic", specifier = ">=2.0" }, { name = "pydantic-settings", specifier = ">=2.0" }, @@ -1163,7 +1226,7 @@ provides-extras = ["enterprise", "full", "build", "dev"] [[package]] name = "openadapt-flow" -version = "1.19.0" +version = "1.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, @@ -1178,23 +1241,23 @@ dependencies = [ { name = "pyyaml" }, { name = "rapidocr-onnxruntime" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/94/c5/2d9a5cf89db0d30a771e2035658eb844fe670972100d5f4818f3f0df68f2/openadapt_flow-1.19.0.tar.gz", hash = "sha256:64a2e00c0ef2c0dc292125e117b3dc152bb0970df416037c65c97a22f4a86a6d", size = 17206700, upload-time = "2026-07-19T23:51:01.039Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/ec/236c997059ca9c3d7df0ac9cb6a05c3cf899f8a14d8c9ae8963edd8bf112/openadapt_flow-1.20.0.tar.gz", hash = "sha256:7123e80b0fc6160ec499f4cdf5aa14777219561cdd8dd06dc18b8adea3971a9f", size = 18846003, upload-time = "2026-07-23T10:24:18.688Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/06/01e6e1b28a602131b0dc767eb55ac5629e57bba27d22b89943fed5d79966/openadapt_flow-1.19.0-py3-none-any.whl", hash = "sha256:23ab2850f67deefc4ec66b82dbbf2f8f52e412ea3ac6ff78c149097ae97cefe7", size = 980819, upload-time = "2026-07-19T23:50:59.231Z" }, + { url = "https://files.pythonhosted.org/packages/77/05/c848efc8ff4678d02580964a3eabab6d264b6cd518f823040787288c4af9/openadapt_flow-1.20.0-py3-none-any.whl", hash = "sha256:0dba8f53f54bda782c5290cbb046c8879e5baa9ed1f61cac9609440f3ab9690b", size = 1181510, upload-time = "2026-07-23T10:24:16.6Z" }, ] [[package]] name = "openadapt-privacy" -version = "0.1.1" +version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pillow" }, { name = "pip" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0c/b9/b4b19c7b0664ba2834f1da046d8ba7fce4cbddf7014969f548fea07e09b7/openadapt_privacy-0.1.1.tar.gz", hash = "sha256:7e0b2a3ed67052c553b598cea6a99d5500f6364bce6c7ef21bf8cb639539b38b", size = 48994, upload-time = "2026-01-29T23:02:36.993Z" } +sdist = { url = "https://files.pythonhosted.org/packages/80/65/fb6ea671d07984a99d7f3f147e162d16428454185e5f84c4d7dd3d12d84f/openadapt_privacy-1.0.0.tar.gz", hash = "sha256:6756fd0e4df68df98217f8d8fa0cd67e9b8879610bb0822209656965e19b3d7d", size = 53443, upload-time = "2026-07-15T21:53:51.812Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/63/1d/fa04ec57de5fe41db5072430e418371411b79187b700ee8389e67c27714e/openadapt_privacy-0.1.1-py3-none-any.whl", hash = "sha256:1737ab682baa17773c4f7b83d88aee0549040b7144b26b2391d0a182c42d89a1", size = 15204, upload-time = "2026-01-29T23:02:35.901Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fd/7bf1e621f372bbd620b272d3c1504b01e6ad60959322115ea35e198a0f80/openadapt_privacy-1.0.0-py3-none-any.whl", hash = "sha256:52fdf09c48466cd02d336e1129ad17d4e528e7cf06a484ba00c1a2aed06ad818", size = 17085, upload-time = "2026-07-15T21:53:50.598Z" }, ] [[package]] @@ -1439,6 +1502,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, ] +[[package]] +name = "pyautogui" +version = "0.9.41" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pillow" }, + { name = "pygetwindow" }, + { name = "pymsgbox" }, + { name = "pyscreeze" }, + { name = "pytweening" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/ef/438d80abd396fd2d124bd37c07c765f913723c54197c4c809d85c8ff5a43/PyAutoGUI-0.9.41.tar.gz", hash = "sha256:1b57b1c8eeeff74bdbbe91162607ca7ac951e918b250072dcd3dd30c3f73da7c", size = 50145, upload-time = "2019-01-07T19:10:15.883Z" } + [[package]] name = "pyclipper" version = "1.4.0" @@ -1623,6 +1699,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, ] +[[package]] +name = "pygetwindow" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyrect" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/ed/56d4a369c6e18f6b239d9ef37b3222ba308bfebf949571b2611ff7d64f1d/PyGetWindow-0.0.4.tar.gz", hash = "sha256:e51e173f2ce8a6a9260fde7f0cf4d15df99ea0d622fad67a400f3e9d6a8a67f2", size = 8898, upload-time = "2019-01-29T02:11:49.715Z" } + [[package]] name = "pygments" version = "2.19.2" @@ -1672,6 +1757,27 @@ 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 = "pympler" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/37/c384631908029676d8e7213dd956bb686af303a80db7afbc9be36bc49495/pympler-1.1.tar.gz", hash = "sha256:1eaa867cb8992c218430f1708fdaccda53df064144d1c5656b1e6f1ee6000424", size = 179954, upload-time = "2024-06-28T19:56:06.563Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/4f/a6a2e2b202d7fd97eadfe90979845b8706676b41cbd3b42ba75adf329d1f/Pympler-1.1-py3-none-any.whl", hash = "sha256:5b223d6027d0619584116a0cbc28e8d2e378f7a79c1e5e024f9ff3b673c58506", size = 165766, upload-time = "2024-06-28T19:56:05.087Z" }, +] + +[[package]] +name = "pymsgbox" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/6a/e80da7594ee598a776972d09e2813df2b06b3bc29218f440631dfa7c78a8/pymsgbox-2.0.1.tar.gz", hash = "sha256:98d055c49a511dcc10fa08c3043e7102d468f5e4b3a83c6d3c61df722c7d798d", size = 20768, upload-time = "2025-09-09T00:38:56.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/3e/08c8cac81b2b2f7502746e6b9c8e5b0ec6432cd882c605560fc409aaf087/pymsgbox-2.0.1-py3-none-any.whl", hash = "sha256:5de8ec19bca2ca7e6c09d39c817c83f17c75cee80275235f43a9931db699f73b", size = 9994, upload-time = "2025-09-09T00:38:55.672Z" }, +] + [[package]] name = "pynput" version = "1.8.1" @@ -1776,6 +1882,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4d/a6/708a55f3ff7a18c403b30a29a11dccfed0410485a7548c60a4b6d4cc0676/pyobjc_framework_quartz-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0cc08fddb339b2760df60dea1057453557588908e42bdc62184b6396ce2d6e9a", size = 224580, upload-time = "2025-11-14T10:01:00.091Z" }, ] +[[package]] +name = "pyrect" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/04/2ba023d5f771b645f7be0c281cdacdcd939fe13d1deb331fc5ed1a6b3a98/PyRect-0.2.0.tar.gz", hash = "sha256:f65155f6df9b929b67caffbd57c0947c5ae5449d3b580d178074bffb47a09b78", size = 17219, upload-time = "2022-03-16T04:45:52.36Z" } + +[[package]] +name = "pyscreeze" +version = "0.1.19" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/27/073bf07400943e38b06ba40def60ec489d114fd7356c2db5a2f793454312/PyScreeze-0.1.19.tar.gz", hash = "sha256:9dfa4ff43d39538f12d4aa2ace446ede87f91a64b486cc819e5f72ba87aaa994", size = 21048, upload-time = "2019-01-05T18:56:07.015Z" } + [[package]] name = "pytest" version = "9.0.2" @@ -1875,6 +1996,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fc/b8/ff33610932e0ee81ae7f1269c890f697d56ff74b9f5b2ee5d9b7fa2c5355/python_xlib-0.33-py2.py3-none-any.whl", hash = "sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398", size = 182185, upload-time = "2022-12-25T18:52:58.662Z" }, ] +[[package]] +name = "pytweening" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/0c/c16bc93ac2755bac0066a8ecbd2a2931a1735a6fffd99a2b9681c7e83e90/pytweening-1.2.0.tar.gz", hash = "sha256:243318b7736698066c5f362ec5c2b6434ecf4297c3c8e7caa8abfe6af4cac71b", size = 171241, upload-time = "2024-02-20T03:37:56.809Z" } + [[package]] name = "pywavelets" version = "1.9.0" @@ -1934,6 +2061,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/d2/a8065103f5e2e613b916489e6c85af6402a1ec64f346d1429e2d32cb8d03/pywavelets-1.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3b6ff6ba4f625d8c955f68c2c39b0a913776d406ab31ee4057f34ad4019fb33b", size = 4306793, upload-time = "2025-08-04T16:20:02.934Z" }, ] +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + [[package]] name = "pywin32-ctypes" version = "0.2.3" @@ -2318,6 +2467,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/e9/6b761de83277f2f02ded7e7ea6f07828ec78e4b229b80e4ca55dd205b9dc/soundfile-0.13.1-py2.py3-none-win_amd64.whl", hash = "sha256:1e70a05a0626524a69e9f0f4dd2ec174b4e9567f4d8b6c11d38b5c289be36ee9", size = 1019162, upload-time = "2025-01-25T09:16:59.573Z" }, ] +[[package]] +name = "sqlalchemy" +version = "2.0.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/69/a67c69e5f28fc9c99d6f7bd60bd50e91f2fed2423e3b30fb228fa00e51f3/sqlalchemy-2.0.51-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1aa10c0daee6705294d181daadaa793221e1a59ed55000a3fab1d42b088ce4ba", size = 2161838, upload-time = "2026-06-15T16:05:17.144Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/c8c22b8438bddc0a030157c6ec0f6ef97b3c38effa444bdab2a27af04090/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5b2ed6d828f1f09bd812861f4f59ca3bc3803f9df871f4555187f0faf018604", size = 3319402, upload-time = "2026-06-15T16:10:40.002Z" }, + { url = "https://files.pythonhosted.org/packages/90/54/44012d32fd77d991256d2ff793ba3807c51d40cb27a85b4796224f6744df/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:436728ce18a80f6951a1e11cc6112c2ede9faf20766f1a26195a7c441ca12dbd", size = 3319675, upload-time = "2026-06-15T16:12:25.658Z" }, + { url = "https://files.pythonhosted.org/packages/29/a5/de0592acaf5906cd7430874392d6f7e8b4a7c8437610953ee2d1501c0b44/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dc261707bf5739aea8a541593f3cc1d463c2701fb05fbcbba0ce031b69a21260", size = 3270777, upload-time = "2026-06-15T16:10:42.125Z" }, + { url = "https://files.pythonhosted.org/packages/cb/14/a44c90739c780b362238e4ac3cb19dd0ca40d13e6ddc5daa112166ddab4f/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a6d26094615306d116dd5e4a51b0304c99dd2356fc569eed6922a80a6bd3b265", size = 3293940, upload-time = "2026-06-15T16:12:27.156Z" }, + { url = "https://files.pythonhosted.org/packages/65/eb/fbd0f206a330e66f8c602a99c37c4e731f107faed62954b41b01f16dd9d9/sqlalchemy-2.0.51-cp311-cp311-win32.whl", hash = "sha256:ca8435d13829b92f4a97362d91975154a4015db3a2634154e1754e9a915e6b86", size = 2121183, upload-time = "2026-06-15T16:13:29.905Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fd/005bf80f3cf6e5c62b5dd68616280f51cd012c60840fa74781b3ed7b1623/sqlalchemy-2.0.51-cp311-cp311-win_amd64.whl", hash = "sha256:4a011ea4510683319ce4ed274b56ee05194b39b6da9d09ca7a39388f0fa84dcc", size = 2145796, upload-time = "2026-06-15T16:13:31.283Z" }, + { url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" }, + { url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" }, + { url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" }, + { url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" }, + { url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" }, + { url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055, upload-time = "2026-06-15T16:19:49.286Z" }, + { url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850, upload-time = "2026-06-15T16:26:43.017Z" }, + { url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721, upload-time = "2026-06-15T16:23:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615, upload-time = "2026-06-15T16:23:13.906Z" }, + { url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" }, + { url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" }, + { url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" }, + { url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" }, +] + [[package]] name = "sympy" version = "1.14.0" From 602d2d53e392db2bbeda72a076e87829d58195b5 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 23 Jul 2026 12:46:57 -0700 Subject: [PATCH 02/15] fix: refuse false successful native recordings --- engine/controller.py | 86 ++++++++++++++++---- tests/conftest.py | 30 +++++++ tests/test_capture_runtime_contract.py | 18 +++++ tests/test_engine/test_controller.py | 108 +++++++++++++++++++++++++ 4 files changed, 225 insertions(+), 17 deletions(-) create mode 100644 tests/test_capture_runtime_contract.py diff --git a/engine/controller.py b/engine/controller.py index 16746c5..7e22057 100644 --- a/engine/controller.py +++ b/engine/controller.py @@ -38,6 +38,22 @@ from engine.storage_manager import StorageManager +def _load_capture_recorder(): + """Load the native recorder or fail before claiming a recording started.""" + + try: + from openadapt_capture import Recorder + except ImportError as exc: + raise RuntimeError( + "OpenAdapt Capture could not be imported; reinstall OpenAdapt Desktop" + ) from exc + if Recorder is None: + raise RuntimeError( + "OpenAdapt Capture's native recorder is unavailable on this system" + ) + return Recorder + + def _now_iso() -> str: return datetime.now(timezone.utc).isoformat() @@ -140,26 +156,41 @@ def start(self, quality: str | None = None, task_description: str = "") -> str: } (capture_dir / "meta.json").write_text(json.dumps(meta, indent=2)) - # Write state.json - state = {"status": "recording", "started_at": started_at, "capture_id": capture_id} - (capture_dir / "state.json").write_text(json.dumps(state, indent=2)) + state_path = capture_dir / "state.json" + state = {"status": "starting", "started_at": started_at, "capture_id": capture_id} + state_path.write_text(json.dumps(state, indent=2)) - # Try to start openadapt-capture Recorder + recorder = None + entered = False try: - from openadapt_capture import Recorder - self._recorder = Recorder(output_dir=str(capture_dir)) - self._recorder.start() - except ImportError: - # openadapt-capture not installed -- record metadata only - self._recorder = None - except Exception: - # Recorder failed (e.g., no display) -- still track the session + Recorder = _load_capture_recorder() + recorder = Recorder(str(capture_dir), task_description=task_description) + recorder.__enter__() + entered = True + if not recorder.wait_for_ready(timeout=60): + raise RuntimeError("OpenAdapt Capture did not become ready within 60 seconds") + if not recorder.is_recording: + raise RuntimeError("OpenAdapt Capture stopped before recording became ready") + except Exception as exc: + if entered and recorder is not None: + try: + recorder.stop() + recorder.__exit__(type(exc), exc, exc.__traceback__) + except Exception: + logger.exception("Recorder cleanup failed after start error") + state.update({"status": "failed", "failed_at": _now_iso(), "error": str(exc)}) + state_path.write_text(json.dumps(state, indent=2)) self._recorder = None + self.state = RecordingState.IDLE + raise RuntimeError(f"Could not start native recording: {exc}") from exc + self._recorder = recorder self._current_capture_id = capture_id self._capture_dir = capture_dir self._started_at = started_at self.state = RecordingState.RECORDING + state["status"] = "recording" + state_path.write_text(json.dumps(state, indent=2)) # Register in DB if storage_manager available if self._storage_manager: @@ -182,13 +213,34 @@ def stop(self) -> dict: stopped_at = _now_iso() event_count = 0 - # Stop the recorder - if self._recorder is not None: + recorder = self._recorder + if recorder is None: + raise RuntimeError("Recording state is active but the native recorder is missing") + + try: + recorder.stop() + recorder.__exit__(None, None, None) + event_count = getattr(recorder, "event_count", 0) or 0 + except Exception as exc: try: - self._recorder.stop() - event_count = getattr(self._recorder, "event_count", 0) or 0 + recorder.__exit__(type(exc), exc, exc.__traceback__) except Exception: - pass + logger.exception("Recorder cleanup failed after stop error") + if self._capture_dir: + state = { + "status": "failed", + "started_at": self._started_at, + "failed_at": _now_iso(), + "capture_id": self._current_capture_id, + "error": str(exc), + } + (self._capture_dir / "state.json").write_text(json.dumps(state, indent=2)) + self._current_capture_id = None + self._capture_dir = None + self._started_at = None + self._recorder = None + self.state = RecordingState.IDLE + raise RuntimeError(f"Could not stop native recording cleanly: {exc}") from exc # Calculate duration duration = 0.0 diff --git a/tests/conftest.py b/tests/conftest.py index bb1aa58..9696142 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,6 +9,36 @@ from engine.config import EngineConfig +class FakeRecorder: + """Inert recorder used by unit tests; live capture is contract-tested separately.""" + + def __init__(self, capture_dir: str, task_description: str = "") -> None: + self.capture_dir = capture_dir + self.task_description = task_description + self.event_count = 0 + self.is_recording = False + + def __enter__(self): + self.is_recording = True + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self.is_recording = False + + def wait_for_ready(self, timeout: float = 60) -> bool: + return self.is_recording + + def stop(self) -> None: + self.is_recording = False + + +@pytest.fixture(autouse=True) +def fake_native_recorder(monkeypatch): + """Prevent unit tests from injecting real mouse/keyboard/screen capture.""" + + monkeypatch.setattr("engine.controller._load_capture_recorder", lambda: FakeRecorder) + + @pytest.fixture def tmp_data_dir(tmp_path: Path) -> Path: """Create a temporary data directory for tests.""" diff --git a/tests/test_capture_runtime_contract.py b/tests/test_capture_runtime_contract.py new file mode 100644 index 0000000..b7fd694 --- /dev/null +++ b/tests/test_capture_runtime_contract.py @@ -0,0 +1,18 @@ +"""Contract between the Desktop engine and the installed Capture runtime.""" + +from __future__ import annotations + +import inspect + + +def test_installed_capture_exposes_native_recorder_contract() -> None: + """The packaged dependency must expose the recorder Desktop invokes.""" + + from openadapt_capture import Recorder + + assert Recorder is not None, "openadapt-capture installed without a usable Recorder" + parameters = inspect.signature(Recorder).parameters + assert "capture_dir" in parameters + assert "task_description" in parameters + for member in ("__enter__", "__exit__", "wait_for_ready", "stop", "event_count"): + assert hasattr(Recorder, member), f"Recorder is missing {member}" diff --git a/tests/test_engine/test_controller.py b/tests/test_engine/test_controller.py index 7be8d1f..351e187 100644 --- a/tests/test_engine/test_controller.py +++ b/tests/test_engine/test_controller.py @@ -34,6 +34,114 @@ def test_start_creates_capture_directory(self, tmp_data_dir: Path) -> None: assert (dirs[0] / "meta.json").exists() assert (dirs[0] / "state.json").exists() + def test_start_uses_capture_runtime_contract( + self, tmp_data_dir: Path, monkeypatch + ) -> None: + """Desktop must use Capture's real context-manager constructor contract.""" + + calls: dict[str, object] = {} + + class ContractRecorder: + event_count = 0 + is_recording = False + + def __init__(self, capture_dir: str, task_description: str = "") -> None: + calls["capture_dir"] = capture_dir + calls["task_description"] = task_description + + def __enter__(self): + self.is_recording = True + calls["entered"] = True + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self.is_recording = False + calls["exited"] = True + + def wait_for_ready(self, timeout: float = 60) -> bool: + calls["timeout"] = timeout + return True + + def stop(self) -> None: + calls["stopped"] = True + + monkeypatch.setattr( + "engine.controller._load_capture_recorder", lambda: ContractRecorder + ) + controller = RecordingController(captures_dir=tmp_data_dir / "captures") + + controller.start(task_description="Claims intake") + controller.stop() + + assert Path(str(calls["capture_dir"])).parent == tmp_data_dir / "captures" + assert calls["task_description"] == "Claims intake" + assert calls["entered"] is True + assert calls["timeout"] == 60 + assert calls["stopped"] is True + assert calls["exited"] is True + + def test_start_failure_is_explicit_and_not_recording( + self, tmp_data_dir: Path, monkeypatch + ) -> None: + """A missing native recorder must never become a metadata-only success.""" + + def unavailable(): + raise RuntimeError("native recorder unavailable") + + monkeypatch.setattr("engine.controller._load_capture_recorder", unavailable) + controller = RecordingController(captures_dir=tmp_data_dir / "captures") + + with pytest.raises(RuntimeError, match="Could not start native recording"): + controller.start() + + assert controller.state == RecordingState.IDLE + assert not controller.is_recording + assert controller.current_capture_id is None + capture_dir = next((tmp_data_dir / "captures").iterdir()) + state = json.loads((capture_dir / "state.json").read_text()) + assert state["status"] == "failed" + assert "native recorder unavailable" in state["error"] + + def test_stop_failure_is_explicit_and_not_completed( + self, tmp_data_dir: Path, monkeypatch + ) -> None: + """A recorder shutdown failure must not produce completed metadata.""" + + class BrokenStopRecorder: + event_count = 0 + is_recording = False + + def __init__(self, capture_dir: str, task_description: str = "") -> None: + pass + + def __enter__(self): + self.is_recording = True + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self.is_recording = False + + def wait_for_ready(self, timeout: float = 60) -> bool: + return True + + def stop(self) -> None: + raise RuntimeError("writer did not stop") + + monkeypatch.setattr( + "engine.controller._load_capture_recorder", lambda: BrokenStopRecorder + ) + controller = RecordingController(captures_dir=tmp_data_dir / "captures") + controller.start() + + with pytest.raises(RuntimeError, match="Could not stop native recording cleanly"): + controller.stop() + + assert controller.state == RecordingState.IDLE + capture_dir = next((tmp_data_dir / "captures").iterdir()) + state = json.loads((capture_dir / "state.json").read_text()) + assert state["status"] == "failed" + assert "writer did not stop" in state["error"] + def test_stop_returns_metadata(self, tmp_data_dir: Path) -> None: """Stopping a recording should return capture metadata.""" controller = RecordingController(captures_dir=tmp_data_dir / "captures") From d7e09ae8b031642b28c5226072fac874ddac632f Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 23 Jul 2026 12:48:21 -0700 Subject: [PATCH 03/15] fix: make recorder startup transactional --- engine/controller.py | 40 ++++++++++++--------- tests/test_engine/test_controller.py | 52 ++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 17 deletions(-) diff --git a/engine/controller.py b/engine/controller.py index 7e22057..f8d46e8 100644 --- a/engine/controller.py +++ b/engine/controller.py @@ -161,41 +161,47 @@ def start(self, quality: str | None = None, task_description: str = "") -> str: state_path.write_text(json.dumps(state, indent=2)) recorder = None - entered = False try: Recorder = _load_capture_recorder() recorder = Recorder(str(capture_dir), task_description=task_description) recorder.__enter__() - entered = True if not recorder.wait_for_ready(timeout=60): raise RuntimeError("OpenAdapt Capture did not become ready within 60 seconds") if not recorder.is_recording: raise RuntimeError("OpenAdapt Capture stopped before recording became ready") + + self._recorder = recorder + self._current_capture_id = capture_id + self._capture_dir = capture_dir + self._started_at = started_at + self.state = RecordingState.RECORDING + state["status"] = "recording" + state_path.write_text(json.dumps(state, indent=2)) + + if self._storage_manager: + self._storage_manager.register_capture(capture_id, capture_dir) except Exception as exc: - if entered and recorder is not None: + if recorder is not None: try: recorder.stop() + except Exception: + logger.exception("Recorder stop failed after start error") + try: recorder.__exit__(type(exc), exc, exc.__traceback__) except Exception: - logger.exception("Recorder cleanup failed after start error") + logger.exception("Recorder exit failed after start error") state.update({"status": "failed", "failed_at": _now_iso(), "error": str(exc)}) - state_path.write_text(json.dumps(state, indent=2)) + try: + state_path.write_text(json.dumps(state, indent=2)) + except Exception: + logger.exception("Could not persist failed recording state") + self._current_capture_id = None + self._capture_dir = None + self._started_at = None self._recorder = None self.state = RecordingState.IDLE raise RuntimeError(f"Could not start native recording: {exc}") from exc - self._recorder = recorder - self._current_capture_id = capture_id - self._capture_dir = capture_dir - self._started_at = started_at - self.state = RecordingState.RECORDING - state["status"] = "recording" - state_path.write_text(json.dumps(state, indent=2)) - - # Register in DB if storage_manager available - if self._storage_manager: - self._storage_manager.register_capture(capture_id, capture_dir) - return capture_id def stop(self) -> dict: diff --git a/tests/test_engine/test_controller.py b/tests/test_engine/test_controller.py index 351e187..ccb0686 100644 --- a/tests/test_engine/test_controller.py +++ b/tests/test_engine/test_controller.py @@ -142,6 +142,58 @@ def stop(self) -> None: assert state["status"] == "failed" assert "writer did not stop" in state["error"] + def test_registration_failure_stops_recorder_and_rolls_back_state( + self, tmp_data_dir: Path, monkeypatch + ) -> None: + """Startup remains transactional through local index registration.""" + + calls: dict[str, bool] = {} + + class TrackedRecorder: + event_count = 0 + is_recording = False + + def __init__(self, capture_dir: str, task_description: str = "") -> None: + pass + + def __enter__(self): + self.is_recording = True + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + calls["exited"] = True + self.is_recording = False + + def wait_for_ready(self, timeout: float = 60) -> bool: + return True + + def stop(self) -> None: + calls["stopped"] = True + self.is_recording = False + + class FailingStorageManager: + def register_capture(self, capture_id: str, capture_dir: Path) -> None: + raise RuntimeError("index unavailable") + + monkeypatch.setattr( + "engine.controller._load_capture_recorder", lambda: TrackedRecorder + ) + controller = RecordingController( + captures_dir=tmp_data_dir / "captures", + storage_manager=FailingStorageManager(), + ) + + with pytest.raises(RuntimeError, match="Could not start native recording"): + controller.start() + + assert calls == {"stopped": True, "exited": True} + assert controller.state == RecordingState.IDLE + assert controller.current_capture_id is None + capture_dir = next((tmp_data_dir / "captures").iterdir()) + state = json.loads((capture_dir / "state.json").read_text()) + assert state["status"] == "failed" + assert "index unavailable" in state["error"] + def test_stop_returns_metadata(self, tmp_data_dir: Path) -> None: """Stopping a recording should return capture metadata.""" controller = RecordingController(captures_dir=tmp_data_dir / "captures") From 0b7158adc5a7133e0e0a1c6424d2c8c9b6dca385 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 23 Jul 2026 12:50:25 -0700 Subject: [PATCH 04/15] fix: make recording finalization fail closed --- engine/controller.py | 162 ++++++++++++++------------- tests/test_engine/test_controller.py | 47 ++++++-- 2 files changed, 119 insertions(+), 90 deletions(-) diff --git a/engine/controller.py b/engine/controller.py index f8d46e8..cd52a52 100644 --- a/engine/controller.py +++ b/engine/controller.py @@ -48,9 +48,7 @@ def _load_capture_recorder(): "OpenAdapt Capture could not be imported; reinstall OpenAdapt Desktop" ) from exc if Recorder is None: - raise RuntimeError( - "OpenAdapt Capture's native recorder is unavailable on this system" - ) + raise RuntimeError("OpenAdapt Capture's native recorder is unavailable on this system") return Recorder @@ -134,9 +132,7 @@ def start(self, quality: str | None = None, task_description: str = "") -> str: RuntimeError: If a recording is already active. """ if self.state != RecordingState.IDLE: - raise RuntimeError( - f"Cannot start recording: controller is in {self.state.value} state" - ) + raise RuntimeError(f"Cannot start recording: controller is in {self.state.value} state") capture_id = uuid.uuid4().hex[:8] ts = datetime.now(timezone.utc).strftime("%Y-%m-%d_%H-%M-%S") @@ -248,76 +244,89 @@ def stop(self) -> dict: self.state = RecordingState.IDLE raise RuntimeError(f"Could not stop native recording cleanly: {exc}") from exc - # Calculate duration - duration = 0.0 - if self._started_at: - try: - start = datetime.fromisoformat(self._started_at) - end = datetime.fromisoformat(stopped_at) - duration = (end - start).total_seconds() - except Exception: - pass + capture_id = self._current_capture_id + capture_dir = self._capture_dir + started_at = self._started_at + try: + duration = 0.0 + if started_at: + try: + start = datetime.fromisoformat(started_at) + end = datetime.fromisoformat(stopped_at) + duration = (end - start).total_seconds() + except Exception: + pass - size_bytes = _dir_size(self._capture_dir) if self._capture_dir else 0 + size_bytes = _dir_size(capture_dir) if capture_dir else 0 - # Update state.json - if self._capture_dir: - state = { - "status": "completed", - "started_at": self._started_at, - "stopped_at": stopped_at, - "capture_id": self._current_capture_id, - } - (self._capture_dir / "state.json").write_text(json.dumps(state, indent=2)) - - # Update meta.json - meta_path = self._capture_dir / "meta.json" - if meta_path.exists(): - meta = json.loads(meta_path.read_text()) - else: - meta = {} - meta.update({ - "stopped_at": stopped_at, - "duration_secs": duration, + if capture_dir: + state = { + "status": "completed", + "started_at": started_at, + "stopped_at": stopped_at, + "capture_id": capture_id, + } + (capture_dir / "state.json").write_text(json.dumps(state, indent=2)) + + meta_path = capture_dir / "meta.json" + if meta_path.exists(): + meta = json.loads(meta_path.read_text()) + else: + meta = {} + meta.update( + { + "stopped_at": stopped_at, + "duration_secs": duration, + "event_count": event_count, + "size_bytes": size_bytes, + } + ) + meta_path.write_text(json.dumps(meta, indent=2)) + + if self._storage_manager: + self._storage_manager.db.update_capture( + capture_id, + stopped_at=stopped_at, + duration_secs=duration, + event_count=event_count, + size_bytes=size_bytes, + ) + + metadata = { + "id": capture_id, + "duration": duration, "event_count": event_count, "size_bytes": size_bytes, - }) - meta_path.write_text(json.dumps(meta, indent=2)) - - # Update DB - if self._storage_manager: - self._storage_manager.db.update_capture( - self._current_capture_id, - stopped_at=stopped_at, - duration_secs=duration, - event_count=event_count, - size_bytes=size_bytes, - ) - - metadata = { - "id": self._current_capture_id, - "duration": duration, - "event_count": event_count, - "size_bytes": size_bytes, - "path": str(self._capture_dir), - } + "path": str(capture_dir), + } - # Post-stop loop step: compile the recording into a flow bundle and track - # it. Opt-in so the pure-recording path (and existing tests) are untouched. - if self._auto_compile and self._flow_bridge is not None and self._capture_dir: - compiled = self.compile_capture(self._current_capture_id, self._capture_dir) - if compiled: - metadata["bundle_id"] = compiled.get("bundle_id") - metadata["bundle_path"] = compiled.get("bundle_path") - - # Reset state - self._current_capture_id = None - self._capture_dir = None - self._started_at = None - self._recorder = None - self.state = RecordingState.IDLE + if self._auto_compile and self._flow_bridge is not None and capture_dir: + compiled = self.compile_capture(capture_id, capture_dir) + if compiled: + metadata["bundle_id"] = compiled.get("bundle_id") + metadata["bundle_path"] = compiled.get("bundle_path") - return metadata + return metadata + except Exception as exc: + if capture_dir: + failed_state = { + "status": "failed", + "started_at": started_at, + "failed_at": _now_iso(), + "capture_id": capture_id, + "error": str(exc), + } + try: + (capture_dir / "state.json").write_text(json.dumps(failed_state, indent=2)) + except Exception: + logger.exception("Could not persist failed recording finalization") + raise RuntimeError(f"Could not finalize native recording: {exc}") from exc + finally: + self._current_capture_id = None + self._capture_dir = None + self._started_at = None + self._recorder = None + self.state = RecordingState.IDLE def compile_capture(self, capture_id: str, capture_dir: Path) -> dict | None: """Compile a finished recording into an openadapt-flow bundle directory. @@ -345,14 +354,13 @@ def compile_capture(self, capture_id: str, capture_dir: Path) -> dict | None: logger.warning("Compile failed for {cid}: {e}", cid=capture_id, e=exc) return None if not result.ok: - logger.warning("Compile returned nonzero for {cid}: {err}", - cid=capture_id, err=result.stderr[:200]) + logger.warning( + "Compile returned nonzero for {cid}: {err}", cid=capture_id, err=result.stderr[:200] + ) return None if self._db is not None: try: - self._db.insert_bundle( - bundle_id, str(bundle_path), capture_id=capture_id - ) + self._db.insert_bundle(bundle_id, str(bundle_path), capture_id=capture_id) except Exception as exc: logger.warning("Could not record bundle {bid}: {e}", bid=bundle_id, e=exc) return {"bundle_id": bundle_id, "bundle_path": str(bundle_path), "ok": True} @@ -418,9 +426,7 @@ def recover(self) -> list[str]: # Register in DB if storage_manager available if self._storage_manager: self._storage_manager.register_capture(capture_id, capture_dir) - self._storage_manager.db.update_capture( - capture_id, stopped_at=stopped_at - ) + self._storage_manager.db.update_capture(capture_id, stopped_at=stopped_at) recovered.append(capture_id) diff --git a/tests/test_engine/test_controller.py b/tests/test_engine/test_controller.py index ccb0686..47dcbf9 100644 --- a/tests/test_engine/test_controller.py +++ b/tests/test_engine/test_controller.py @@ -34,9 +34,7 @@ def test_start_creates_capture_directory(self, tmp_data_dir: Path) -> None: assert (dirs[0] / "meta.json").exists() assert (dirs[0] / "state.json").exists() - def test_start_uses_capture_runtime_contract( - self, tmp_data_dir: Path, monkeypatch - ) -> None: + def test_start_uses_capture_runtime_contract(self, tmp_data_dir: Path, monkeypatch) -> None: """Desktop must use Capture's real context-manager constructor contract.""" calls: dict[str, object] = {} @@ -65,9 +63,7 @@ def wait_for_ready(self, timeout: float = 60) -> bool: def stop(self) -> None: calls["stopped"] = True - monkeypatch.setattr( - "engine.controller._load_capture_recorder", lambda: ContractRecorder - ) + monkeypatch.setattr("engine.controller._load_capture_recorder", lambda: ContractRecorder) controller = RecordingController(captures_dir=tmp_data_dir / "captures") controller.start(task_description="Claims intake") @@ -127,9 +123,7 @@ def wait_for_ready(self, timeout: float = 60) -> bool: def stop(self) -> None: raise RuntimeError("writer did not stop") - monkeypatch.setattr( - "engine.controller._load_capture_recorder", lambda: BrokenStopRecorder - ) + monkeypatch.setattr("engine.controller._load_capture_recorder", lambda: BrokenStopRecorder) controller = RecordingController(captures_dir=tmp_data_dir / "captures") controller.start() @@ -175,9 +169,7 @@ class FailingStorageManager: def register_capture(self, capture_id: str, capture_dir: Path) -> None: raise RuntimeError("index unavailable") - monkeypatch.setattr( - "engine.controller._load_capture_recorder", lambda: TrackedRecorder - ) + monkeypatch.setattr("engine.controller._load_capture_recorder", lambda: TrackedRecorder) controller = RecordingController( captures_dir=tmp_data_dir / "captures", storage_manager=FailingStorageManager(), @@ -194,6 +186,37 @@ def register_capture(self, capture_id: str, capture_dir: Path) -> None: assert state["status"] == "failed" assert "index unavailable" in state["error"] + def test_finalization_failure_resets_controller_and_is_not_completed( + self, tmp_data_dir: Path + ) -> None: + """A local-index failure cannot wedge or falsely complete a stopped run.""" + + class FailingDB: + def update_capture(self, capture_id: str, **fields: object) -> None: + raise RuntimeError("index update failed") + + class StorageManager: + db = FailingDB() + + def register_capture(self, capture_id: str, capture_dir: Path) -> None: + pass + + controller = RecordingController( + captures_dir=tmp_data_dir / "captures", + storage_manager=StorageManager(), + ) + controller.start() + + with pytest.raises(RuntimeError, match="Could not finalize native recording"): + controller.stop() + + assert controller.state == RecordingState.IDLE + assert controller.current_capture_id is None + capture_dir = next((tmp_data_dir / "captures").iterdir()) + state = json.loads((capture_dir / "state.json").read_text()) + assert state["status"] == "failed" + assert "index update failed" in state["error"] + def test_stop_returns_metadata(self, tmp_data_dir: Path) -> None: """Stopping a recording should return capture metadata.""" controller = RecordingController(captures_dir=tmp_data_dir / "captures") From 218c15c24e8b6ab014a9d28a0e24a51f5df8a1f1 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 23 Jul 2026 13:02:26 -0700 Subject: [PATCH 05/15] fix: enforce frozen dependency license boundary --- scripts/build_frozen_engine.py | 31 +++- scripts/frozen_notices.py | 255 ++++++++++++++++++++++++++++++ scripts/verify_build_artifact.py | 133 +++++++++++++++- tests/test_build_frozen_engine.py | 101 ++++++++++++ tests/test_frozen_notices.py | 193 ++++++++++++++++++++++ 5 files changed, 704 insertions(+), 9 deletions(-) create mode 100644 scripts/frozen_notices.py create mode 100644 tests/test_frozen_notices.py diff --git a/scripts/build_frozen_engine.py b/scripts/build_frozen_engine.py index 24932ba..74e9e5e 100644 --- a/scripts/build_frozen_engine.py +++ b/scripts/build_frozen_engine.py @@ -12,10 +12,16 @@ import argparse import os +import shutil import sys from importlib.metadata import PackageNotFoundError, distribution from pathlib import Path +try: + from scripts.frozen_notices import NOTICE_BUNDLE_MEMBER, prepare_notice_bundle +except ModuleNotFoundError: # pragma: no cover - direct ``python scripts/...`` use + from frozen_notices import NOTICE_BUNDLE_MEMBER, prepare_notice_bundle + ROOT = Path(__file__).resolve().parents[1] # Defense in depth. Static analysis of the product CLI should not pull these @@ -65,6 +71,7 @@ def build_command( signing_identity: str = "", platform: str = sys.platform, onnxruntime_dir: Path | None = None, + notice_bundle: Path | None = None, ) -> list[str]: """Return the deterministic PyInstaller command without importing it.""" @@ -91,6 +98,8 @@ def build_command( ] for source, destination in notice_data(onnxruntime_dir): command.extend(("--add-data", f"{source}:{destination}")) + notice_bundle = notice_bundle or (ROOT / ".build-frozen-notices") + command.extend(("--add-data", f"{notice_bundle}:{NOTICE_BUNDLE_MEMBER}")) for module in EXCLUDED_MODULES: command.extend(("--exclude-module", module)) # A Developer ID build must sign the binaries *inside* PyInstaller's @@ -115,13 +124,23 @@ def main() -> int: import PyInstaller.__main__ - command = build_command( - distpath=args.distpath, - workpath=args.workpath, - specpath=args.specpath, - signing_identity=os.environ.get("APPLE_SIGNING_IDENTITY", ""), + workpath = Path(args.workpath) + notice_bundle_path = workpath.parent / f".{workpath.name}-frozen-notices" + notice_bundle = prepare_notice_bundle( + notice_bundle_path, + root_license=ROOT / "LICENSE", ) - PyInstaller.__main__.run(command) + try: + command = build_command( + distpath=args.distpath, + workpath=args.workpath, + specpath=args.specpath, + signing_identity=os.environ.get("APPLE_SIGNING_IDENTITY", ""), + notice_bundle=notice_bundle, + ) + PyInstaller.__main__.run(command) + finally: + shutil.rmtree(notice_bundle_path, ignore_errors=True) return 0 diff --git a/scripts/frozen_notices.py b/scripts/frozen_notices.py new file mode 100644 index 0000000..c731f06 --- /dev/null +++ b/scripts/frozen_notices.py @@ -0,0 +1,255 @@ +"""Build a license/NOTICE bundle for the frozen Desktop dependency closure. + +The native sidecar is a redistribution boundary: PyInstaller embeds Python +packages that are otherwise installed as separate wheels. Derive the closure +from installed distribution metadata, preserve every available license/NOTICE +file, and emit a hash-bound inventory that the artifact verifier can inspect. +""" + +from __future__ import annotations + +import hashlib +import json +import re +import shutil +from collections import deque +from importlib.metadata import PackageNotFoundError, distribution +from pathlib import Path +from typing import Callable, Iterable + +from packaging.markers import default_environment +from packaging.requirements import InvalidRequirement, Requirement +from packaging.utils import canonicalize_name + +ROOT_DISTRIBUTION = "openadapt-desktop" +ROOT_EXTRAS = frozenset({"build"}) +NOTICE_BUNDLE_MEMBER = "third_party/python" +NOTICE_INVENTORY_NAME = "NOTICE-INVENTORY.json" + +# These packages may be used as separately distributed optional components, but +# they must not be copied into OpenAdapt's permissively licensed one-file +# runtime. The metadata scan below also catches any other GPL/AGPL/LGPL +# distribution before PyInstaller runs. +FORBIDDEN_FROZEN_DISTRIBUTIONS = frozenset({"oa-atomacos", "pynput"}) +COPYLEFT_LICENSE_RE = re.compile( + r"(?:\bA?GPL(?:v?\d|[-+. ]|$)|\bLGPL(?:v?\d|[-+. ]|$)|" + r"GNU (?:AFFERO |LESSER )?GENERAL PUBLIC LICENSE)", + re.IGNORECASE, +) +NOTICE_FILE_RE = re.compile( + r"(?:^|[/\\])(?:licen[cs]e|copying|notice|authors)(?:[._-]|$)", + re.IGNORECASE, +) + +# These packages are central to the Desktop/Capture/Flow runtime and therefore +# must contribute concrete notice text, not only a short metadata classifier. +# Other closure members with notice files are bundled as well. +REQUIRED_NOTICE_TOKENS: dict[str, tuple[str, ...]] = { + "openadapt-desktop": ("license",), + "openadapt-capture": ("license",), + "openadapt-privacy": ("license",), + "openadapt-flow": ("license",), + "alembic": ("license",), + "mako": ("license",), + "pympler": ("license", "notice"), + "sqlalchemy": ("license",), +} +FIRST_PARTY_MIT_FALLBACK = frozenset( + {"openadapt-desktop", "openadapt-flow", "openadapt-capture", "openadapt-privacy"} +) + + +def _metadata_values(metadata, key: str) -> list[str]: + values = metadata.get_all(key) if hasattr(metadata, "get_all") else None + if values: + return [str(value) for value in values if value] + value = metadata.get(key) + return [str(value)] if value else [] + + +def license_evidence(dist) -> list[str]: + """Return the distribution's machine-readable license declarations.""" + + evidence: list[str] = [] + for key in ("License-Expression", "License"): + evidence.extend(_metadata_values(dist.metadata, key)) + evidence.extend( + classifier + for classifier in _metadata_values(dist.metadata, "Classifier") + if classifier.startswith("License ::") + ) + return evidence + + +def dependency_closure( + *, + root_name: str = ROOT_DISTRIBUTION, + root_extras: Iterable[str] = ROOT_EXTRAS, + distribution_getter: Callable[[str], object] = distribution, +) -> dict[str, object]: + """Resolve the installed dependency closure for the selected root extras.""" + + environment = default_environment() + pending: deque[tuple[str, frozenset[str]]] = deque([(root_name, frozenset(root_extras))]) + resolved: dict[str, object] = {} + processed_contexts: dict[str, set[str]] = {} + + while pending: + requested_name, requested_extras = pending.popleft() + canonical_name = canonicalize_name(requested_name) + try: + dist = resolved.get(canonical_name) or distribution_getter(requested_name) + except PackageNotFoundError as exc: + raise RuntimeError(f"frozen dependency {requested_name!r} is not installed") from exc + + resolved[canonical_name] = dist + prior_contexts = processed_contexts.setdefault(canonical_name, set()) + requested_contexts = {""} | set(requested_extras) + new_contexts = requested_contexts - prior_contexts + if not new_contexts: + continue + prior_contexts.update(new_contexts) + + for raw_requirement in dist.requires or (): + try: + requirement = Requirement(raw_requirement) + except InvalidRequirement as exc: + raise RuntimeError( + f"{canonical_name} has an invalid Requires-Dist entry: {raw_requirement!r}" + ) from exc + active = False + for extra in new_contexts: + marker_environment = dict(environment) + marker_environment["extra"] = extra + if requirement.marker is None or requirement.marker.evaluate(marker_environment): + active = True + break + if active: + pending.append((requirement.name, frozenset(requirement.extras))) + + return dict(sorted(resolved.items())) + + +def _notice_sources(dist) -> list[tuple[str, Path]]: + sources: list[tuple[str, Path]] = [] + for member in dist.files or (): + member_name = str(member).replace("\\", "/") + if not NOTICE_FILE_RE.search(member_name): + continue + source = Path(dist.locate_file(member)) + if source.is_file(): + sources.append((member_name, source)) + return sorted(sources) + + +def _declared_name(dist) -> str: + name = dist.metadata.get("Name") + if not name: + raise RuntimeError("installed distribution metadata is missing Name") + return canonicalize_name(str(name)) + + +def _reject_copyleft(closure: dict[str, object]) -> None: + for canonical_name, dist in closure.items(): + evidence = license_evidence(dist) + if canonical_name in FORBIDDEN_FROZEN_DISTRIBUTIONS or COPYLEFT_LICENSE_RE.search( + "\n".join(evidence) + ): + detail = "; ".join(evidence) or "known forbidden distribution" + raise RuntimeError( + f"refusing copyleft distribution in frozen runtime: {canonical_name} ({detail})" + ) + + +def prepare_notice_bundle( + output: Path, + *, + root_license: Path, + closure: dict[str, object] | None = None, + required_notice_tokens: dict[str, tuple[str, ...]] = REQUIRED_NOTICE_TOKENS, +) -> Path: + """Stage closure notices and a deterministic, hash-bound JSON inventory.""" + + closure = closure or dependency_closure() + _reject_copyleft(closure) + if output.exists(): + shutil.rmtree(output) + output.mkdir(parents=True) + + packages: list[dict[str, object]] = [] + for canonical_name, dist in sorted(closure.items()): + declared_name = _declared_name(dist) + if declared_name != canonical_name: + raise RuntimeError( + f"distribution name drift: requested {canonical_name}, " + f"metadata declares {declared_name}" + ) + sources = _notice_sources(dist) + if ( + not sources + and canonical_name in FIRST_PARTY_MIT_FALLBACK + and "MIT" in license_evidence(dist) + ): + if not root_license.is_file(): + raise RuntimeError(f"first-party MIT license is missing: {root_license}") + sources = [("workspace:LICENSE", root_license)] + if not sources: + raise RuntimeError( + f"{canonical_name} has no concrete license/NOTICE file in its " + "installed distribution" + ) + + notices: list[dict[str, object]] = [] + package_dir = output / canonical_name + for index, (source_member, source) in enumerate(sources, start=1): + safe_name = re.sub(r"[^A-Za-z0-9._-]+", "-", source.name).strip("-") + destination = package_dir / f"{index:03d}-{safe_name or 'NOTICE'}" + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, destination) + payload = destination.read_bytes() + notices.append( + { + "source_member": source_member, + "bundled_member": ( + f"{NOTICE_BUNDLE_MEMBER}/{canonical_name}/{destination.name}" + ), + "sha256": hashlib.sha256(payload).hexdigest(), + "bytes": len(payload), + } + ) + + packages.append( + { + "name": canonical_name, + "version": str(dist.version), + "license_evidence": license_evidence(dist), + "notices": notices, + } + ) + + package_index = {str(package["name"]): package for package in packages} + for required_name, required_tokens in sorted(required_notice_tokens.items()): + package = package_index.get(required_name) + if package is None: + raise RuntimeError( + f"required frozen dependency is absent from notice closure: {required_name}" + ) + notice_members = [ + str(notice["bundled_member"]).lower() + for notice in package["notices"] # type: ignore[index] + ] + for token in required_tokens: + if not any(token.lower() in member for member in notice_members): + raise RuntimeError(f"{required_name} did not provide required {token} notice") + + inventory = { + "schema_version": 1, + "root_distribution": ROOT_DISTRIBUTION, + "root_extras": sorted(ROOT_EXTRAS), + "packages": packages, + } + (output / NOTICE_INVENTORY_NAME).write_text( + json.dumps(inventory, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + return output diff --git a/scripts/verify_build_artifact.py b/scripts/verify_build_artifact.py index 96fddba..d91b44a 100644 --- a/scripts/verify_build_artifact.py +++ b/scripts/verify_build_artifact.py @@ -4,6 +4,8 @@ from __future__ import annotations import argparse +import hashlib +import json import re import subprocess import sys @@ -12,11 +14,28 @@ import zipfile from pathlib import Path +try: + from scripts.frozen_notices import ( + COPYLEFT_LICENSE_RE, + FORBIDDEN_FROZEN_DISTRIBUTIONS, + NOTICE_BUNDLE_MEMBER, + NOTICE_INVENTORY_NAME, + REQUIRED_NOTICE_TOKENS, + ) +except ModuleNotFoundError: # pragma: no cover - direct ``python scripts/...`` use + from frozen_notices import ( + COPYLEFT_LICENSE_RE, + FORBIDDEN_FROZEN_DISTRIBUTIONS, + NOTICE_BUNDLE_MEMBER, + NOTICE_INVENTORY_NAME, + REQUIRED_NOTICE_TOKENS, + ) + ROOT = Path(__file__).resolve().parents[1] FORBIDDEN_FROZEN_MEMBERS = re.compile( r"(?:openimis|adversary_corpus|identity_roc|reliability_corpus|" - r"grown[_-]corpus|oracle[_-]recipes)", + r"grown[_-]corpus|oracle[_-]recipes|oa[_.-]atomacos|pynput)", re.IGNORECASE, ) @@ -26,6 +45,7 @@ "third_party/rapidocr/LICENSE", "third_party/rapidocr/NOTICE", ) +NOTICE_INVENTORY_MEMBER = f"{NOTICE_BUNDLE_MEMBER}/{NOTICE_INVENTORY_NAME}" def bundled_flow_banner(root: Path = ROOT) -> str: @@ -52,6 +72,110 @@ def normalized_inventory(value: str) -> str: return re.sub(r"[\\/]+", "/", value) +def frozen_member_keys(raw_members) -> dict[str, str]: + """Map normalized archive members back to their exact platform keys.""" + + member_keys: dict[str, str] = {} + for raw_member in raw_members: + member = normalized_inventory(raw_member) + if member in member_keys and member_keys[member] != raw_member: + raise ValueError(f"duplicate normalized frozen member: {member}") + member_keys[member] = raw_member + return member_keys + + +def validate_frozen_notice_inventory( + inventory_payload: bytes, + *, + members: set[str], + extract_member, +) -> None: + """Validate the generated notice inventory against exact archive bytes.""" + + try: + inventory = json.loads(inventory_payload) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValueError("frozen notice inventory is not valid UTF-8 JSON") from exc + if inventory.get("schema_version") != 1: + raise ValueError("frozen notice inventory has an unsupported schema") + packages = inventory.get("packages") + if not isinstance(packages, list) or not packages: + raise ValueError("frozen notice inventory has no packages") + + package_index: dict[str, dict] = {} + for package in packages: + if not isinstance(package, dict) or not isinstance(package.get("name"), str): + raise ValueError("frozen notice inventory has a malformed package") + name = package["name"] + if name in package_index: + raise ValueError(f"duplicate frozen notice package: {name}") + evidence = package.get("license_evidence") + if not isinstance(evidence, list) or not all(isinstance(value, str) for value in evidence): + raise ValueError(f"invalid license evidence for {name}") + if name in FORBIDDEN_FROZEN_DISTRIBUTIONS or COPYLEFT_LICENSE_RE.search( + "\n".join(evidence) + ): + raise ValueError(f"copyleft package crossed frozen boundary: {name}") + notices = package.get("notices") + if not isinstance(notices, list) or not notices: + raise ValueError(f"missing concrete notice files for {name}") + for notice in notices: + if not isinstance(notice, dict): + raise ValueError(f"invalid notice record for {name}") + member = notice.get("bundled_member") + expected_hash = notice.get("sha256") + if not isinstance(member, str) or member not in members: + raise ValueError(f"notice member is absent for {name}: {member}") + if not isinstance(expected_hash, str) or not re.fullmatch( + r"[0-9a-f]{64}", expected_hash + ): + raise ValueError(f"invalid notice hash for {name}: {expected_hash}") + payload = extract_member(member) + if hashlib.sha256(payload).hexdigest() != expected_hash: + raise ValueError(f"notice hash mismatch for {name}: {member}") + package_index[name] = package + + for required_name, required_tokens in REQUIRED_NOTICE_TOKENS.items(): + package = package_index.get(required_name) + if package is None: + raise ValueError(f"required frozen notice package is absent: {required_name}") + notice_members = [str(notice["bundled_member"]).lower() for notice in package["notices"]] + for token in required_tokens: + if not any(token.lower() in member for member in notice_members): + raise ValueError(f"{required_name} is missing required {token} notice") + + +def verify_frozen_notice_bundle(artifact: Path) -> None: + """Read and validate license data from the actual PyInstaller archive.""" + + try: + from PyInstaller.archive.readers import CArchiveReader + except ImportError as exc: # pragma: no cover - build environment guard + raise RuntimeError("PyInstaller is required to inspect frozen notices") from exc + + reader = CArchiveReader(str(artifact)) + # CArchiveReader exposes the exact top-level member keys and avoids parsing + # archive_viewer's repr-formatted, platform-dependent diagnostic output. + member_keys = frozen_member_keys(reader.toc) + if NOTICE_INVENTORY_MEMBER not in member_keys: + raise ValueError("frozen sidecar omitted the Python notice inventory") + + def extract_member(member: str) -> bytes: + raw_member = member_keys.get(member) + if raw_member is None: + raise ValueError(f"frozen notice member is absent: {member}") + payload = reader.extract(raw_member) + if not isinstance(payload, bytes): + raise ValueError(f"could not extract frozen notice member: {member}") + return payload + + validate_frozen_notice_inventory( + extract_member(NOTICE_INVENTORY_MEMBER), + members=set(member_keys), + extract_member=extract_member, + ) + + def artifact_path(kind: str, root: Path = ROOT) -> Path: suffix = ".exe" if sys.platform == "win32" else "" if kind == "sidecar": @@ -174,9 +298,12 @@ def main() -> int: ] if missing_notices: parser.error( - "frozen sidecar omitted required third-party notices: " - + "; ".join(missing_notices) + "frozen sidecar omitted required third-party notices: " + "; ".join(missing_notices) ) + try: + verify_frozen_notice_bundle(artifact) + except (RuntimeError, ValueError) as exc: + parser.error(str(exc)) print(f"Verified {args.kind} artifact: {artifact} ({artifact.stat().st_size} bytes)") return 0 diff --git a/tests/test_build_frozen_engine.py b/tests/test_build_frozen_engine.py index 455540b..9831d7c 100644 --- a/tests/test_build_frozen_engine.py +++ b/tests/test_build_frozen_engine.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from pathlib import Path import pytest @@ -17,6 +18,7 @@ def _build_command(identity: str, tmp_path: Path) -> list[str]: signing_identity=identity, platform="darwin", onnxruntime_dir=onnxruntime_dir, + notice_bundle=tmp_path / "frozen-notices", ) @@ -46,6 +48,7 @@ def test_frozen_runtime_bundles_required_third_party_notices(tmp_path: Path) -> f"{tmp_path / 'onnxruntime' / 'ThirdPartyNotices.txt'}:third_party/onnxruntime", f"{build.RAPIDOCR_NOTICE_DIR / 'LICENSE'}:third_party/rapidocr", f"{build.RAPIDOCR_NOTICE_DIR / 'NOTICE'}:third_party/rapidocr", + f"{tmp_path / 'frozen-notices'}:third_party/python", ] @@ -62,3 +65,101 @@ def test_windows_frozen_inventory_member_paths_are_normalized() -> None: normalized = verify.normalized_inventory(windows_inventory) assert "third_party/rapidocr/LICENSE" in normalized assert "//" not in normalized + member_keys = verify.frozen_member_keys([r"third_party\python\NOTICE-INVENTORY.json"]) + assert member_keys["third_party/python/NOTICE-INVENTORY.json"] == ( + r"third_party\python\NOTICE-INVENTORY.json" + ) + + +def test_frozen_inventory_rejects_copyleft_module_names() -> None: + assert verify.FORBIDDEN_FROZEN_MEMBERS.search("'oa_atomacos._a11y'") + assert verify.FORBIDDEN_FROZEN_MEMBERS.search("'pynput.keyboard._darwin'") + + +def test_frozen_notice_inventory_binds_concrete_archive_bytes() -> None: + payloads = { + "third_party/python/openadapt-desktop/001-LICENSE": b"desktop MIT\n", + "third_party/python/openadapt-capture/001-LICENSE": b"capture MIT\n", + "third_party/python/openadapt-privacy/001-LICENSE": b"privacy MIT\n", + "third_party/python/openadapt-flow/001-LICENSE": b"flow MIT\n", + "third_party/python/alembic/001-LICENSE": b"alembic MIT\n", + "third_party/python/mako/001-LICENSE": b"mako MIT\n", + "third_party/python/pympler/001-LICENSE": b"Apache\n", + "third_party/python/pympler/002-NOTICE": b"Pympler notice\n", + "third_party/python/sqlalchemy/001-LICENSE": b"sqlalchemy MIT\n", + } + packages = [] + for name in verify.REQUIRED_NOTICE_TOKENS: + notices = [] + for member, payload in payloads.items(): + if f"/{name}/" not in member: + continue + import hashlib + + notices.append( + { + "bundled_member": member, + "sha256": hashlib.sha256(payload).hexdigest(), + } + ) + packages.append( + { + "name": name, + "version": "1.0.0", + "license_evidence": ["MIT"], + "notices": notices, + } + ) + inventory = json.dumps({"schema_version": 1, "packages": packages}).encode() + + verify.validate_frozen_notice_inventory( + inventory, + members=set(payloads), + extract_member=payloads.__getitem__, + ) + + +def test_frozen_notice_inventory_rejects_copyleft_metadata() -> None: + inventory = json.dumps( + { + "schema_version": 1, + "packages": [ + { + "name": "oa-atomacos", + "version": "3.2.0", + "license_evidence": ["GPLv2"], + "notices": [], + } + ], + } + ).encode() + + with pytest.raises(ValueError, match="copyleft package"): + verify.validate_frozen_notice_inventory( + inventory, + members=set(), + extract_member=lambda member: b"", + ) + + +def test_frozen_notice_inventory_rejects_metadata_only_package() -> None: + inventory = json.dumps( + { + "schema_version": 1, + "packages": [ + { + "name": "metadata-only", + "version": "1.0.0", + "license_evidence": ["MIT"], + "notices": [], + } + ], + } + ).encode() + + with pytest.raises(ValueError, match="missing concrete notice"): + verify.validate_frozen_notice_inventory( + inventory, + members=set(), + extract_member=lambda member: b"", + ) diff --git a/tests/test_frozen_notices.py b/tests/test_frozen_notices.py new file mode 100644 index 0000000..84fa6f3 --- /dev/null +++ b/tests/test_frozen_notices.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +import json +from email.message import Message +from pathlib import Path, PurePosixPath + +import pytest + +from scripts import frozen_notices as notices + + +class FakeDistribution: + def __init__( + self, + root: Path, + name: str, + *, + version: str = "1.0.0", + requires: list[str] | None = None, + license_expression: str = "MIT", + notice_files: dict[str, str] | None = None, + ) -> None: + self.root = root + self.version = version + self.requires = requires or [] + self.metadata = Message() + self.metadata["Name"] = name + self.metadata["License-Expression"] = license_expression + self.files = [] + for member, text in (notice_files or {}).items(): + path = root / member + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + self.files.append(PurePosixPath(member)) + + def locate_file(self, member: PurePosixPath) -> Path: + return self.root / member + + +def test_dependency_closure_resolves_only_selected_extra(tmp_path: Path) -> None: + distributions = { + "desktop": FakeDistribution( + tmp_path / "desktop", + "desktop", + requires=[ + "runtime", + "builder; extra == 'build'", + "developer; extra == 'dev'", + ], + ), + "runtime": FakeDistribution(tmp_path / "runtime", "runtime"), + "builder": FakeDistribution(tmp_path / "builder", "builder", requires=["runtime"]), + "developer": FakeDistribution(tmp_path / "developer", "developer"), + } + + closure = notices.dependency_closure( + root_name="desktop", + root_extras={"build"}, + distribution_getter=distributions.__getitem__, + ) + + assert set(closure) == {"desktop", "runtime", "builder"} + + +def test_dependency_closure_terminates_on_cycles(tmp_path: Path) -> None: + distributions = { + "desktop": FakeDistribution( + tmp_path / "desktop", + "desktop", + requires=["runtime"], + ), + "runtime": FakeDistribution( + tmp_path / "runtime", + "runtime", + requires=["desktop"], + ), + } + + closure = notices.dependency_closure( + root_name="desktop", + root_extras=set(), + distribution_getter=distributions.__getitem__, + ) + + assert set(closure) == {"desktop", "runtime"} + + +def test_notice_bundle_copies_files_and_hashes_inventory(tmp_path: Path) -> None: + desktop = FakeDistribution( + tmp_path / "desktop", + "openadapt-desktop", + notice_files={"dist-info/LICENSE": "desktop MIT\n"}, + ) + capture = FakeDistribution( + tmp_path / "capture", + "openadapt-capture", + notice_files={"dist-info/LICENSE": "capture MIT\n"}, + ) + output = notices.prepare_notice_bundle( + tmp_path / "bundle", + root_license=tmp_path / "unused", + closure={ + "openadapt-capture": capture, + "openadapt-desktop": desktop, + }, + required_notice_tokens={ + "openadapt-desktop": ("license",), + "openadapt-capture": ("license",), + }, + ) + + inventory = json.loads((output / notices.NOTICE_INVENTORY_NAME).read_text()) + assert [package["name"] for package in inventory["packages"]] == [ + "openadapt-capture", + "openadapt-desktop", + ] + for package in inventory["packages"]: + assert package["notices"] + member = package["notices"][0]["bundled_member"] + relative = member.removeprefix(f"{notices.NOTICE_BUNDLE_MEMBER}/") + assert (output / relative).is_file() + + +def test_notice_bundle_rejects_copyleft_before_staging(tmp_path: Path) -> None: + forbidden = FakeDistribution( + tmp_path / "forbidden", + "oa-atomacos", + license_expression="GPL-2.0", + notice_files={"dist-info/LICENSE": "GPL\n"}, + ) + + with pytest.raises(RuntimeError, match="copyleft distribution"): + notices.prepare_notice_bundle( + tmp_path / "bundle", + root_license=tmp_path / "LICENSE", + closure={"oa-atomacos": forbidden}, + required_notice_tokens={}, + ) + + +def test_first_party_mit_fallback_is_explicit_and_concrete(tmp_path: Path) -> None: + privacy = FakeDistribution(tmp_path / "privacy", "openadapt-privacy") + root_license = tmp_path / "LICENSE" + root_license.write_text("OpenAdapt MIT terms\n") + + output = notices.prepare_notice_bundle( + tmp_path / "bundle", + root_license=root_license, + closure={"openadapt-privacy": privacy}, + required_notice_tokens={"openadapt-privacy": ("license",)}, + ) + + inventory = json.loads((output / notices.NOTICE_INVENTORY_NAME).read_text()) + notice = inventory["packages"][0]["notices"][0] + assert notice["source_member"] == "workspace:LICENSE" + relative = notice["bundled_member"].removeprefix(f"{notices.NOTICE_BUNDLE_MEMBER}/") + assert (output / relative).read_text() == "OpenAdapt MIT terms\n" + + +def test_notice_bundle_rejects_third_party_without_concrete_notice( + tmp_path: Path, +) -> None: + unknown = FakeDistribution( + tmp_path / "unknown", + "unknown-package", + license_expression="", + ) + + with pytest.raises(RuntimeError, match="no concrete license/NOTICE"): + notices.prepare_notice_bundle( + tmp_path / "bundle", + root_license=tmp_path / "LICENSE", + closure={"unknown-package": unknown}, + required_notice_tokens={}, + ) + + +def test_metadata_license_does_not_replace_concrete_notice( + tmp_path: Path, +) -> None: + dependency = FakeDistribution( + tmp_path / "dependency", + "metadata-only", + license_expression="MIT", + ) + + with pytest.raises(RuntimeError, match="no concrete license/NOTICE"): + notices.prepare_notice_bundle( + tmp_path / "bundle", + root_license=tmp_path / "LICENSE", + closure={"metadata-only": dependency}, + required_notice_tokens={}, + ) From c2492b5b5197d0b364f806cd616dc9f632660348 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 23 Jul 2026 13:46:30 -0700 Subject: [PATCH 06/15] fix: isolate PyInstaller bootloader license boundary --- scripts/frozen_notices.py | 183 +++++++++++++++++++++++++++++- scripts/verify_build_artifact.py | 137 +++++++++++++++++++++- tests/test_build_frozen_engine.py | 68 ++++++++++- tests/test_frozen_notices.py | 136 ++++++++++++++++++++-- 4 files changed, 503 insertions(+), 21 deletions(-) diff --git a/scripts/frozen_notices.py b/scripts/frozen_notices.py index c731f06..743be0a 100644 --- a/scripts/frozen_notices.py +++ b/scripts/frozen_notices.py @@ -22,10 +22,27 @@ from packaging.utils import canonicalize_name ROOT_DISTRIBUTION = "openadapt-desktop" -ROOT_EXTRAS = frozenset({"build"}) +FROZEN_RUNTIME_ROOTS = ("openadapt-desktop", "openadapt-flow") +BUILD_EXTRA = frozenset({"build"}) NOTICE_BUNDLE_MEMBER = "third_party/python" NOTICE_INVENTORY_NAME = "NOTICE-INVENTORY.json" +# PyInstaller itself is a build tool and must not be classified as an ordinary +# frozen runtime package. Its compiled bootloader and loader files are, +# however, embedded in every executable under the upstream Bootloader +# Exception. Pin the reviewed upstream notice bytes so an upgrade requires an +# explicit release-boundary review instead of silently inheriting new terms. +PYINSTALLER_DISTRIBUTION = "pyinstaller" +PYINSTALLER_VERSION = "6.21.0" +PYINSTALLER_NOTICE_SHA256 = "dcf75fdb959db1e3b41c0f8505069d2ece781b5ec6b3d0a4d30975cfc6580245" +PYINSTALLER_NOTICE_MEMBER = f"{NOTICE_BUNDLE_MEMBER}/build-components/pyinstaller/COPYING.txt" +PYINSTALLER_EXCEPTION_MARKERS = ( + "The PyInstaller licensing terms", + "Bootloader Exception", + "unlimited permission to link or embed compiled bootloader", + "./PyInstaller/loader", +) + # These packages may be used as separately distributed optional components, but # they must not be copied into OpenAdapt's permissively licensed one-file # runtime. The metadata scan below also catches any other GPL/AGPL/LGPL @@ -84,7 +101,7 @@ def license_evidence(dist) -> list[str]: def dependency_closure( *, root_name: str = ROOT_DISTRIBUTION, - root_extras: Iterable[str] = ROOT_EXTRAS, + root_extras: Iterable[str] = (), distribution_getter: Callable[[str], object] = distribution, ) -> dict[str, object]: """Resolve the installed dependency closure for the selected root extras.""" @@ -130,6 +147,36 @@ def dependency_closure( return dict(sorted(resolved.items())) +def frozen_runtime_closure( + *, + distribution_getter: Callable[[str], object] = distribution, +) -> dict[str, object]: + """Resolve packages that execute in the sidecar, excluding build tooling. + + Flow is a deliberately frozen payload selected from Desktop's ``build`` + extra, but PyInstaller and its packaging dependencies are not. Resolve the + two actual runtime roots without extras instead of treating every member of + ``Desktop[build]`` as redistributed Python runtime code. + """ + + resolved: dict[str, object] = {} + for root_name in FROZEN_RUNTIME_ROOTS: + closure = dependency_closure( + root_name=root_name, + root_extras=(), + distribution_getter=distribution_getter, + ) + for name, dist in closure.items(): + prior = resolved.get(name) + if prior is not None and str(prior.version) != str(dist.version): + raise RuntimeError( + f"frozen dependency version conflict for {name}: " + f"{prior.version} != {dist.version}" + ) + resolved[name] = dist + return dict(sorted(resolved.items())) + + def _notice_sources(dist) -> list[tuple[str, Path]]: sources: list[tuple[str, Path]] = [] for member in dist.files or (): @@ -161,16 +208,130 @@ def _reject_copyleft(closure: dict[str, object]) -> None: ) +def _top_level_imports(dist) -> tuple[str, ...]: + """Return import roots supplied by a wheel, for artifact exclusion checks.""" + + roots: set[str] = set() + for member in dist.files or (): + top_level = str(member).replace("\\", "/").split("/", 1)[0] + if top_level.endswith(".py"): + top_level = top_level[:-3] + if top_level.isidentifier(): + roots.add(top_level) + if not roots: + raise RuntimeError( + f"build-only distribution {_declared_name(dist)} has no auditable import roots" + ) + return tuple(sorted(roots)) + + +def _build_only_packages( + runtime_closure: dict[str, object], + *, + build_closure: dict[str, object] | None = None, +) -> list[dict[str, object]]: + """Describe build-only imports that the final archive must not contain.""" + + if build_closure is None: + build_closure = dependency_closure( + root_name=ROOT_DISTRIBUTION, + root_extras=BUILD_EXTRA, + ) + packages: list[dict[str, object]] = [] + for name in sorted(set(build_closure) - set(runtime_closure)): + dist = build_closure[name] + packages.append( + { + "name": _declared_name(dist), + "version": str(dist.version), + "archive_import_roots": list(_top_level_imports(dist)), + } + ) + if PYINSTALLER_DISTRIBUTION not in {package["name"] for package in packages}: + raise RuntimeError("PyInstaller was not isolated as a build-only distribution") + return packages + + +def _stage_pyinstaller_bootloader_notice( + output: Path, + *, + pyinstaller_dist=None, +) -> dict[str, object]: + """Stage the exact reviewed license exception for the embedded bootloader.""" + + if pyinstaller_dist is None: + pyinstaller_dist = distribution(PYINSTALLER_DISTRIBUTION) + declared_name = _declared_name(pyinstaller_dist) + if declared_name != PYINSTALLER_DISTRIBUTION: + raise RuntimeError( + "PyInstaller distribution name drift: " + f"expected {PYINSTALLER_DISTRIBUTION}, got {declared_name}" + ) + if str(pyinstaller_dist.version) != PYINSTALLER_VERSION: + raise RuntimeError( + "PyInstaller version requires a new bootloader license review: " + f"expected {PYINSTALLER_VERSION}, got {pyinstaller_dist.version}" + ) + + candidates = [ + (member, source) + for member, source in _notice_sources(pyinstaller_dist) + if member.replace("\\", "/").endswith("/licenses/COPYING.txt") + ] + if len(candidates) != 1: + raise RuntimeError( + "expected one PyInstaller licenses/COPYING.txt, " + f"found {[member for member, _ in candidates]}" + ) + source_member, source = candidates[0] + payload = source.read_bytes() + digest = hashlib.sha256(payload).hexdigest() + if digest != PYINSTALLER_NOTICE_SHA256: + raise RuntimeError( + "PyInstaller bootloader notice bytes require review: " + f"expected {PYINSTALLER_NOTICE_SHA256}, got {digest}" + ) + try: + notice_text = payload.decode("utf-8") + except UnicodeDecodeError as exc: + raise RuntimeError("PyInstaller bootloader notice is not UTF-8") from exc + missing_markers = [ + marker for marker in PYINSTALLER_EXCEPTION_MARKERS if marker not in notice_text + ] + if missing_markers: + raise RuntimeError( + "PyInstaller notice omitted Bootloader Exception terms: " + "; ".join(missing_markers) + ) + + destination = output / "build-components" / "pyinstaller" / "COPYING.txt" + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(payload) + return { + "name": "pyinstaller-bootloader", + "source_distribution": PYINSTALLER_DISTRIBUTION, + "source_version": PYINSTALLER_VERSION, + "license_scope": "GPL-2.0-or-later WITH PyInstaller-Bootloader-exception", + "source_member": source_member, + "bundled_member": PYINSTALLER_NOTICE_MEMBER, + "sha256": digest, + "bytes": len(payload), + "required_markers": list(PYINSTALLER_EXCEPTION_MARKERS), + } + + def prepare_notice_bundle( output: Path, *, root_license: Path, closure: dict[str, object] | None = None, + build_closure: dict[str, object] | None = None, + pyinstaller_dist=None, required_notice_tokens: dict[str, tuple[str, ...]] = REQUIRED_NOTICE_TOKENS, ) -> Path: """Stage closure notices and a deterministic, hash-bound JSON inventory.""" - closure = closure or dependency_closure() + if closure is None: + closure = frozen_runtime_closure() _reject_copyleft(closure) if output.exists(): shutil.rmtree(output) @@ -242,11 +403,21 @@ def prepare_notice_bundle( if not any(token.lower() in member for member in notice_members): raise RuntimeError(f"{required_name} did not provide required {token} notice") + embedded_build_components = [ + _stage_pyinstaller_bootloader_notice( + output, + pyinstaller_dist=pyinstaller_dist, + ) + ] inventory = { - "schema_version": 1, - "root_distribution": ROOT_DISTRIBUTION, - "root_extras": sorted(ROOT_EXTRAS), + "schema_version": 2, + "runtime_roots": list(FROZEN_RUNTIME_ROOTS), "packages": packages, + "build_only_packages": _build_only_packages( + closure, + build_closure=build_closure, + ), + "embedded_build_components": embedded_build_components, } (output / NOTICE_INVENTORY_NAME).write_text( json.dumps(inventory, indent=2, sort_keys=True) + "\n", diff --git a/scripts/verify_build_artifact.py b/scripts/verify_build_artifact.py index d91b44a..90620c4 100644 --- a/scripts/verify_build_artifact.py +++ b/scripts/verify_build_artifact.py @@ -18,16 +18,28 @@ from scripts.frozen_notices import ( COPYLEFT_LICENSE_RE, FORBIDDEN_FROZEN_DISTRIBUTIONS, + FROZEN_RUNTIME_ROOTS, NOTICE_BUNDLE_MEMBER, NOTICE_INVENTORY_NAME, + PYINSTALLER_DISTRIBUTION, + PYINSTALLER_EXCEPTION_MARKERS, + PYINSTALLER_NOTICE_MEMBER, + PYINSTALLER_NOTICE_SHA256, + PYINSTALLER_VERSION, REQUIRED_NOTICE_TOKENS, ) except ModuleNotFoundError: # pragma: no cover - direct ``python scripts/...`` use from frozen_notices import ( COPYLEFT_LICENSE_RE, FORBIDDEN_FROZEN_DISTRIBUTIONS, + FROZEN_RUNTIME_ROOTS, NOTICE_BUNDLE_MEMBER, NOTICE_INVENTORY_NAME, + PYINSTALLER_DISTRIBUTION, + PYINSTALLER_EXCEPTION_MARKERS, + PYINSTALLER_NOTICE_MEMBER, + PYINSTALLER_NOTICE_SHA256, + PYINSTALLER_VERSION, REQUIRED_NOTICE_TOKENS, ) @@ -89,15 +101,17 @@ def validate_frozen_notice_inventory( *, members: set[str], extract_member, -) -> None: +) -> tuple[str, ...]: """Validate the generated notice inventory against exact archive bytes.""" try: inventory = json.loads(inventory_payload) except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise ValueError("frozen notice inventory is not valid UTF-8 JSON") from exc - if inventory.get("schema_version") != 1: + if inventory.get("schema_version") != 2: raise ValueError("frozen notice inventory has an unsupported schema") + if inventory.get("runtime_roots") != list(FROZEN_RUNTIME_ROOTS): + raise ValueError("frozen notice inventory has unexpected runtime roots") packages = inventory.get("packages") if not isinstance(packages, list) or not packages: raise ValueError("frozen notice inventory has no packages") @@ -144,6 +158,119 @@ def validate_frozen_notice_inventory( if not any(token.lower() in member for member in notice_members): raise ValueError(f"{required_name} is missing required {token} notice") + build_only_packages = inventory.get("build_only_packages") + if not isinstance(build_only_packages, list) or not build_only_packages: + raise ValueError("frozen notice inventory has no build-only package boundary") + build_only_import_roots: set[str] = set() + build_only_names: set[str] = set() + for package in build_only_packages: + if not isinstance(package, dict): + raise ValueError("frozen notice inventory has a malformed build-only package") + name = package.get("name") + version = package.get("version") + roots = package.get("archive_import_roots") + if ( + not isinstance(name, str) + or not isinstance(version, str) + or not isinstance(roots, list) + or not roots + or not all(isinstance(root, str) and root.isidentifier() for root in roots) + ): + raise ValueError(f"invalid build-only package record: {package}") + if name in package_index or name in build_only_names: + raise ValueError(f"duplicate runtime/build-only package: {name}") + build_only_names.add(name) + build_only_import_roots.update(roots) + + pyinstaller_build_record = next( + (package for package in build_only_packages if package["name"] == PYINSTALLER_DISTRIBUTION), + None, + ) + if ( + pyinstaller_build_record is None + or pyinstaller_build_record["version"] != PYINSTALLER_VERSION + or "PyInstaller" not in pyinstaller_build_record["archive_import_roots"] + ): + raise ValueError("exact PyInstaller build-only boundary is absent") + + components = inventory.get("embedded_build_components") + if not isinstance(components, list) or len(components) != 1: + raise ValueError("frozen notice inventory has an invalid build-component boundary") + bootloader = components[0] + if not isinstance(bootloader, dict): + raise ValueError("PyInstaller bootloader notice record is malformed") + expected_fields = { + "name": "pyinstaller-bootloader", + "source_distribution": PYINSTALLER_DISTRIBUTION, + "source_version": PYINSTALLER_VERSION, + "license_scope": "GPL-2.0-or-later WITH PyInstaller-Bootloader-exception", + "source_member": (f"pyinstaller-{PYINSTALLER_VERSION}.dist-info/licenses/COPYING.txt"), + "bundled_member": PYINSTALLER_NOTICE_MEMBER, + "sha256": PYINSTALLER_NOTICE_SHA256, + "required_markers": list(PYINSTALLER_EXCEPTION_MARKERS), + } + for key, expected in expected_fields.items(): + if bootloader.get(key) != expected: + raise ValueError(f"PyInstaller bootloader {key} drifted") + if PYINSTALLER_NOTICE_MEMBER not in members: + raise ValueError("PyInstaller Bootloader Exception notice is absent") + bootloader_payload = extract_member(PYINSTALLER_NOTICE_MEMBER) + if hashlib.sha256(bootloader_payload).hexdigest() != PYINSTALLER_NOTICE_SHA256: + raise ValueError("PyInstaller Bootloader Exception notice hash mismatch") + if bootloader.get("bytes") != len(bootloader_payload): + raise ValueError("PyInstaller Bootloader Exception notice size mismatch") + try: + bootloader_text = bootloader_payload.decode("utf-8") + except UnicodeDecodeError as exc: + raise ValueError("PyInstaller Bootloader Exception notice is not UTF-8") from exc + missing_markers = [ + marker for marker in PYINSTALLER_EXCEPTION_MARKERS if marker not in bootloader_text + ] + if missing_markers: + raise ValueError( + "PyInstaller Bootloader Exception notice omitted reviewed terms: " + + "; ".join(missing_markers) + ) + return tuple(sorted(build_only_import_roots)) + + +def _frozen_python_modules(reader) -> set[str]: + """Return top-level and PYZ module names from a PyInstaller archive.""" + + modules = { + normalized_inventory(str(member)) + for member in reader.toc + if not normalized_inventory(str(member)).startswith(f"{NOTICE_BUNDLE_MEMBER}/") + } + for member, entry in reader.toc.items(): + # ``z`` is PyInstaller's CArchive typecode for an embedded PYZ archive. + if entry[-1] != "z": + continue + embedded = reader.open_embedded_archive(member) + modules.update(str(module) for module in embedded.toc) + return modules + + +def reject_frozen_build_only_imports( + *, + modules: set[str], + import_roots: tuple[str, ...], +) -> None: + """Prove build-tool Python packages did not cross the frozen boundary.""" + + violations: list[str] = [] + for module in modules: + normalized_module = normalized_inventory(module) + for root in import_roots: + if normalized_module == root or normalized_module.startswith((f"{root}.", f"{root}/")): + violations.append(normalized_module) + break + if violations: + raise ValueError( + "build-only Python modules crossed frozen boundary: " + + "; ".join(sorted(violations)[:20]) + ) + def verify_frozen_notice_bundle(artifact: Path) -> None: """Read and validate license data from the actual PyInstaller archive.""" @@ -169,11 +296,15 @@ def extract_member(member: str) -> bytes: raise ValueError(f"could not extract frozen notice member: {member}") return payload - validate_frozen_notice_inventory( + build_only_import_roots = validate_frozen_notice_inventory( extract_member(NOTICE_INVENTORY_MEMBER), members=set(member_keys), extract_member=extract_member, ) + reject_frozen_build_only_imports( + modules=_frozen_python_modules(reader), + import_roots=build_only_import_roots, + ) def artifact_path(kind: str, root: Path = ROOT) -> Path: diff --git a/tests/test_build_frozen_engine.py b/tests/test_build_frozen_engine.py index 9831d7c..f1af209 100644 --- a/tests/test_build_frozen_engine.py +++ b/tests/test_build_frozen_engine.py @@ -76,7 +76,22 @@ def test_frozen_inventory_rejects_copyleft_module_names() -> None: assert verify.FORBIDDEN_FROZEN_MEMBERS.search("'pynput.keyboard._darwin'") -def test_frozen_notice_inventory_binds_concrete_archive_bytes() -> None: +def test_frozen_notice_inventory_binds_concrete_archive_bytes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + bootloader_notice = b"reviewed test Bootloader Exception notice\n" + import hashlib + + monkeypatch.setattr( + verify, + "PYINSTALLER_NOTICE_SHA256", + hashlib.sha256(bootloader_notice).hexdigest(), + ) + monkeypatch.setattr( + verify, + "PYINSTALLER_EXCEPTION_MARKERS", + ("Bootloader Exception",), + ) payloads = { "third_party/python/openadapt-desktop/001-LICENSE": b"desktop MIT\n", "third_party/python/openadapt-capture/001-LICENSE": b"capture MIT\n", @@ -87,6 +102,7 @@ def test_frozen_notice_inventory_binds_concrete_archive_bytes() -> None: "third_party/python/pympler/001-LICENSE": b"Apache\n", "third_party/python/pympler/002-NOTICE": b"Pympler notice\n", "third_party/python/sqlalchemy/001-LICENSE": b"sqlalchemy MIT\n", + verify.PYINSTALLER_NOTICE_MEMBER: bootloader_notice, } packages = [] for name in verify.REQUIRED_NOTICE_TOKENS: @@ -110,19 +126,60 @@ def test_frozen_notice_inventory_binds_concrete_archive_bytes() -> None: "notices": notices, } ) - inventory = json.dumps({"schema_version": 1, "packages": packages}).encode() + inventory = json.dumps( + { + "schema_version": 2, + "runtime_roots": list(verify.FROZEN_RUNTIME_ROOTS), + "packages": packages, + "build_only_packages": [ + { + "name": verify.PYINSTALLER_DISTRIBUTION, + "version": verify.PYINSTALLER_VERSION, + "archive_import_roots": ["PyInstaller"], + } + ], + "embedded_build_components": [ + { + "name": "pyinstaller-bootloader", + "source_distribution": verify.PYINSTALLER_DISTRIBUTION, + "source_version": verify.PYINSTALLER_VERSION, + "license_scope": ("GPL-2.0-or-later WITH PyInstaller-Bootloader-exception"), + "source_member": ("pyinstaller-6.21.0.dist-info/licenses/COPYING.txt"), + "bundled_member": verify.PYINSTALLER_NOTICE_MEMBER, + "sha256": hashlib.sha256(bootloader_notice).hexdigest(), + "bytes": len(bootloader_notice), + "required_markers": ["Bootloader Exception"], + } + ], + } + ).encode() - verify.validate_frozen_notice_inventory( + build_only_roots = verify.validate_frozen_notice_inventory( inventory, members=set(payloads), extract_member=payloads.__getitem__, ) + assert build_only_roots == ("PyInstaller",) + + +def test_frozen_archive_rejects_build_only_python_modules() -> None: + with pytest.raises(ValueError, match="build-only Python modules"): + verify.reject_frozen_build_only_imports( + modules={"openadapt_flow", "PyInstaller.building.api"}, + import_roots=("PyInstaller", "altgraph"), + ) + + verify.reject_frozen_build_only_imports( + modules={"openadapt_flow", "pyimod02_importers", "pyi_rth_pkgutil"}, + import_roots=("PyInstaller", "altgraph"), + ) def test_frozen_notice_inventory_rejects_copyleft_metadata() -> None: inventory = json.dumps( { - "schema_version": 1, + "schema_version": 2, + "runtime_roots": list(verify.FROZEN_RUNTIME_ROOTS), "packages": [ { "name": "oa-atomacos", @@ -145,7 +202,8 @@ def test_frozen_notice_inventory_rejects_copyleft_metadata() -> None: def test_frozen_notice_inventory_rejects_metadata_only_package() -> None: inventory = json.dumps( { - "schema_version": 1, + "schema_version": 2, + "runtime_roots": list(verify.FROZEN_RUNTIME_ROOTS), "packages": [ { "name": "metadata-only", diff --git a/tests/test_frozen_notices.py b/tests/test_frozen_notices.py index 84fa6f3..a948424 100644 --- a/tests/test_frozen_notices.py +++ b/tests/test_frozen_notices.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import json from email.message import Message from pathlib import Path, PurePosixPath @@ -62,6 +63,43 @@ def test_dependency_closure_resolves_only_selected_extra(tmp_path: Path) -> None assert set(closure) == {"desktop", "runtime", "builder"} +def test_frozen_runtime_closure_excludes_build_tools(tmp_path: Path) -> None: + distributions = { + "openadapt-desktop": FakeDistribution( + tmp_path / "desktop", + "openadapt-desktop", + requires=[ + "desktop-runtime", + "pyinstaller; extra == 'build'", + "openadapt-flow; extra == 'build'", + ], + ), + "desktop-runtime": FakeDistribution( + tmp_path / "desktop-runtime", + "desktop-runtime", + ), + "openadapt-flow": FakeDistribution( + tmp_path / "flow", + "openadapt-flow", + requires=["flow-runtime"], + ), + "flow-runtime": FakeDistribution(tmp_path / "flow-runtime", "flow-runtime"), + "pyinstaller": FakeDistribution(tmp_path / "pyinstaller", "pyinstaller"), + } + + closure = notices.frozen_runtime_closure( + distribution_getter=distributions.__getitem__, + ) + + assert set(closure) == { + "openadapt-desktop", + "desktop-runtime", + "openadapt-flow", + "flow-runtime", + } + assert "pyinstaller" not in closure + + def test_dependency_closure_terminates_on_cycles(tmp_path: Path) -> None: distributions = { "desktop": FakeDistribution( @@ -85,7 +123,39 @@ def test_dependency_closure_terminates_on_cycles(tmp_path: Path) -> None: assert set(closure) == {"desktop", "runtime"} -def test_notice_bundle_copies_files_and_hashes_inventory(tmp_path: Path) -> None: +def _bootloader_kwargs( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + runtime_closure: dict[str, FakeDistribution], +) -> dict[str, object]: + notice_text = "\n".join(notices.PYINSTALLER_EXCEPTION_MARKERS) + "\n" + pyinstaller = FakeDistribution( + tmp_path / "pyinstaller", + "pyinstaller", + version=notices.PYINSTALLER_VERSION, + license_expression="GPL-2.0-or-later WITH Bootloader-exception", + notice_files={ + ( + f"pyinstaller-{notices.PYINSTALLER_VERSION}.dist-info/licenses/COPYING.txt" + ): notice_text, + "PyInstaller/__init__.py": "", + }, + ) + monkeypatch.setattr( + notices, + "PYINSTALLER_NOTICE_SHA256", + hashlib.sha256(notice_text.encode()).hexdigest(), + ) + return { + "build_closure": {**runtime_closure, "pyinstaller": pyinstaller}, + "pyinstaller_dist": pyinstaller, + } + + +def test_notice_bundle_copies_files_and_hashes_inventory( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: desktop = FakeDistribution( tmp_path / "desktop", "openadapt-desktop", @@ -96,20 +166,23 @@ def test_notice_bundle_copies_files_and_hashes_inventory(tmp_path: Path) -> None "openadapt-capture", notice_files={"dist-info/LICENSE": "capture MIT\n"}, ) + closure = { + "openadapt-capture": capture, + "openadapt-desktop": desktop, + } output = notices.prepare_notice_bundle( tmp_path / "bundle", root_license=tmp_path / "unused", - closure={ - "openadapt-capture": capture, - "openadapt-desktop": desktop, - }, + closure=closure, required_notice_tokens={ "openadapt-desktop": ("license",), "openadapt-capture": ("license",), }, + **_bootloader_kwargs(tmp_path, monkeypatch, closure), ) inventory = json.loads((output / notices.NOTICE_INVENTORY_NAME).read_text()) + assert inventory["schema_version"] == 2 assert [package["name"] for package in inventory["packages"]] == [ "openadapt-capture", "openadapt-desktop", @@ -119,6 +192,50 @@ def test_notice_bundle_copies_files_and_hashes_inventory(tmp_path: Path) -> None member = package["notices"][0]["bundled_member"] relative = member.removeprefix(f"{notices.NOTICE_BUNDLE_MEMBER}/") assert (output / relative).is_file() + assert inventory["embedded_build_components"][0]["name"] == "pyinstaller-bootloader" + assert inventory["build_only_packages"] == [ + { + "archive_import_roots": ["PyInstaller"], + "name": "pyinstaller", + "version": notices.PYINSTALLER_VERSION, + } + ] + assert (output / "build-components" / "pyinstaller" / "COPYING.txt").is_file() + + +def test_bootloader_notice_rejects_unreviewed_bytes(tmp_path: Path) -> None: + pyinstaller = FakeDistribution( + tmp_path / "pyinstaller", + "pyinstaller", + version=notices.PYINSTALLER_VERSION, + license_expression="GPL-2.0-or-later WITH Bootloader-exception", + notice_files={ + ( + f"pyinstaller-{notices.PYINSTALLER_VERSION}.dist-info/licenses/COPYING.txt" + ): "\n".join(notices.PYINSTALLER_EXCEPTION_MARKERS) + "\nchanged\n", + }, + ) + + with pytest.raises(RuntimeError, match="notice bytes require review"): + notices._stage_pyinstaller_bootloader_notice( # noqa: SLF001 + tmp_path / "bundle", + pyinstaller_dist=pyinstaller, + ) + + +def test_bootloader_notice_rejects_unreviewed_version(tmp_path: Path) -> None: + pyinstaller = FakeDistribution( + tmp_path / "pyinstaller", + "pyinstaller", + version="6.22.0", + license_expression="GPL-2.0-or-later WITH Bootloader-exception", + ) + + with pytest.raises(RuntimeError, match="requires a new bootloader license review"): + notices._stage_pyinstaller_bootloader_notice( # noqa: SLF001 + tmp_path / "bundle", + pyinstaller_dist=pyinstaller, + ) def test_notice_bundle_rejects_copyleft_before_staging(tmp_path: Path) -> None: @@ -138,16 +255,21 @@ def test_notice_bundle_rejects_copyleft_before_staging(tmp_path: Path) -> None: ) -def test_first_party_mit_fallback_is_explicit_and_concrete(tmp_path: Path) -> None: +def test_first_party_mit_fallback_is_explicit_and_concrete( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: privacy = FakeDistribution(tmp_path / "privacy", "openadapt-privacy") root_license = tmp_path / "LICENSE" root_license.write_text("OpenAdapt MIT terms\n") + closure = {"openadapt-privacy": privacy} output = notices.prepare_notice_bundle( tmp_path / "bundle", root_license=root_license, - closure={"openadapt-privacy": privacy}, + closure=closure, required_notice_tokens={"openadapt-privacy": ("license",)}, + **_bootloader_kwargs(tmp_path, monkeypatch, closure), ) inventory = json.loads((output / notices.NOTICE_INVENTORY_NAME).read_text()) From 3baa0ccddc5359a95c0bfc1d360e212bb7ddb401 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 23 Jul 2026 13:51:43 -0700 Subject: [PATCH 07/15] fix: gate mac recording on input monitoring --- engine/dispatch.py | 55 ++++++++++++++++-- src/lib/types.ts | 1 + src/screens/Onboarding.tsx | 29 ++++++++-- tests/test_engine/test_dispatch.py | 93 +++++++++++++++++++++++++++++- 4 files changed, 165 insertions(+), 13 deletions(-) diff --git a/engine/dispatch.py b/engine/dispatch.py index 5ef9486..4c1fe11 100644 --- a/engine/dispatch.py +++ b/engine/dispatch.py @@ -253,6 +253,17 @@ def start_recording(self, **params: Any) -> dict: controller = self.services.controller if controller.is_recording: return self._status_dict(controller) + if sys.platform == "darwin" and not _mac_preflight_input_monitoring(): + # Starting a capture is the explicit user action where macOS may + # present its Input Monitoring consent prompt. Passive permission + # checks must remain prompt-free. + if not _mac_request_input_monitoring(): + message = ( + "Input Monitoring permission is required to record keyboard " + "and mouse input. Grant it in System Settings, then try again." + ) + self.emit("recording_error", {"error": message}) + raise PermissionError(message) task = params.get("purpose") or params.get("task") or params.get("name") or "" capture_id = controller.start(task_description=str(task)) self.services.audit.log("recording_started", capture_id=capture_id) @@ -823,17 +834,27 @@ def refresh_policy(self, **params: Any) -> dict: # ------------------------------------------------------- permissions def check_permissions(self, **params: Any) -> dict: - """Return a best-effort :class:`PermissionStatus`. + """Return the prompt-free :class:`PermissionStatus`. - On macOS the capture/accessibility perms are the #1 silent-failure mode; - we detect them when ``pyobjc``/``openadapt-capture`` expose a preflight, - else report ``True`` (non-mac platforms don't gate on these). + macOS capture needs Screen Recording, Accessibility, and Input + Monitoring. This check never requests access. Input Monitoring fails + closed if its preflight API is unavailable; non-mac platforms do not + use these macOS permissions and report all three as granted. """ if sys.platform != "darwin": - return {"screen_recording": True, "accessibility": True} + return { + "screen_recording": True, + "accessibility": True, + "input_monitoring": True, + } screen = _mac_preflight_screen() access = _mac_preflight_accessibility() - return {"screen_recording": screen, "accessibility": access} + input_monitoring = _mac_preflight_input_monitoring() + return { + "screen_recording": screen, + "accessibility": access, + "input_monitoring": input_monitoring, + } # ------------------------------------------------------- review / egress @@ -966,3 +987,25 @@ def _mac_preflight_accessibility() -> bool: # pragma: no cover - platform-speci return bool(AXIsProcessTrusted()) except Exception: return True + + +def _mac_preflight_input_monitoring() -> bool: # pragma: no cover - platform-specific + """Check Input Monitoring without presenting the system consent prompt.""" + try: + from Quartz import CGPreflightListenEventAccess + + return bool(CGPreflightListenEventAccess()) + except Exception as exc: + logger.warning("Input Monitoring preflight unavailable: {e}", e=exc) + return False + + +def _mac_request_input_monitoring() -> bool: # pragma: no cover - platform-specific + """Request Input Monitoring after an explicit capture-start action.""" + try: + from Quartz import CGRequestListenEventAccess + + return bool(CGRequestListenEventAccess()) + except Exception as exc: + logger.warning("Input Monitoring request unavailable: {e}", e=exc) + return False diff --git a/src/lib/types.ts b/src/lib/types.ts index 71ce40a..9d9b95b 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -80,6 +80,7 @@ export interface NeedsAttention { export interface PermissionStatus { screen_recording: boolean; accessibility: boolean; + input_monitoring: boolean; } // Runner lane (EXPERIMENTAL — outbound dispatch loop, spec §2). diff --git a/src/screens/Onboarding.tsx b/src/screens/Onboarding.tsx index 29fbca7..36682d0 100644 --- a/src/screens/Onboarding.tsx +++ b/src/screens/Onboarding.tsx @@ -1,6 +1,6 @@ // First-run onboarding (spec §5): empty-state hero "Record your first workflow", -// OS permission gate (screen recording + accessibility), and the honest -// "get past the OS warning" copy. Mirrors the cloud seamless flow. +// OS permission gate and the honest "get past the OS warning" copy. Mirrors +// the cloud seamless flow. import { useEffect, useState } from "react"; import { CMD, engineTry, openExternal } from "../lib/engine"; import type { PermissionStatus } from "../lib/types"; @@ -15,11 +15,14 @@ const SCREEN_PANE = "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture"; const AX_PANE = "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"; +const INPUT_PANE = + "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent"; export function Onboarding({ onStart }: { onStart: () => void }) { const [perms, setPerms] = useState({ screen_recording: false, accessibility: false, + input_monitoring: false, }); const [checked, setChecked] = useState(false); @@ -27,7 +30,11 @@ export function Onboarding({ onStart }: { onStart: () => void }) { const p = await engineTry( CMD.CHECK_PERMISSIONS, {}, - { screen_recording: false, accessibility: false }, + { + screen_recording: false, + accessibility: false, + input_monitoring: false, + }, ); setPerms(p); setChecked(true); @@ -37,7 +44,11 @@ export function Onboarding({ onStart }: { onStart: () => void }) { void refresh(); }, []); - const ready = perms.screen_recording && perms.accessibility; + const ready = + !MAC || + (perms.screen_recording && + perms.accessibility && + perms.input_monitoring); return (
@@ -93,6 +104,16 @@ export function Onboarding({ onStart }: { onStart: () => void }) { Open pane
+
+ + {perms.input_monitoring ? "granted" : "needed"} + + + Input Monitoring + +
)} diff --git a/tests/test_engine/test_dispatch.py b/tests/test_engine/test_dispatch.py index 8281510..3d3ac66 100644 --- a/tests/test_engine/test_dispatch.py +++ b/tests/test_engine/test_dispatch.py @@ -73,8 +73,9 @@ def deps(tmp_path: Path): class TestRecordingCommands: - def test_start_and_stop(self, deps) -> None: + def test_start_and_stop(self, deps, monkeypatch) -> None: disp, _db, events = deps + monkeypatch.setattr("engine.dispatch.sys.platform", "linux") r = disp.dispatch("start_recording", {}) assert r["capture_id"] == "cap1" assert ("recording_started", {"capture_id": "cap1"}) in events @@ -88,6 +89,66 @@ def test_get_status_shape(self, deps) -> None: assert set(s) >= {"recording", "paused", "duration_secs", "capture_id"} assert s["recording"] is False + def test_mac_start_requests_input_monitoring_only_when_needed( + self, deps, monkeypatch + ) -> None: + disp, _db, _events = deps + monkeypatch.setattr("engine.dispatch.sys.platform", "darwin") + monkeypatch.setattr( + "engine.dispatch._mac_preflight_input_monitoring", lambda: False + ) + requests: list[bool] = [] + monkeypatch.setattr( + "engine.dispatch._mac_request_input_monitoring", + lambda: requests.append(True) or True, + ) + + result = disp.dispatch("start_recording", {}) + + assert result["recording"] is True + assert requests == [True] + + def test_mac_start_refuses_when_input_monitoring_remains_denied( + self, deps, monkeypatch + ) -> None: + disp, _db, events = deps + monkeypatch.setattr("engine.dispatch.sys.platform", "darwin") + monkeypatch.setattr( + "engine.dispatch._mac_preflight_input_monitoring", lambda: False + ) + monkeypatch.setattr( + "engine.dispatch._mac_request_input_monitoring", lambda: False + ) + + with pytest.raises(PermissionError, match="Input Monitoring permission"): + disp.dispatch("start_recording", {}) + + assert disp.services.controller.is_recording is False + assert ( + "recording_error", + { + "error": ( + "Input Monitoring permission is required to record keyboard " + "and mouse input. Grant it in System Settings, then try again." + ) + }, + ) in events + + def test_mac_start_does_not_request_when_input_monitoring_is_granted( + self, deps, monkeypatch + ) -> None: + disp, _db, _events = deps + monkeypatch.setattr("engine.dispatch.sys.platform", "darwin") + monkeypatch.setattr( + "engine.dispatch._mac_preflight_input_monitoring", lambda: True + ) + monkeypatch.setattr( + "engine.dispatch._mac_request_input_monitoring", + lambda: pytest.fail("permission request must not run"), + ) + + assert disp.dispatch("start_recording", {})["recording"] is True + class TestLibraryCommands: def test_get_workflows_from_bundles(self, deps) -> None: @@ -385,10 +446,36 @@ def test_maps_flow_halt(self, deps, tmp_path: Path) -> None: class TestMisc: - def test_check_permissions_shape(self, deps) -> None: + def test_non_mac_check_permissions_shape(self, deps, monkeypatch) -> None: disp, _db, _e = deps + monkeypatch.setattr("engine.dispatch.sys.platform", "linux") p = disp.dispatch("check_permissions", {}) - assert set(p) == {"screen_recording", "accessibility"} + assert p == { + "screen_recording": True, + "accessibility": True, + "input_monitoring": True, + } + + def test_mac_permission_check_is_prompt_free(self, deps, monkeypatch) -> None: + disp, _db, _e = deps + monkeypatch.setattr("engine.dispatch.sys.platform", "darwin") + monkeypatch.setattr("engine.dispatch._mac_preflight_screen", lambda: True) + monkeypatch.setattr( + "engine.dispatch._mac_preflight_accessibility", lambda: False + ) + monkeypatch.setattr( + "engine.dispatch._mac_preflight_input_monitoring", lambda: False + ) + monkeypatch.setattr( + "engine.dispatch._mac_request_input_monitoring", + lambda: pytest.fail("passive check must never request permission"), + ) + + assert disp.dispatch("check_permissions", {}) == { + "screen_recording": True, + "accessibility": False, + "input_monitoring": False, + } def test_unknown_command_raises(self, deps) -> None: disp, _db, _e = deps From b7ba5f014f69072e56beef718420ab614e9ee88f Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 23 Jul 2026 13:56:50 -0700 Subject: [PATCH 08/15] fix: keep publication guard build-extra free --- scripts/frozen_notices.py | 22 ++++++++++++++------ tests/test_build_frozen_engine.py | 34 +++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/scripts/frozen_notices.py b/scripts/frozen_notices.py index 743be0a..9ec09a8 100644 --- a/scripts/frozen_notices.py +++ b/scripts/frozen_notices.py @@ -17,10 +17,6 @@ from pathlib import Path from typing import Callable, Iterable -from packaging.markers import default_environment -from packaging.requirements import InvalidRequirement, Requirement -from packaging.utils import canonicalize_name - ROOT_DISTRIBUTION = "openadapt-desktop" FROZEN_RUNTIME_ROOTS = ("openadapt-desktop", "openadapt-flow") BUILD_EXTRA = frozenset({"build"}) @@ -76,6 +72,12 @@ ) +def _canonicalize_name(name: str) -> str: + """Normalize a distribution name without importing build-only tooling.""" + + return re.sub(r"[-_.]+", "-", name).lower() + + def _metadata_values(metadata, key: str) -> list[str]: values = metadata.get_all(key) if hasattr(metadata, "get_all") else None if values: @@ -106,6 +108,14 @@ def dependency_closure( ) -> dict[str, object]: """Resolve the installed dependency closure for the selected root extras.""" + try: + from packaging.markers import default_environment + from packaging.requirements import InvalidRequirement, Requirement + except ModuleNotFoundError as exc: + raise RuntimeError( + "the build extra is required to resolve the frozen dependency closure" + ) from exc + environment = default_environment() pending: deque[tuple[str, frozenset[str]]] = deque([(root_name, frozenset(root_extras))]) resolved: dict[str, object] = {} @@ -113,7 +123,7 @@ def dependency_closure( while pending: requested_name, requested_extras = pending.popleft() - canonical_name = canonicalize_name(requested_name) + canonical_name = _canonicalize_name(requested_name) try: dist = resolved.get(canonical_name) or distribution_getter(requested_name) except PackageNotFoundError as exc: @@ -193,7 +203,7 @@ def _declared_name(dist) -> str: name = dist.metadata.get("Name") if not name: raise RuntimeError("installed distribution metadata is missing Name") - return canonicalize_name(str(name)) + return _canonicalize_name(str(name)) def _reject_copyleft(closure: dict[str, object]) -> None: diff --git a/tests/test_build_frozen_engine.py b/tests/test_build_frozen_engine.py index f1af209..fc432ab 100644 --- a/tests/test_build_frozen_engine.py +++ b/tests/test_build_frozen_engine.py @@ -1,6 +1,10 @@ from __future__ import annotations import json +import subprocess +import sys +import tarfile +import zipfile from pathlib import Path import pytest @@ -59,6 +63,36 @@ def test_missing_onnxruntime_notice_fails_closed(tmp_path: Path) -> None: build.notice_data(tmp_path) +def test_python_distribution_guard_runs_without_build_extra(tmp_path: Path) -> None: + """The recovery publication guard must not require Packaging/PyInstaller.""" + + dist = tmp_path / "dist" + dist.mkdir() + with zipfile.ZipFile(dist / "openadapt_desktop-0.8.0-py3-none-any.whl", "w") as archive: + archive.writestr("openadapt_desktop/__init__.py", "") + with tarfile.open(dist / "openadapt_desktop-0.8.0.tar.gz", "w:gz") as archive: + payload = tmp_path / "__init__.py" + payload.write_text("") + archive.add(payload, arcname="openadapt_desktop-0.8.0/openadapt_desktop/__init__.py") + + result = subprocess.run( + [ + sys.executable, + "-S", + str(verify.ROOT / "scripts" / "verify_build_artifact.py"), + "python-distribution", + "--root", + str(tmp_path), + ], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stdout + result.stderr + assert result.stdout.count("Verified Python distribution:") == 2 + + def test_windows_frozen_inventory_member_paths_are_normalized() -> None: windows_inventory = repr("third_party\\rapidocr\\LICENSE") + "\n" From 1e5f343433e1a0fea7eb6c0d896f8536b4664855 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 23 Jul 2026 14:01:31 -0700 Subject: [PATCH 09/15] fix: request macOS input monitoring from onboarding --- engine/dispatch.py | 17 +++++- src/lib/engine.ts | 3 +- src/screens/Onboarding.tsx | 24 ++++++++ tests/test_e2e/test_ipc.py | 3 +- tests/test_engine/test_dispatch.py | 91 +++++++++++++++++++++++++++++- 5 files changed, 134 insertions(+), 4 deletions(-) diff --git a/engine/dispatch.py b/engine/dispatch.py index 4c1fe11..454dfb6 100644 --- a/engine/dispatch.py +++ b/engine/dispatch.py @@ -209,6 +209,7 @@ def _register(self) -> None: "refresh_policy": self.refresh_policy, # OS permissions "check_permissions": self.check_permissions, + "request_input_monitoring": self.request_input_monitoring, # review / egress gate "scrub_capture": self.scrub_capture, "approve_review": self.approve_review, @@ -856,6 +857,20 @@ def check_permissions(self, **params: Any) -> dict: "input_monitoring": input_monitoring, } + def request_input_monitoring(self, **params: Any) -> dict: + """Request Input Monitoring and return the refreshed permission state. + + This command is reserved for an explicit user action in the onboarding + UI. The passive :meth:`check_permissions` command remains prompt-free. + The post-request preflight is authoritative: a successful request call + does not count as permission until macOS reports access as granted. + """ + if sys.platform != "darwin": + return self.check_permissions() + if not _mac_preflight_input_monitoring(): + _mac_request_input_monitoring() + return self.check_permissions() + # ------------------------------------------------------- review / egress def scrub_capture(self, **params: Any) -> dict: @@ -1001,7 +1016,7 @@ def _mac_preflight_input_monitoring() -> bool: # pragma: no cover - platform-sp def _mac_request_input_monitoring() -> bool: # pragma: no cover - platform-specific - """Request Input Monitoring after an explicit capture-start action.""" + """Request Input Monitoring after an explicit user action.""" try: from Quartz import CGRequestListenEventAccess diff --git a/src/lib/engine.ts b/src/lib/engine.ts index d5e4ef3..d358514 100644 --- a/src/lib/engine.ts +++ b/src/lib/engine.ts @@ -48,8 +48,9 @@ export const CMD = { // cached fail-closed by the engine (engine/policy.py, docs/POLICY_SYNC.md). // The desktop consumes it read-only; the canonical write path is the cloud API. GET_EFFECTIVE_POLICY: "get_effective_policy", - // OS permissions (screen recording / accessibility) + // OS permissions (screen recording / accessibility / input monitoring) CHECK_PERMISSIONS: "check_permissions", + REQUEST_INPUT_MONITORING: "request_input_monitoring", // review / egress gate (existing engine surface) SCRUB_CAPTURE: "scrub_capture", APPROVE_REVIEW: "approve_review", diff --git a/src/screens/Onboarding.tsx b/src/screens/Onboarding.tsx index 36682d0..94d36e5 100644 --- a/src/screens/Onboarding.tsx +++ b/src/screens/Onboarding.tsx @@ -25,6 +25,7 @@ export function Onboarding({ onStart }: { onStart: () => void }) { input_monitoring: false, }); const [checked, setChecked] = useState(false); + const [requestingInput, setRequestingInput] = useState(false); async function refresh() { const p = await engineTry( @@ -44,6 +45,20 @@ export function Onboarding({ onStart }: { onStart: () => void }) { void refresh(); }, []); + async function requestInputMonitoring() { + setRequestingInput(true); + try { + await engineTry( + CMD.REQUEST_INPUT_MONITORING, + {}, + perms, + ); + } finally { + await refresh(); + setRequestingInput(false); + } + } + const ready = !MAC || (perms.screen_recording && @@ -110,6 +125,15 @@ export function Onboarding({ onStart }: { onStart: () => void }) { Input Monitoring + {!perms.input_monitoring && ( + + )} diff --git a/tests/test_e2e/test_ipc.py b/tests/test_e2e/test_ipc.py index 26954f1..1d37974 100644 --- a/tests/test_e2e/test_ipc.py +++ b/tests/test_e2e/test_ipc.py @@ -94,7 +94,8 @@ def test_handlers_registered(self, handler) -> None: "compile_recording", "replay_workflow", "run_workflow", "teach_fix", "push_workflow", "get_workflows", "get_needs_attention", "login_browser", "login_paste", "logout", "get_auth_status", - "get_config", "set_config", "check_permissions", "get_sync_state", + "get_config", "set_config", "check_permissions", + "request_input_monitoring", "get_sync_state", ): assert cmd in handler._handlers, f"missing handler: {cmd}" diff --git a/tests/test_engine/test_dispatch.py b/tests/test_engine/test_dispatch.py index 3d3ac66..bf1d05c 100644 --- a/tests/test_engine/test_dispatch.py +++ b/tests/test_engine/test_dispatch.py @@ -477,6 +477,94 @@ def test_mac_permission_check_is_prompt_free(self, deps, monkeypatch) -> None: "input_monitoring": False, } + def test_non_mac_input_monitoring_request_returns_granted_shape( + self, deps, monkeypatch + ) -> None: + disp, _db, _e = deps + monkeypatch.setattr("engine.dispatch.sys.platform", "linux") + monkeypatch.setattr( + "engine.dispatch._mac_request_input_monitoring", + lambda: pytest.fail("non-mac platforms must not request macOS access"), + ) + + assert disp.dispatch("request_input_monitoring", {}) == { + "screen_recording": True, + "accessibility": True, + "input_monitoring": True, + } + + def test_mac_input_monitoring_request_rechecks_authoritative_state( + self, deps, monkeypatch + ) -> None: + disp, _db, _e = deps + monkeypatch.setattr("engine.dispatch.sys.platform", "darwin") + monkeypatch.setattr("engine.dispatch._mac_preflight_screen", lambda: True) + monkeypatch.setattr( + "engine.dispatch._mac_preflight_accessibility", lambda: True + ) + checks = iter((False, True)) + monkeypatch.setattr( + "engine.dispatch._mac_preflight_input_monitoring", + lambda: next(checks), + ) + requests: list[bool] = [] + monkeypatch.setattr( + "engine.dispatch._mac_request_input_monitoring", + lambda: requests.append(True) or True, + ) + + assert disp.dispatch("request_input_monitoring", {}) == { + "screen_recording": True, + "accessibility": True, + "input_monitoring": True, + } + assert requests == [True] + + def test_mac_input_monitoring_request_does_not_assume_success( + self, deps, monkeypatch + ) -> None: + disp, _db, _e = deps + monkeypatch.setattr("engine.dispatch.sys.platform", "darwin") + monkeypatch.setattr("engine.dispatch._mac_preflight_screen", lambda: True) + monkeypatch.setattr( + "engine.dispatch._mac_preflight_accessibility", lambda: True + ) + monkeypatch.setattr( + "engine.dispatch._mac_preflight_input_monitoring", lambda: False + ) + monkeypatch.setattr( + "engine.dispatch._mac_request_input_monitoring", lambda: True + ) + + assert disp.dispatch("request_input_monitoring", {}) == { + "screen_recording": True, + "accessibility": True, + "input_monitoring": False, + } + + def test_mac_input_monitoring_request_skips_prompt_when_already_granted( + self, deps, monkeypatch + ) -> None: + disp, _db, _e = deps + monkeypatch.setattr("engine.dispatch.sys.platform", "darwin") + monkeypatch.setattr("engine.dispatch._mac_preflight_screen", lambda: True) + monkeypatch.setattr( + "engine.dispatch._mac_preflight_accessibility", lambda: True + ) + monkeypatch.setattr( + "engine.dispatch._mac_preflight_input_monitoring", lambda: True + ) + monkeypatch.setattr( + "engine.dispatch._mac_request_input_monitoring", + lambda: pytest.fail("granted access must not prompt again"), + ) + + assert disp.dispatch("request_input_monitoring", {}) == { + "screen_recording": True, + "accessibility": True, + "input_monitoring": True, + } + def test_unknown_command_raises(self, deps) -> None: disp, _db, _e = deps with pytest.raises(KeyError): @@ -497,7 +585,8 @@ def test_all_frontend_commands_registered(self, deps) -> None: "run_workflow", "get_run_report", "teach_fix", "push_workflow", "get_sync_state", "pause_sync", "resume_sync", "get_needs_attention", "login_browser", "login_paste", "logout", "get_auth_status", - "get_config", "set_config", "check_permissions", "scrub_capture", + "get_config", "set_config", "check_permissions", + "request_input_monitoring", "scrub_capture", "approve_review", "dismiss_review", "get_pending_reviews", } assert expected.issubset(set(disp.commands)) From 0a469e3666b6b5213fa36b0b85cab588dfca3bc6 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 23 Jul 2026 18:30:03 -0700 Subject: [PATCH 10/15] feat: ship verified managed desktop runtimes --- docs/BETA_NATIVE_INSTALLERS.md | 15 +- engine/main.py | 49 + engine/managed_vision.py | 514 ++++++++++ engine/vision-runtime-manifest.json | 69 ++ pyproject.toml | 6 +- scripts/build_frozen_engine.py | 32 +- scripts/frozen_notices.py | 230 ++++- scripts/verify_build_artifact.py | 35 +- src-tauri/Cargo.lock | 28 + src-tauri/Cargo.toml | 10 +- src-tauri/Entitlements.plist | 15 + src-tauri/ffmpeg-runtime-manifest.json | 561 +++++++++++ src-tauri/src/ffmpeg.rs | 1259 ++++++++++++++++++++++++ src-tauri/src/main.rs | 15 +- src-tauri/src/pairing.rs | 2 +- src-tauri/src/sidecar.rs | 13 +- src-tauri/tauri.conf.json | 4 +- src/lib/engine.ts | 40 + src/lib/types.ts | 16 + src/screens/Onboarding.tsx | 92 +- tests/test_build_frozen_engine.py | 57 +- tests/test_engine/test_main.py | 53 + tests/test_ffmpeg_runtime_packaging.py | 37 + tests/test_frozen_notices.py | 185 ++++ tests/test_managed_vision.py | 217 ++++ tests/test_native_release.py | 13 +- tests/test_public_metadata.py | 12 +- third_party/README.md | 66 +- third_party/flatbuffers/LICENSE | 202 ++++ third_party/loguru/LICENSE | 21 + third_party/pyobjc/LICENSE.txt | 10 + uv.lock | 727 +++++++------- 32 files changed, 4205 insertions(+), 400 deletions(-) create mode 100644 engine/managed_vision.py create mode 100644 engine/vision-runtime-manifest.json create mode 100644 src-tauri/Entitlements.plist create mode 100644 src-tauri/ffmpeg-runtime-manifest.json create mode 100644 src-tauri/src/ffmpeg.rs create mode 100644 tests/test_managed_vision.py create mode 100644 third_party/flatbuffers/LICENSE create mode 100644 third_party/loguru/LICENSE create mode 100644 third_party/pyobjc/LICENSE.txt diff --git a/docs/BETA_NATIVE_INSTALLERS.md b/docs/BETA_NATIVE_INSTALLERS.md index 98539bc..1253bd9 100644 --- a/docs/BETA_NATIVE_INSTALLERS.md +++ b/docs/BETA_NATIVE_INSTALLERS.md @@ -10,13 +10,26 @@ only the fixed `openadapt://connect` action and forwards it to the sidecar's strict, transactional pairing flow. The canonical compiler and governed runtime remain in `openadapt-flow`. Each -native installer freezes the exact `openadapt-flow==1.20.0` runtime and its +native installer freezes the exact `openadapt-flow==1.20.1` runtime and its `playwright==1.61.0` browser automation dependency into the Desktop sidecar. Compile, replay, run, and teach therefore work without a separate Python, `openadapt-flow`, or `playwright` installation on `PATH`. The first browser workflow downloads the Chromium revision pinned by the bundled Playwright runtime unless an approved browser cache is pre-provisioned. +Desktop keeps separately licensed media and vision components outside its MIT +installer. On first use, it downloads the exact release-reviewed component for +the current platform, verifies the pinned URL, byte count, and SHA-256, installs +it into a versioned local cache, and re-verifies every extracted file before +loading it. This applies to the managed FFmpeg 8.1.2 runtime used for capture +encoding and the RapidOCR 1.4.4/OpenCV 5.0.0.93 runtime used for visual +resolution. A partial or drifted download is never activated; rerunning the +operation retries it. Enterprise images can pre-provision the same exact cache +without changing the runtime contract. Developer ID builds carry the narrow +macOS library-validation entitlement required to load that independently +signed, hash-verified OpenCV extension; the manifest and full-file cache audit +remain the admission boundary. + Native releases use a distinct `desktop-vX.Y.Z` tag and prerelease channel. The native version comes from `package.json`, `src-tauri/Cargo.toml`, and `src-tauri/tauri.conf.json`; the Native Installer Freshness workflow diff --git a/engine/main.py b/engine/main.py index 9241d41..53b674a 100644 --- a/engine/main.py +++ b/engine/main.py @@ -17,6 +17,7 @@ import os import sys +from importlib.metadata import PackageNotFoundError, version from importlib.util import find_spec from pathlib import Path @@ -33,6 +34,43 @@ EMBEDDED_FLOW_MODE = "__openadapt_flow__" +def _embedded_flow_version() -> str: + """Return the exact Flow distribution version copied into the freeze.""" + + try: + return version("openadapt-flow") + except PackageNotFoundError as exc: + raise RuntimeError( + "The bundled OpenAdapt Flow runtime metadata is missing." + ) from exc + + +def _print_embedded_flow_help() -> None: + """Keep internal Flow discovery offline before optional vision provision.""" + + print( + f"""openadapt-flow {_embedded_flow_version()} + +Bundled OpenAdapt Flow runtime. + +Usage: openadapt-flow COMMAND [OPTIONS] + +Core commands: + record Record a demonstrated workflow + compile Compile a recording into a deterministic bundle + replay Replay a bundle locally + run Run a bundle under a deployment configuration + teach Teach a governed repair after a halt + approve Approve a durably paused escalation + resume Resume from the last verified checkpoint + lint Inspect bundle coverage + certify Enforce a bundle safety policy + console Open the local operator console + +Run a command with --help for its complete options.""" + ) + + def _configure_frozen_browser_cache() -> None: """Keep Playwright downloads outside PyInstaller's ephemeral extraction. @@ -74,6 +112,17 @@ def _run_embedded_flow() -> None: _configure_frozen_browser_cache() _normalize_flow_auto_scrub_capability() + flow_args = sys.argv[2:] + if not flow_args or flow_args[0] in {"-h", "--help"}: + _print_embedded_flow_help() + return + if flow_args[0] in {"-V", "--version"}: + print(f"openadapt-flow {_embedded_flow_version()}") + return + if getattr(sys, "frozen", False): + from engine.managed_vision import ensure_managed_vision_runtime + + ensure_managed_vision_runtime() from openadapt_flow.__main__ import main as flow_main sys.argv = [sys.argv[0], *sys.argv[2:]] diff --git a/engine/managed_vision.py b/engine/managed_vision.py new file mode 100644 index 0000000..de77b2d --- /dev/null +++ b/engine/managed_vision.py @@ -0,0 +1,514 @@ +"""Exact-hash first-use provisioning for Desktop's local vision runtime. + +OpenCV's upstream wheels include separately licensed codec libraries. They are +therefore not copied into OpenAdapt's MIT wheel, frozen sidecar, or installer. +Desktop downloads the exact reviewed OpenCV and RapidOCR wheels only when a +Flow command first needs the vision stack, verifies their published hashes, +extracts them into a versioned user-data cache, and loads them as an optional +runtime component. Re-running the command retries a failed download; a +partially prepared directory is never treated as ready. +""" + +from __future__ import annotations + +import hashlib +import importlib +import json +import os +import platform +import secrets +import shutil +import sys +import tempfile +import urllib.parse +import urllib.request +import zipfile +from dataclasses import dataclass +from importlib.metadata import distribution +from pathlib import Path, PurePosixPath +from typing import BinaryIO, Callable + +MANIFEST_PATH = Path(__file__).with_name("vision-runtime-manifest.json") +MARKER_NAME = ".complete.json" +MAX_EXTRACTED_BYTES = 700 * 1024 * 1024 +DOWNLOAD_CHUNK_BYTES = 1024 * 1024 +ALLOWED_DOWNLOAD_HOST = "files.pythonhosted.org" +RAPIDOCR_NOTICE_MEMBERS = ( + ("LICENSE", "LICENSE"), + ("NOTICE", "NOTICE"), +) +SUPPORTED_TARGETS = frozenset( + { + "aarch64-apple-darwin", + "x86_64-apple-darwin", + "x86_64-pc-windows-msvc", + "x86_64-unknown-linux-gnu", + } +) + + +class ManagedVisionRuntimeError(RuntimeError): + """The separately provisioned vision runtime could not be made ready.""" + + +@dataclass(frozen=True) +class Wheel: + distribution: str + version: str + url: str + sha256: str + bytes: int + license_expression: str + + +@dataclass(frozen=True) +class RuntimeContract: + runtime_version: str + target: str + wheels: tuple[Wheel, ...] + manifest_sha256: str + + @property + def build_id(self) -> str: + return f"{self.runtime_version}-{self.manifest_sha256[:12]}" + + +def current_target( + *, + system: str | None = None, + machine: str | None = None, +) -> str: + """Return the release-manifest target for this host.""" + + system = (system or platform.system()).lower() + machine = (machine or platform.machine()).lower() + if machine in {"amd64", "x64"}: + machine = "x86_64" + elif machine in {"arm64", "aarch64"}: + machine = "aarch64" + target = { + ("darwin", "aarch64"): "aarch64-apple-darwin", + ("darwin", "x86_64"): "x86_64-apple-darwin", + ("windows", "x86_64"): "x86_64-pc-windows-msvc", + ("linux", "x86_64"): "x86_64-unknown-linux-gnu", + }.get((system, machine)) + if target is None: + raise ManagedVisionRuntimeError( + f"no reviewed local vision runtime is published for {system}/{machine}" + ) + return target + + +def _wheel(record: object) -> Wheel: + if not isinstance(record, dict): + raise ManagedVisionRuntimeError("vision runtime wheel record is not an object") + try: + wheel = Wheel( + distribution=str(record["distribution"]), + version=str(record["version"]), + url=str(record["url"]), + sha256=str(record["sha256"]), + bytes=int(record["bytes"]), + license_expression=str(record["license_expression"]), + ) + except (KeyError, TypeError, ValueError) as exc: + raise ManagedVisionRuntimeError("vision runtime wheel record is malformed") from exc + parsed = urllib.parse.urlparse(wheel.url) + if ( + parsed.scheme != "https" + or parsed.hostname != ALLOWED_DOWNLOAD_HOST + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + or not parsed.path.endswith(".whl") + ): + raise ManagedVisionRuntimeError( + f"vision runtime wheel URL is not an exact trusted PyPI file: {wheel.url}" + ) + if ( + not wheel.distribution + or not wheel.version + or not wheel.license_expression + or len(wheel.sha256) != 64 + or any(character not in "0123456789abcdef" for character in wheel.sha256) + or wheel.bytes <= 0 + or wheel.bytes > 150 * 1024 * 1024 + ): + raise ManagedVisionRuntimeError( + f"vision runtime wheel metadata is invalid for {wheel.distribution or 'unknown'}" + ) + return wheel + + +def load_contract( + *, + manifest_path: Path = MANIFEST_PATH, + target: str | None = None, +) -> RuntimeContract: + """Load and strictly validate the embedded release-reviewed manifest.""" + + try: + payload = manifest_path.read_bytes() + manifest = json.loads(payload) + except (OSError, json.JSONDecodeError) as exc: + raise ManagedVisionRuntimeError("embedded vision runtime manifest is invalid") from exc + if not isinstance(manifest, dict) or manifest.get("schema_version") != 1: + raise ManagedVisionRuntimeError("unsupported vision runtime manifest schema") + if manifest.get("runtime") != "openadapt-managed-vision": + raise ManagedVisionRuntimeError("unexpected vision runtime identity") + runtime_version = manifest.get("runtime_version") + shared = manifest.get("shared_wheels") + artifacts = manifest.get("artifacts") + if ( + not isinstance(runtime_version, str) + or not runtime_version + or not isinstance(shared, list) + or not shared + or not isinstance(artifacts, list) + ): + raise ManagedVisionRuntimeError("vision runtime manifest is incomplete") + + target_names = [artifact.get("target") for artifact in artifacts if isinstance(artifact, dict)] + if set(target_names) != SUPPORTED_TARGETS or len(target_names) != len(SUPPORTED_TARGETS): + raise ManagedVisionRuntimeError( + "vision runtime manifest must cover each supported target exactly once" + ) + selected_target = target or current_target() + selected = next( + ( + artifact + for artifact in artifacts + if isinstance(artifact, dict) and artifact.get("target") == selected_target + ), + None, + ) + if selected is None or not isinstance(selected.get("wheels"), list): + raise ManagedVisionRuntimeError( + f"vision runtime manifest has no artifact for {selected_target}" + ) + wheels = tuple(_wheel(record) for record in [*shared, *selected["wheels"]]) + names = [wheel.distribution for wheel in wheels] + if sorted(names) != ["opencv-python", "rapidocr-onnxruntime"]: + raise ManagedVisionRuntimeError( + f"vision runtime must contain exact OpenCV and RapidOCR wheels, found {names}" + ) + return RuntimeContract( + runtime_version=runtime_version, + target=selected_target, + wheels=wheels, + manifest_sha256=hashlib.sha256(payload).hexdigest(), + ) + + +def runtime_root() -> Path: + """Return the versioned-cache parent, with an absolute offline override.""" + + override = os.environ.get("OPENADAPT_VISION_RUNTIME_ROOT", "").strip() + if override: + path = Path(override).expanduser() + if not path.is_absolute(): + raise ManagedVisionRuntimeError( + "OPENADAPT_VISION_RUNTIME_ROOT must be an absolute directory" + ) + return path + return Path.home() / ".openadapt" / "vision-runtime" + + +def _hash_file(path: Path) -> tuple[str, int]: + digest = hashlib.sha256() + size = 0 + with path.open("rb") as stream: + while chunk := stream.read(DOWNLOAD_CHUNK_BYTES): + digest.update(chunk) + size += len(chunk) + return digest.hexdigest(), size + + +def _download( + wheel: Wheel, + destination: Path, + *, + opener: Callable[..., BinaryIO] = urllib.request.urlopen, +) -> None: + request = urllib.request.Request( + wheel.url, + headers={"User-Agent": "OpenAdapt-Desktop-managed-vision/1"}, + ) + try: + response = opener(request, timeout=60) + with response, destination.open("xb") as output: + final_url = getattr(response, "geturl", lambda: wheel.url)() + parsed = urllib.parse.urlparse(final_url) + if parsed.scheme != "https" or parsed.hostname != ALLOWED_DOWNLOAD_HOST: + raise ManagedVisionRuntimeError( + f"vision runtime download redirected outside trusted PyPI: {final_url}" + ) + digest = hashlib.sha256() + size = 0 + while chunk := response.read(DOWNLOAD_CHUNK_BYTES): + size += len(chunk) + if size > wheel.bytes: + raise ManagedVisionRuntimeError( + f"{wheel.distribution} download exceeded its reviewed size" + ) + digest.update(chunk) + output.write(chunk) + except ManagedVisionRuntimeError: + raise + except (OSError, ValueError) as exc: + raise ManagedVisionRuntimeError(f"could not download {wheel.distribution}: {exc}") from exc + if size != wheel.bytes or digest.hexdigest() != wheel.sha256: + raise ManagedVisionRuntimeError( + f"{wheel.distribution} wheel failed exact size/hash verification" + ) + + +def _safe_member(name: str) -> PurePosixPath: + if not name or "\\" in name: + raise ManagedVisionRuntimeError(f"unsafe vision runtime wheel member: {name!r}") + member = PurePosixPath(name) + if member.is_absolute() or any(part in {"", ".", ".."} for part in member.parts): + raise ManagedVisionRuntimeError(f"unsafe vision runtime wheel member: {name!r}") + return member + + +def _extract_wheels( + archives: tuple[tuple[Wheel, Path], ...], + staging: Path, +) -> list[dict[str, object]]: + """Extract exact wheels without links, traversal, duplicates, or overrun.""" + + files: list[dict[str, object]] = [] + seen: set[str] = set() + extracted_bytes = 0 + for wheel, archive in archives: + with zipfile.ZipFile(archive) as package: + for info in package.infolist(): + if info.is_dir(): + continue + member = _safe_member(info.filename) + key = member.as_posix().casefold() + if key in seen: + raise ManagedVisionRuntimeError( + f"duplicate vision runtime wheel member: {member}" + ) + seen.add(key) + mode = (info.external_attr >> 16) & 0o170000 + if mode == 0o120000: + raise ManagedVisionRuntimeError( + f"vision runtime wheel contains a symlink: {member}" + ) + extracted_bytes += info.file_size + if extracted_bytes > MAX_EXTRACTED_BYTES: + raise ManagedVisionRuntimeError( + "vision runtime wheels exceed the extracted-size limit" + ) + destination = staging.joinpath(*member.parts) + destination.parent.mkdir(parents=True, exist_ok=True) + digest = hashlib.sha256() + size = 0 + with package.open(info) as source, destination.open("xb") as output: + while chunk := source.read(DOWNLOAD_CHUNK_BYTES): + size += len(chunk) + digest.update(chunk) + output.write(chunk) + if size != info.file_size: + raise ManagedVisionRuntimeError( + f"short extraction for vision runtime member: {member}" + ) + files.append( + { + "member": member.as_posix(), + "sha256": digest.hexdigest(), + "bytes": size, + } + ) + return sorted(files, key=lambda record: str(record["member"])) + + +def _install_runtime_notices( + staging: Path, + *, + notice_root: Path | None = None, +) -> list[dict[str, object]]: + """Install the reviewed RapidOCR license beside its provisioned runtime.""" + + if notice_root is None: + resource_root = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parents[1])) + notice_root = resource_root / "third_party" / "rapidocr" + destination_root = staging / "rapidocr_onnxruntime-1.4.4.dist-info" / "licenses" + records: list[dict[str, object]] = [] + for source_name, destination_name in RAPIDOCR_NOTICE_MEMBERS: + source = notice_root / source_name + if not source.is_file(): + raise ManagedVisionRuntimeError( + f"reviewed RapidOCR {source_name} is missing from Desktop" + ) + destination = destination_root / destination_name + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, destination) + digest, size = _hash_file(destination) + records.append( + { + "member": destination.relative_to(staging).as_posix(), + "sha256": digest, + "bytes": size, + } + ) + return records + + +def _marker(contract: RuntimeContract, files: list[dict[str, object]]) -> dict[str, object]: + return { + "schema_version": 1, + "runtime_version": contract.runtime_version, + "target": contract.target, + "manifest_sha256": contract.manifest_sha256, + "wheels": [ + { + "distribution": wheel.distribution, + "version": wheel.version, + "url": wheel.url, + "sha256": wheel.sha256, + "bytes": wheel.bytes, + "license_expression": wheel.license_expression, + } + for wheel in contract.wheels + ], + "files": files, + } + + +def _cache_is_valid(path: Path, contract: RuntimeContract) -> bool: + try: + marker = json.loads((path / MARKER_NAME).read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return False + expected = _marker(contract, marker.get("files", []) if isinstance(marker, dict) else []) + if not isinstance(marker, dict): + return False + for key in ( + "schema_version", + "runtime_version", + "target", + "manifest_sha256", + "wheels", + ): + if marker.get(key) != expected[key]: + return False + files = marker.get("files") + if not isinstance(files, list) or not files: + return False + for record in files: + if ( + not isinstance(record, dict) + or not isinstance(record.get("member"), str) + or not isinstance(record.get("sha256"), str) + or not isinstance(record.get("bytes"), int) + ): + return False + try: + member = _safe_member(record["member"]) + except ManagedVisionRuntimeError: + return False + candidate = path.joinpath(*member.parts) + if candidate.is_symlink() or not candidate.is_file(): + return False + digest, size = _hash_file(candidate) + if size != record["bytes"] or digest != record["sha256"]: + return False + return True + + +def _activate(path: Path, contract: RuntimeContract) -> None: + resolved = str(path.resolve()) + if resolved not in sys.path: + sys.path.insert(0, resolved) + importlib.invalidate_caches() + + try: + import cv2 + import rapidocr_onnxruntime + except ImportError as exc: + raise ManagedVisionRuntimeError( + "managed vision runtime imports failed after verified extraction" + ) from exc + expected = {wheel.distribution: wheel.version for wheel in contract.wheels} + # Keep names data-driven from the signed manifest. Besides avoiding a + # second source of truth, this prevents PyInstaller's metadata scanner from + # copying these externally provisioned distributions into the sidecar. + versions = {name: distribution(name).version for name in expected} + expected_cv2 = ".".join(expected["opencv-python"].split(".")[:3]) + if versions != expected or cv2.__version__ != expected_cv2: + raise ManagedVisionRuntimeError( + f"managed vision runtime version drift: expected {expected}, found {versions}" + ) + runtime_path = path.resolve() + for module in (cv2, rapidocr_onnxruntime): + module_path = Path(module.__file__ or "").resolve() + if runtime_path not in module_path.parents: + raise ManagedVisionRuntimeError( + f"{module.__name__} loaded outside the verified managed runtime" + ) + + +def ensure_managed_vision_runtime( + *, + status: Callable[[str], None] | None = None, +) -> Path: + """Ensure, verify, activate, and return the optional runtime directory.""" + + status = status or (lambda message: print(message, file=sys.stderr, flush=True)) + contract = load_contract() + root = runtime_root() + final = root / contract.build_id / contract.target + if _cache_is_valid(final, contract): + _activate(final, contract) + return final + + status("OpenAdapt: preparing the separately licensed local vision runtime (one-time download).") + root.mkdir(parents=True, exist_ok=True) + staging = Path(tempfile.mkdtemp(prefix=".vision-", dir=root)) + archives: list[tuple[Wheel, Path]] = [] + try: + for wheel in contract.wheels: + status(f"OpenAdapt: downloading {wheel.distribution} {wheel.version}...") + archive = staging / f"{wheel.distribution}-{wheel.version}.whl" + _download(wheel, archive) + archives.append((wheel, archive)) + payload = staging / "payload" + payload.mkdir() + status("OpenAdapt: verifying and installing the local vision runtime...") + files = _extract_wheels(tuple(archives), payload) + files.extend(_install_runtime_notices(payload)) + files.sort(key=lambda record: str(record["member"])) + (payload / MARKER_NAME).write_text( + json.dumps(_marker(contract, files), indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + final.parent.mkdir(parents=True, exist_ok=True) + if final.exists(): + quarantine = final.with_name( + f"{final.name}.invalid-{os.getpid()}-{secrets.token_hex(4)}" + ) + os.replace(final, quarantine) + os.replace(payload, final) + except Exception as exc: + if isinstance(exc, ManagedVisionRuntimeError): + detail = str(exc) + else: + detail = f"unexpected provisioning failure: {exc}" + raise ManagedVisionRuntimeError( + f"{detail}. Run the command again to retry; partial downloads were not activated." + ) from exc + finally: + shutil.rmtree(staging, ignore_errors=True) + + if not _cache_is_valid(final, contract): + raise ManagedVisionRuntimeError( + "managed vision runtime failed its post-install integrity check" + ) + _activate(final, contract) + status("OpenAdapt: local vision runtime is ready.") + return final diff --git a/engine/vision-runtime-manifest.json b/engine/vision-runtime-manifest.json new file mode 100644 index 0000000..0fdd5b7 --- /dev/null +++ b/engine/vision-runtime-manifest.json @@ -0,0 +1,69 @@ +{ + "schema_version": 1, + "runtime": "openadapt-managed-vision", + "runtime_version": "rapidocr-1.4.4-opencv-5.0.0.93-r2", + "shared_wheels": [ + { + "distribution": "rapidocr-onnxruntime", + "version": "1.4.4", + "url": "https://files.pythonhosted.org/packages/ba/12/1e5497183bdbe782dbb91bad1d0d2297dba4d2831b2652657f7517bfc6df/rapidocr_onnxruntime-1.4.4-py3-none-any.whl", + "sha256": "971d7d5f223a7a808662229df1ef69893809d8457d834e6373d3854bc1782cbf", + "bytes": 14915192, + "license_expression": "Apache-2.0" + } + ], + "artifacts": [ + { + "target": "aarch64-apple-darwin", + "wheels": [ + { + "distribution": "opencv-python", + "version": "5.0.0.93", + "url": "https://files.pythonhosted.org/packages/9c/75/76f6ade78f6102c61034f828e2a22616708df2c9504bc8d6af9dd8f73dc5/opencv_python-5.0.0.93-cp37-abi3-macosx_13_0_arm64.whl", + "sha256": "198a75138241810206a17c829dbcc40a7cb1841cda538ca86cbbfc6c7d95f898", + "bytes": 48322443, + "license_expression": "Apache-2.0 with bundled third-party notices" + } + ] + }, + { + "target": "x86_64-apple-darwin", + "wheels": [ + { + "distribution": "opencv-python", + "version": "5.0.0.93", + "url": "https://files.pythonhosted.org/packages/15/8c/bc1bda6aae69a32e9d84fc34153ba104cd25226861eb4aea33b2cea4860d/opencv_python-5.0.0.93-cp37-abi3-macosx_14_0_x86_64.whl", + "sha256": "6bbc32f59e1b1a7db7b39c81f63d00625f041d333037fd8702f6da52cc39108b", + "bytes": 34782755, + "license_expression": "Apache-2.0 with bundled third-party notices" + } + ] + }, + { + "target": "x86_64-pc-windows-msvc", + "wheels": [ + { + "distribution": "opencv-python", + "version": "5.0.0.93", + "url": "https://files.pythonhosted.org/packages/21/f0/9fa6e85cb10c8eb36a0222d27e50fe381b86ce49a55446bf39f491727564/opencv_python-5.0.0.93-cp37-abi3-win_amd64.whl", + "sha256": "f90ba04b8f73bc5c3814037699739f0156f597338a98f05956c684e7c3ca10d2", + "bytes": 44000345, + "license_expression": "Apache-2.0 with bundled third-party notices" + } + ] + }, + { + "target": "x86_64-unknown-linux-gnu", + "wheels": [ + { + "distribution": "opencv-python", + "version": "5.0.0.93", + "url": "https://files.pythonhosted.org/packages/95/54/eb47866b94f2b5b42dde17644b78055ef1ee05aae59962c7290e55270803/opencv_python-5.0.0.93-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", + "sha256": "f8b6d0a212253dd26ad338c812f1f23ca118fdf05a9c8c6b9444f161aa8c5881", + "bytes": 71064711, + "license_expression": "Apache-2.0 with bundled third-party notices" + } + ] + } + ] +} diff --git a/pyproject.toml b/pyproject.toml index ed177ae..e9078b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ ] dependencies = [ - "openadapt-capture>=1.0.1", + "openadapt-capture>=1.0.4", "openadapt-privacy>=1.0.0", "pydantic>=2.0", "pydantic-settings>=2.0", @@ -46,7 +46,7 @@ build = [ # The native installer freezes this exact canonical runtime into the # engine sidecar. Keep it exact: Desktop and Flow can release # independently, while each installer remains reproducible and rollbackable. - "openadapt-flow==1.20.0", + "openadapt-flow==1.20.1", # ONNX Runtime 1.27 dropped macOS Intel wheels. Flow's RapidOCR dependency # accepts this universal2 release, which retains Python 3.12 Intel support. "onnxruntime==1.20.1; sys_platform == 'darwin' and platform_machine == 'x86_64'", @@ -86,7 +86,7 @@ version_variables = ["engine/__init__.py:__version__"] commit_message = "chore: release {version}" major_on_zero = false allow_zero_version = true -build_command = "python -m ensurepip --upgrade && python -m pip install --disable-pip-version-check uv==0.11.29 && python scripts/check_release_consistency.py --sync && git add uv.lock && uv build --wheel --sdist && python scripts/check_release_consistency.py --require-dist" +build_command = "python -m ensurepip --upgrade && python -m pip install --disable-pip-version-check uv==0.11.29 && python scripts/check_release_consistency.py --sync && git add uv.lock && uv build --wheel --sdist && python scripts/check_release_consistency.py --require-dist && python scripts/verify_build_artifact.py python-distribution" [tool.semantic_release.branches.main] match = "main" diff --git a/scripts/build_frozen_engine.py b/scripts/build_frozen_engine.py index 74e9e5e..9f674f2 100644 --- a/scripts/build_frozen_engine.py +++ b/scripts/build_frozen_engine.py @@ -27,6 +27,15 @@ # Defense in depth. Static analysis of the product CLI should not pull these # modules in, and the artifact audit independently refuses them if it ever does. EXCLUDED_MODULES = ( + # OpenCV's upstream wheels and RapidOCR are exact-hash, first-use runtime + # components. They remain outside the MIT sidecar/installer and are loaded + # from the verified user-data cache by engine.managed_vision. + "cv2", + "rapidocr_onnxruntime", + # Build tooling is neither required at runtime nor permitted in the frozen + # application artifact. + "setuptools", + "_distutils_hack", "openadapt_flow.benchmark", "openadapt_flow.validation.adversary_corpus", "openadapt_flow.validation.adversary_corpus_v2", @@ -92,7 +101,22 @@ def build_command( "--collect-data", "openadapt_flow", "--collect-data", - "rapidocr_onnxruntime", + "engine", + "--hidden-import", + "onnxruntime", + "--hidden-import", + "shapely", + # OpenCV's separately provisioned loader imports this legacy NumPy + # compatibility alias; NumPy 2.x no longer exposes it to PyInstaller's + # static graph unless named explicitly. + "--hidden-import", + "numpy.core.multiarray", + "--hidden-import", + "pyclipper", + "--hidden-import", + "six", + "--hidden-import", + "tqdm", "--copy-metadata", "openadapt-flow", ] @@ -111,6 +135,12 @@ def build_command( signing_identity = signing_identity.strip() if platform == "darwin" and signing_identity and signing_identity != "-": command.extend(("--codesign-identity", signing_identity)) + command.extend( + ( + "--osx-entitlements-file", + str(ROOT / "src-tauri" / "Entitlements.plist"), + ) + ) command.append(str(ROOT / "engine" / "__main__.py")) return command diff --git a/scripts/frozen_notices.py b/scripts/frozen_notices.py index 9ec09a8..06a717a 100644 --- a/scripts/frozen_notices.py +++ b/scripts/frozen_notices.py @@ -22,6 +22,7 @@ BUILD_EXTRA = frozenset({"build"}) NOTICE_BUNDLE_MEMBER = "third_party/python" NOTICE_INVENTORY_NAME = "NOTICE-INVENTORY.json" +REVIEWED_NOTICE_ROOT = Path(__file__).resolve().parents[1] / "third_party" # PyInstaller itself is a build tool and must not be classified as an ordinary # frozen runtime package. Its compiled bootloader and loader files are, @@ -43,16 +44,111 @@ # they must not be copied into OpenAdapt's permissively licensed one-file # runtime. The metadata scan below also catches any other GPL/AGPL/LGPL # distribution before PyInstaller runs. -FORBIDDEN_FROZEN_DISTRIBUTIONS = frozenset({"oa-atomacos", "pynput"}) +FORBIDDEN_FROZEN_DISTRIBUTIONS = frozenset( + { + "av", + "oa-atomacos", + "pynput", + "scipy", + } +) +EXTERNALLY_PROVISIONED_DISTRIBUTIONS = frozenset( + { + "opencv-python", + "opencv-python-headless", + "rapidocr-onnxruntime", + } +) COPYLEFT_LICENSE_RE = re.compile( r"(?:\bA?GPL(?:v?\d|[-+. ]|$)|\bLGPL(?:v?\d|[-+. ]|$)|" r"GNU (?:AFFERO |LESSER )?GENERAL PUBLIC LICENSE)", re.IGNORECASE, ) -NOTICE_FILE_RE = re.compile( - r"(?:^|[/\\])(?:licen[cs]e|copying|notice|authors)(?:[._-]|$)", - re.IGNORECASE, -) +# Matplotlib's wheel metadata embeds the full notices for its bundled +# components. FreeType is offered under ``FTL OR GPL-2.0-or-later``; OpenAdapt +# redistributes it under the permissive FTL alternative. Keep this exception +# bound to the exact reviewed version and complete metadata bytes so a future +# Matplotlib release, platform-specific metadata drift, or a newly introduced +# copyleft-only component fails closed and requires a fresh review. +REVIEWED_PERMISSIVE_DUAL_LICENSE_EVIDENCE = { + ("matplotlib", "3.11.1"): ( + "a000c9b0ba20e722b42edf7081b936cc8f9fa5f62d4265b4fd0c45826d633a65" + ), +} +# Some upstream wheels omit their concrete license file even though their +# source release carries one. Do not replace that missing file with a broad +# metadata-only allowance. Pin the exact distribution version, upstream commit, +# repository asset, and reviewed bytes; any package or license drift then fails +# closed and requires a new release-boundary review. +REVIEWED_EXTERNAL_NOTICE_FILES: dict[tuple[str, str], dict[str, str]] = { + ("flatbuffers", "25.12.19"): { + "relative_path": "flatbuffers/LICENSE", + "source_url": ( + "https://github.com/google/flatbuffers/blob/" + "7e163021e59cca4f8e1e35a7c828b5c6b7915953/LICENSE" + ), + "source_commit": "7e163021e59cca4f8e1e35a7c828b5c6b7915953", + "sha256": "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30", + "license_expression": "Apache-2.0", + "license_evidence_sha256": ( + "c62f9b91d0225d20bebe9794089b7c6f4cf6e332eccbe9a2a71e600ed546a16d" + ), + }, + ("loguru", "0.7.3"): { + "relative_path": "loguru/LICENSE", + "source_url": ( + "https://github.com/Delgan/loguru/blob/" + "ae3bfd1b85b6b4a3db535f69b975687c79498be4/LICENSE" + ), + "source_commit": "ae3bfd1b85b6b4a3db535f69b975687c79498be4", + "sha256": "b35d026cc7aca9d5859a02eb87ddf7a386a24c986838651bd1f283f94e003327", + "license_expression": "MIT", + "license_evidence_sha256": ( + "012477eb0ae60687750030b5b3d1206dd5a17244ffe5ed5300044feaf6ae62ad" + ), + }, + ("pyobjc-core", "12.2.1"): { + "relative_path": "pyobjc/LICENSE.txt", + "source_url": ( + "https://github.com/ronaldoussoren/pyobjc/blob/" + "cb525f3e7f433c851b68725dea54bc70d35681b8/" + "pyobjc-core/License.txt" + ), + "source_commit": "cb525f3e7f433c851b68725dea54bc70d35681b8", + "sha256": "0ca04b07928d4872b9d9bb22187ca0426dd8bfab08f26eada0999a71dc81aaff", + "license_expression": "MIT", + "license_evidence_sha256": ( + "e5dcffe836b6ec8a58e492419b550e65fb8cbdc308503979e5dacb33ac7ea3b7" + ), + }, + ("pyobjc-framework-applicationservices", "12.2.1"): { + "relative_path": "pyobjc/LICENSE.txt", + "source_url": ( + "https://github.com/ronaldoussoren/pyobjc/blob/" + "cb525f3e7f433c851b68725dea54bc70d35681b8/" + "pyobjc-framework-ApplicationServices/License.txt" + ), + "source_commit": "cb525f3e7f433c851b68725dea54bc70d35681b8", + "sha256": "0ca04b07928d4872b9d9bb22187ca0426dd8bfab08f26eada0999a71dc81aaff", + "license_expression": "MIT", + "license_evidence_sha256": ( + "e5dcffe836b6ec8a58e492419b550e65fb8cbdc308503979e5dacb33ac7ea3b7" + ), + }, + ("rapidocr-onnxruntime", "1.4.4"): { + "relative_path": "rapidocr/LICENSE", + "source_url": ( + "https://github.com/RapidAI/RapidOCR/blob/" + "86ae3f5079df3422c1829cd84baf19bc8a7a9453/LICENSE" + ), + "source_commit": "86ae3f5079df3422c1829cd84baf19bc8a7a9453", + "sha256": "3e0af25fdd06aa9586ae97adb00ea927ebe5a3805ac77d2d3a81ce5f55693333", + "license_expression": "Apache-2.0", + "license_evidence_sha256": ( + "2af71558e438db0b73a20beab92dc278a94e1bbe974c00c1a33e3ab62d53a608" + ), + }, +} # These packages are central to the Desktop/Capture/Flow runtime and therefore # must contribute concrete notice text, not only a short metadata classifier. @@ -184,6 +280,8 @@ def frozen_runtime_closure( f"{prior.version} != {dist.version}" ) resolved[name] = dist + for name in EXTERNALLY_PROVISIONED_DISTRIBUTIONS: + resolved.pop(name, None) return dict(sorted(resolved.items())) @@ -191,10 +289,52 @@ def _notice_sources(dist) -> list[tuple[str, Path]]: sources: list[tuple[str, Path]] = [] for member in dist.files or (): member_name = str(member).replace("\\", "/") - if not NOTICE_FILE_RE.search(member_name): + basename = member_name.rsplit("/", 1)[-1].lower() + is_notice = basename in { + "license", + "licence", + "copying", + "notice", + "authors", + } or basename.startswith( + ( + "license.", + "license-", + "license_", + "licence.", + "licence-", + "licence_", + ) + ) + if basename in { + "copying.txt", + "copying.md", + "copying.rst", + "notice.txt", + "notice.md", + "notice.rst", + "authors.txt", + "authors.md", + "authors.rst", + }: + is_notice = True + if not is_notice: continue source = Path(dist.locate_file(member)) if source.is_file(): + payload = source.read_bytes() + if b"\x00" in payload: + raise RuntimeError( + f"{_declared_name(dist)} notice candidate contains NUL bytes: " + f"{member_name}" + ) + try: + payload.decode("utf-8") + except UnicodeDecodeError as exc: + raise RuntimeError( + f"{_declared_name(dist)} notice candidate is not UTF-8: " + f"{member_name}" + ) from exc sources.append((member_name, source)) return sorted(sources) @@ -206,16 +346,88 @@ def _declared_name(dist) -> str: return _canonicalize_name(str(name)) +def _reviewed_external_notice_sources( + canonical_name: str, + dist, + *, + notice_root: Path = REVIEWED_NOTICE_ROOT, +) -> list[tuple[str, Path]]: + """Return an exact reviewed upstream notice for a notice-less wheel.""" + + record = REVIEWED_EXTERNAL_NOTICE_FILES.get((canonical_name, str(dist.version))) + if record is None: + return [] + + evidence = "\n".join(license_evidence(dist)) + evidence_digest = hashlib.sha256(evidence.encode("utf-8")).hexdigest() + if evidence_digest != record["license_evidence_sha256"]: + raise RuntimeError( + f"{canonical_name} license metadata requires review: expected " + f"{record['license_evidence_sha256']}, got {evidence_digest}" + ) + + source = notice_root / record["relative_path"] + if not source.is_file(): + raise RuntimeError( + f"reviewed external notice is missing for {canonical_name}: {source}" + ) + payload = source.read_bytes() + digest = hashlib.sha256(payload).hexdigest() + if digest != record["sha256"]: + raise RuntimeError( + f"reviewed external notice bytes require review for {canonical_name}: " + f"expected {record['sha256']}, got {digest}" + ) + source_member = ( + f"reviewed-upstream:{record['source_url']}" + f"#commit={record['source_commit']}" + ) + return [(source_member, source)] + + +def has_unapproved_copyleft_evidence( + canonical_name: str, + version: str, + evidence: Iterable[str], +) -> bool: + """Return whether license evidence contains unreviewed copyleft terms. + + A permissive alternative is not a copyleft distribution, but prose license + fields cannot be parsed safely as SPDX expressions. Exact-byte review pins + are therefore the only exception to the conservative token scan. + """ + + evidence_text = "\n".join(evidence) + if not COPYLEFT_LICENSE_RE.search(evidence_text): + return False + reviewed_hash = REVIEWED_PERMISSIVE_DUAL_LICENSE_EVIDENCE.get( + (canonical_name, version) + ) + if reviewed_hash is None: + return True + return hashlib.sha256(evidence_text.encode("utf-8")).hexdigest() != reviewed_hash + + def _reject_copyleft(closure: dict[str, object]) -> None: for canonical_name, dist in closure.items(): evidence = license_evidence(dist) - if canonical_name in FORBIDDEN_FROZEN_DISTRIBUTIONS or COPYLEFT_LICENSE_RE.search( - "\n".join(evidence) + if canonical_name in FORBIDDEN_FROZEN_DISTRIBUTIONS or ( + has_unapproved_copyleft_evidence( + canonical_name, + str(dist.version), + evidence, + ) ): detail = "; ".join(evidence) or "known forbidden distribution" raise RuntimeError( f"refusing copyleft distribution in frozen runtime: {canonical_name} ({detail})" ) + external = sorted(set(closure) & EXTERNALLY_PROVISIONED_DISTRIBUTIONS) + if external: + raise RuntimeError( + "refusing separately provisioned distribution in frozen runtime: " + + ", ".join(external) + ) def _top_level_imports(dist) -> tuple[str, ...]: @@ -364,6 +576,8 @@ def prepare_notice_bundle( if not root_license.is_file(): raise RuntimeError(f"first-party MIT license is missing: {root_license}") sources = [("workspace:LICENSE", root_license)] + if not sources: + sources = _reviewed_external_notice_sources(canonical_name, dist) if not sources: raise RuntimeError( f"{canonical_name} has no concrete license/NOTICE file in its " diff --git a/scripts/verify_build_artifact.py b/scripts/verify_build_artifact.py index 90620c4..bd01f57 100644 --- a/scripts/verify_build_artifact.py +++ b/scripts/verify_build_artifact.py @@ -16,7 +16,7 @@ try: from scripts.frozen_notices import ( - COPYLEFT_LICENSE_RE, + EXTERNALLY_PROVISIONED_DISTRIBUTIONS, FORBIDDEN_FROZEN_DISTRIBUTIONS, FROZEN_RUNTIME_ROOTS, NOTICE_BUNDLE_MEMBER, @@ -27,10 +27,11 @@ PYINSTALLER_NOTICE_SHA256, PYINSTALLER_VERSION, REQUIRED_NOTICE_TOKENS, + has_unapproved_copyleft_evidence, ) except ModuleNotFoundError: # pragma: no cover - direct ``python scripts/...`` use from frozen_notices import ( - COPYLEFT_LICENSE_RE, + EXTERNALLY_PROVISIONED_DISTRIBUTIONS, FORBIDDEN_FROZEN_DISTRIBUTIONS, FROZEN_RUNTIME_ROOTS, NOTICE_BUNDLE_MEMBER, @@ -41,13 +42,22 @@ PYINSTALLER_NOTICE_SHA256, PYINSTALLER_VERSION, REQUIRED_NOTICE_TOKENS, + has_unapproved_copyleft_evidence, ) ROOT = Path(__file__).resolve().parents[1] FORBIDDEN_FROZEN_MEMBERS = re.compile( r"(?:openimis|adversary_corpus|identity_roc|reliability_corpus|" - r"grown[_-]corpus|oracle[_-]recipes|oa[_.-]atomacos|pynput)", + r"grown[_-]corpus|oracle[_-]recipes|oa[_.-]atomacos|pynput|" + r"(?:^|[/.'\" _-])scipy(?:[/.'\" _-]|$)|" + r"(?:^|[/.'\" ])av(?:[/.'\" ]|$)|" + r"lib(?:x264|x265|quadmath|gfortran|gcc))", + re.IGNORECASE, +) +FORBIDDEN_EMBEDDED_VISION_MEMBERS = re.compile( + r"(?:^|[/.'\" _-])(?:cv2|opencv[_-]python(?:[_-]headless)?|" + r"rapidocr[_-]onnxruntime)(?:[/.'\" _-]|$)", re.IGNORECASE, ) @@ -126,8 +136,13 @@ def validate_frozen_notice_inventory( evidence = package.get("license_evidence") if not isinstance(evidence, list) or not all(isinstance(value, str) for value in evidence): raise ValueError(f"invalid license evidence for {name}") - if name in FORBIDDEN_FROZEN_DISTRIBUTIONS or COPYLEFT_LICENSE_RE.search( - "\n".join(evidence) + version = package.get("version") + if not isinstance(version, str): + raise ValueError(f"invalid frozen notice version for {name}") + if name in EXTERNALLY_PROVISIONED_DISTRIBUTIONS: + raise ValueError(f"separately provisioned package crossed frozen boundary: {name}") + if name in FORBIDDEN_FROZEN_DISTRIBUTIONS or ( + has_unapproved_copyleft_evidence(name, version, evidence) ): raise ValueError(f"copyleft package crossed frozen boundary: {name}") notices = package.get("notices") @@ -418,6 +433,16 @@ def main() -> int: line.strip() for line in inventory_text.splitlines() if FORBIDDEN_FROZEN_MEMBERS.search(line) + or ( + FORBIDDEN_EMBEDDED_VISION_MEMBERS.search(line) + and not any( + notice in line + for notice in ( + "third_party/rapidocr/LICENSE", + "third_party/rapidocr/NOTICE", + ) + ) + ) ) if forbidden: parser.error( diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f2a61c9..e22b065 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1042,6 +1042,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -2452,8 +2453,12 @@ dependencies = [ name = "openadapt-desktop" version = "0.8.0" dependencies = [ + "hex", + "reqwest", + "rustls", "serde", "serde_json", + "sha2", "tauri", "tauri-build", "tauri-plugin-deep-link", @@ -2461,8 +2466,10 @@ dependencies = [ "tauri-plugin-shell", "tauri-plugin-single-instance", "tauri-plugin-updater", + "tempfile", "tokio", "url", + "zip", ] [[package]] @@ -4210,6 +4217,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "windows-sys 0.61.2", ] @@ -5569,16 +5577,36 @@ checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" dependencies = [ "arbitrary", "crc32fast", + "flate2", "indexmap 2.14.0", "memchr", + "zopfli", ] +[[package]] +name = "zlib-rs" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" + [[package]] name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zvariant" version = "5.13.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 636bc95..52445be 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -27,12 +27,20 @@ tauri-plugin-deep-link = "2" tauri-plugin-single-instance = { version = "2", features = ["deep-link"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -tokio = { version = "1", features = ["sync", "time"] } +hex = "0.4" +reqwest = { version = "0.13.4", default-features = false, features = ["rustls-no-provider", "stream"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } +sha2 = "0.10" +tokio = { version = "1", features = ["fs", "io-util", "process", "sync", "time"] } url = "2" +zip = { version = "4.6.1", default-features = false, features = ["deflate"] } [build-dependencies] tauri-build = { version = "2", features = [] } +[dev-dependencies] +tempfile = "3" + [[bin]] name = "openadapt-desktop" path = "src/main.rs" diff --git a/src-tauri/Entitlements.plist b/src-tauri/Entitlements.plist new file mode 100644 index 0000000..a7f6be8 --- /dev/null +++ b/src-tauri/Entitlements.plist @@ -0,0 +1,15 @@ + + + + + + com.apple.security.cs.disable-library-validation + + + diff --git a/src-tauri/ffmpeg-runtime-manifest.json b/src-tauri/ffmpeg-runtime-manifest.json new file mode 100644 index 0000000..28f7dfb --- /dev/null +++ b/src-tauri/ffmpeg-runtime-manifest.json @@ -0,0 +1,561 @@ +{ + "schema_version": 1, + "runtime": "ffmpeg", + "runtime_version": "8.1.2-r1", + "artifacts": [ + { + "archive_max_bytes": 7908219, + "archive_sha256": "7cd08f97a97d3032f2093f06227fea1d12d4078dbb2c75e1109a9d3d24d7a266", + "build_id": "ffmpeg-8.1.2-r1-aarch64-apple-darwin", + "files": [ + { + "destination": "LICENSES/FFmpeg-LGPL-2.1-or-later.txt", + "max_bytes": 1075093, + "member": "LICENSES/FFmpeg-LGPL-2.1-or-later.txt", + "sha256": "246041b6ecf9bc32d718a62c57877c78b5eb397b6467e74ed7ae2626ab189c30" + }, + { + "destination": "LICENSES/FFmpeg-LICENSE.md", + "max_bytes": 1052922, + "member": "LICENSES/FFmpeg-LICENSE.md", + "sha256": "2e1d16c72fd74e12063776371da757322f8b77589386532f4fd8634bde7de1af" + }, + { + "destination": "PROVENANCE/BUILD.json", + "max_bytes": 1049137, + "member": "PROVENANCE/BUILD.json", + "sha256": "a92f41ca8ad73bb2da91179b4ab0583f8b99f8fd146d88506f8968334d10b80e" + }, + { + "destination": "PROVENANCE/SOURCE.json", + "max_bytes": 1048880, + "member": "PROVENANCE/SOURCE.json", + "sha256": "d0f106418cf0a8c6795fd7d6fc23302e3932c75d364c83cb669e1645d9a47822" + }, + { + "destination": "PROVENANCE/configure-args.txt", + "max_bytes": 1049251, + "member": "PROVENANCE/configure-args.txt", + "sha256": "3b432e240d428ead3dffd5ac545859f2043e4b936f20467698c056ec1a36a064" + }, + { + "destination": "PROVENANCE/ffmpeg-buildconf.txt", + "max_bytes": 1050537, + "member": "PROVENANCE/ffmpeg-buildconf.txt", + "sha256": "4e4a10bd78ac72927c3cbc2e04d810b32c4dd33d927ddef3fada1d5531afd85f" + }, + { + "destination": "PROVENANCE/ffmpeg-encoders.txt", + "max_bytes": 1048964, + "member": "PROVENANCE/ffmpeg-encoders.txt", + "sha256": "21e0de55b20c0c75836cddd38575cbd180e89db70b5f0257b79fde6265771756" + }, + { + "destination": "PROVENANCE/ffmpeg-muxers.txt", + "max_bytes": 1048774, + "member": "PROVENANCE/ffmpeg-muxers.txt", + "sha256": "af77eb6b41f7d51a4d8ffa03408ccda69a8f04143f3f1f5b16f56f56e78bbf81" + }, + { + "destination": "PROVENANCE/ffmpeg-version.txt", + "max_bytes": 1049704, + "member": "PROVENANCE/ffmpeg-version.txt", + "sha256": "5f0ee666d12a22d38a311ee2607c49adc96a12214443047024ee37e06818e1f1" + }, + { + "destination": "PROVENANCE/ffprobe-buildconf.txt", + "max_bytes": 1050512, + "member": "PROVENANCE/ffprobe-buildconf.txt", + "sha256": "f78fa0722ad91b803e2672cb2d2b8f15b4ddd0dd4e5a16b947b3b9e552e0c829" + }, + { + "destination": "PROVENANCE/ffprobe-version.txt", + "max_bytes": 1049679, + "member": "PROVENANCE/ffprobe-version.txt", + "sha256": "a8bd9ebcf5f1ade058d4475ee1131f4e595c43c306a08415447e9d2c1d18c1a0" + }, + { + "destination": "PROVENANCE/hardware-probe.txt", + "max_bytes": 1048648, + "member": "PROVENANCE/hardware-probe.txt", + "sha256": "59c36623ad151eb73edf563731244c517581adfe9acdd056e10d8360395a1a5a" + }, + { + "destination": "PROVENANCE/native-dependencies.txt", + "max_bytes": 1049500, + "member": "PROVENANCE/native-dependencies.txt", + "sha256": "b3cf88cbca600a83cf16c907e3bcdc3b4437ca6c41ddd06fe115723426c951fc" + }, + { + "destination": "bin/ffmpeg", + "max_bytes": 5889424, + "member": "bin/ffmpeg", + "role": "ffmpeg", + "sha256": "bc0189969e8ca336e4e49b63ef84effb5d301b1cf3209fc214a20abc0679b585" + }, + { + "destination": "bin/ffprobe", + "max_bytes": 5535568, + "member": "bin/ffprobe", + "role": "ffprobe", + "sha256": "a0dbc88c6d1b971c044121bbee55ac761b691ca9a0b9f6e39fa63613499d12d4" + }, + { + "destination": "SHA256SUMS", + "max_bytes": 1049980, + "member": "SHA256SUMS", + "sha256": "0f6f59a261bc29b2573792b33df774d8f726811a17a37897c913b9310bb2d317" + } + ], + "license": { + "expression": "LGPL-2.1-or-later", + "license_destination": "LICENSES/FFmpeg-LGPL-2.1-or-later.txt" + }, + "probe": { + "ffprobe_version_contains": "ffprobe version 8.1.2", + "forbidden_build_flags": [ + "--enable-gpl", + "--enable-nonfree" + ], + "required_build_flags": [ + "--disable-gpl", + "--disable-nonfree", + "--disable-version3", + "--disable-network" + ], + "required_encoders": [ + "mpeg4", + "png" + ], + "required_muxers": [ + "mp4", + "image2pipe" + ], + "version_contains": "ffmpeg version 8.1.2" + }, + "source": { + "build_workflow": ".github/workflows/ffmpeg-runtime.yml", + "sha256": "464beb5e7bf0c311e68b45ae2f04e9cc2af88851abb4082231742a74d97b524c", + "signature_url": "https://ffmpeg.org/releases/ffmpeg-8.1.2.tar.xz.asc", + "signing_key_fingerprint": "FCF986EA15E6E293A5644F10B4322F04D67658D8", + "url": "https://ffmpeg.org/releases/ffmpeg-8.1.2.tar.xz" + }, + "target": "aarch64-apple-darwin", + "url": "https://github.com/OpenAdaptAI/openadapt-desktop/releases/download/ffmpeg-runtime-v8.1.2-r1/openadapt-ffmpeg-8.1.2-r1-aarch64-apple-darwin.zip" + }, + { + "archive_max_bytes": 8185775, + "archive_sha256": "a45ac3c766d94ff4bf77c87e1b2baccc29e1aca806b270d495f1698e6bf6776b", + "build_id": "ffmpeg-8.1.2-r1-x86_64-apple-darwin", + "files": [ + { + "destination": "LICENSES/FFmpeg-LGPL-2.1-or-later.txt", + "max_bytes": 1075093, + "member": "LICENSES/FFmpeg-LGPL-2.1-or-later.txt", + "sha256": "246041b6ecf9bc32d718a62c57877c78b5eb397b6467e74ed7ae2626ab189c30" + }, + { + "destination": "LICENSES/FFmpeg-LICENSE.md", + "max_bytes": 1052922, + "member": "LICENSES/FFmpeg-LICENSE.md", + "sha256": "2e1d16c72fd74e12063776371da757322f8b77589386532f4fd8634bde7de1af" + }, + { + "destination": "PROVENANCE/BUILD.json", + "max_bytes": 1049136, + "member": "PROVENANCE/BUILD.json", + "sha256": "e0ec10f59f4f2a98dc4ca6df3747cdf752bc7eeca59cf023217908563b653676" + }, + { + "destination": "PROVENANCE/SOURCE.json", + "max_bytes": 1048880, + "member": "PROVENANCE/SOURCE.json", + "sha256": "d0f106418cf0a8c6795fd7d6fc23302e3932c75d364c83cb669e1645d9a47822" + }, + { + "destination": "PROVENANCE/configure-args.txt", + "max_bytes": 1049251, + "member": "PROVENANCE/configure-args.txt", + "sha256": "3f93d4a4c6ab218622ea30d7dfdf91bb185743981a243b4ab2b88e63872e82de" + }, + { + "destination": "PROVENANCE/ffmpeg-buildconf.txt", + "max_bytes": 1050537, + "member": "PROVENANCE/ffmpeg-buildconf.txt", + "sha256": "4e1c9d91b900bd52316422fae1d84f42a6b8aba20de254346c0cc36d50cfec8c" + }, + { + "destination": "PROVENANCE/ffmpeg-encoders.txt", + "max_bytes": 1048964, + "member": "PROVENANCE/ffmpeg-encoders.txt", + "sha256": "21e0de55b20c0c75836cddd38575cbd180e89db70b5f0257b79fde6265771756" + }, + { + "destination": "PROVENANCE/ffmpeg-muxers.txt", + "max_bytes": 1048774, + "member": "PROVENANCE/ffmpeg-muxers.txt", + "sha256": "af77eb6b41f7d51a4d8ffa03408ccda69a8f04143f3f1f5b16f56f56e78bbf81" + }, + { + "destination": "PROVENANCE/ffmpeg-version.txt", + "max_bytes": 1049704, + "member": "PROVENANCE/ffmpeg-version.txt", + "sha256": "29bf59d510b432196ee3e6dfc5e0d099b67382f606bb269a8a7417461ed97d2e" + }, + { + "destination": "PROVENANCE/ffprobe-buildconf.txt", + "max_bytes": 1050512, + "member": "PROVENANCE/ffprobe-buildconf.txt", + "sha256": "b0471397b6c4ea134114ecccf749a46e073a0fa56c5e92a7ca381261b7111bba" + }, + { + "destination": "PROVENANCE/ffprobe-version.txt", + "max_bytes": 1049679, + "member": "PROVENANCE/ffprobe-version.txt", + "sha256": "be07976ce0a69379806b9c496aa1f9fcf1ba49521624a886ec6f4e7b23a3de1e" + }, + { + "destination": "PROVENANCE/hardware-probe.txt", + "max_bytes": 1049730, + "member": "PROVENANCE/hardware-probe.txt", + "sha256": "fd5a72f0b17abaf9c51d6e6724a16b3976dd253f62783a9cc254ca0de896420d" + }, + { + "destination": "PROVENANCE/native-dependencies.txt", + "max_bytes": 1049499, + "member": "PROVENANCE/native-dependencies.txt", + "sha256": "6fcb29fc03b7def2c7db1456df19ac1470b3cebd21ea84b2c5d49a96376e04a3" + }, + { + "destination": "bin/ffmpeg", + "max_bytes": 7331584, + "member": "bin/ffmpeg", + "role": "ffmpeg", + "sha256": "dfb2197b1ef2b3da19ad41f4fbc337f2c50000390d75697a81e3516a32f1293a" + }, + { + "destination": "bin/ffprobe", + "max_bytes": 6948320, + "member": "bin/ffprobe", + "role": "ffprobe", + "sha256": "b4da8255066f2a99604ef532cc54a08c73fef9de8bdf8eecdebaddd8c0b7797c" + }, + { + "destination": "SHA256SUMS", + "max_bytes": 1049980, + "member": "SHA256SUMS", + "sha256": "26bfd1f57248c23d16702c7ad5d16633e1bea2fabd4d0837988983feeac189d5" + } + ], + "license": { + "expression": "LGPL-2.1-or-later", + "license_destination": "LICENSES/FFmpeg-LGPL-2.1-or-later.txt" + }, + "probe": { + "ffprobe_version_contains": "ffprobe version 8.1.2", + "forbidden_build_flags": [ + "--enable-gpl", + "--enable-nonfree" + ], + "required_build_flags": [ + "--disable-gpl", + "--disable-nonfree", + "--disable-version3", + "--disable-network" + ], + "required_encoders": [ + "mpeg4", + "png" + ], + "required_muxers": [ + "mp4", + "image2pipe" + ], + "version_contains": "ffmpeg version 8.1.2" + }, + "source": { + "build_workflow": ".github/workflows/ffmpeg-runtime.yml", + "sha256": "464beb5e7bf0c311e68b45ae2f04e9cc2af88851abb4082231742a74d97b524c", + "signature_url": "https://ffmpeg.org/releases/ffmpeg-8.1.2.tar.xz.asc", + "signing_key_fingerprint": "FCF986EA15E6E293A5644F10B4322F04D67658D8", + "url": "https://ffmpeg.org/releases/ffmpeg-8.1.2.tar.xz" + }, + "target": "x86_64-apple-darwin", + "url": "https://github.com/OpenAdaptAI/openadapt-desktop/releases/download/ffmpeg-runtime-v8.1.2-r1/openadapt-ffmpeg-8.1.2-r1-x86_64-apple-darwin.zip" + }, + { + "archive_max_bytes": 8285917, + "archive_sha256": "bd88874357d3ea6490e22e7911f31df777e4e291b3ba83f3bfef9dc9bd366080", + "build_id": "ffmpeg-8.1.2-r1-x86_64-pc-windows-msvc", + "files": [ + { + "destination": "LICENSES/FFmpeg-LGPL-2.1-or-later.txt", + "max_bytes": 1075093, + "member": "LICENSES/FFmpeg-LGPL-2.1-or-later.txt", + "sha256": "246041b6ecf9bc32d718a62c57877c78b5eb397b6467e74ed7ae2626ab189c30" + }, + { + "destination": "LICENSES/FFmpeg-LICENSE.md", + "max_bytes": 1052922, + "member": "LICENSES/FFmpeg-LICENSE.md", + "sha256": "2e1d16c72fd74e12063776371da757322f8b77589386532f4fd8634bde7de1af" + }, + { + "destination": "LICENSES/zlib.txt", + "max_bytes": 1049578, + "member": "LICENSES/zlib.txt", + "sha256": "e32ff4e00d9d94930537635291da39e7e612703334bf6fde8c7f1686fe8a45a2" + }, + { + "destination": "PROVENANCE/BUILD.json", + "max_bytes": 1049143, + "member": "PROVENANCE/BUILD.json", + "sha256": "91a178e8d7f7dd329431f3635c645fd33aba0445992aa46fcdb702e762b809fd" + }, + { + "destination": "PROVENANCE/SOURCE.json", + "max_bytes": 1048886, + "member": "PROVENANCE/SOURCE.json", + "sha256": "ddc99297e6fe5622b2ba5de62b6fca067daca44905fc6c803e2e105e4a865746" + }, + { + "destination": "PROVENANCE/configure-args.txt", + "max_bytes": 1049270, + "member": "PROVENANCE/configure-args.txt", + "sha256": "5adb3fe0194bf584a6249bab9928e71c4d90831bf0833fec06b75781bf01930b" + }, + { + "destination": "PROVENANCE/ffmpeg-buildconf.txt", + "max_bytes": 1050621, + "member": "PROVENANCE/ffmpeg-buildconf.txt", + "sha256": "75851ac492db725c6ef86cded56de2de8313ce6ffed837fb2e08e23d3e1df7a2" + }, + { + "destination": "PROVENANCE/ffmpeg-encoders.txt", + "max_bytes": 1048934, + "member": "PROVENANCE/ffmpeg-encoders.txt", + "sha256": "7ea19f4a554aa75386de4eb0d5c8f65a5f9bc86638fa0e7c5f08ef5db87719c1" + }, + { + "destination": "PROVENANCE/ffmpeg-muxers.txt", + "max_bytes": 1048784, + "member": "PROVENANCE/ffmpeg-muxers.txt", + "sha256": "a350ec22725e4fe0cca2c379548f7bb65f965cc23deaa5b0904a48fd1c8434b6" + }, + { + "destination": "PROVENANCE/ffmpeg-version.txt", + "max_bytes": 1049730, + "member": "PROVENANCE/ffmpeg-version.txt", + "sha256": "0e720e78220f29ca0ddd7110a18d8ecac1cfc428d3212603e6ab3ae18d0f4ad4" + }, + { + "destination": "PROVENANCE/ffprobe-buildconf.txt", + "max_bytes": 1050594, + "member": "PROVENANCE/ffprobe-buildconf.txt", + "sha256": "c10543c972f1d425b5efa4ec6213f17245bc22d564b2a89762f3023ae252bbc8" + }, + { + "destination": "PROVENANCE/ffprobe-version.txt", + "max_bytes": 1049703, + "member": "PROVENANCE/ffprobe-version.txt", + "sha256": "bc699260a6847e3d4acbad8c4d90fe97ef2a4d59fdd2791ae536a07b490ee0f4" + }, + { + "destination": "PROVENANCE/native-dependencies.txt", + "max_bytes": 5531936, + "member": "PROVENANCE/native-dependencies.txt", + "sha256": "e02dad7e124dda2fa93cebd490100a188e6def0b33c557953ad09a0687fd1b13" + }, + { + "destination": "bin/ffmpeg.exe", + "max_bytes": 6898688, + "member": "bin/ffmpeg.exe", + "role": "ffmpeg", + "sha256": "33e322b544f07118d4c1a8a56e9e84e5a5c06f86c9fcbf749359b2a1042eebd3" + }, + { + "destination": "bin/ffprobe.exe", + "max_bytes": 6568960, + "member": "bin/ffprobe.exe", + "role": "ffprobe", + "sha256": "f65a1b8874446cce3bda7112b89c6172a8c12983eade2b040cf3672e4fa5740b" + }, + { + "destination": "SHA256SUMS", + "max_bytes": 1049991, + "member": "SHA256SUMS", + "sha256": "bfde3e2e99d40bf8c50e3c1898ef1b60e7340ddc5bc353330e0736ad3c5e3244" + } + ], + "license": { + "expression": "LGPL-2.1-or-later", + "license_destination": "LICENSES/FFmpeg-LGPL-2.1-or-later.txt" + }, + "probe": { + "ffprobe_version_contains": "ffprobe version 8.1.2", + "forbidden_build_flags": [ + "--enable-gpl", + "--enable-nonfree" + ], + "required_build_flags": [ + "--disable-gpl", + "--disable-nonfree", + "--disable-version3", + "--disable-network" + ], + "required_encoders": [ + "mpeg4", + "png" + ], + "required_muxers": [ + "mp4", + "image2pipe" + ], + "version_contains": "ffmpeg version 8.1.2" + }, + "source": { + "build_workflow": ".github/workflows/ffmpeg-runtime.yml", + "sha256": "464beb5e7bf0c311e68b45ae2f04e9cc2af88851abb4082231742a74d97b524c", + "signature_url": "https://ffmpeg.org/releases/ffmpeg-8.1.2.tar.xz.asc", + "signing_key_fingerprint": "FCF986EA15E6E293A5644F10B4322F04D67658D8", + "url": "https://ffmpeg.org/releases/ffmpeg-8.1.2.tar.xz" + }, + "target": "x86_64-pc-windows-msvc", + "url": "https://github.com/OpenAdaptAI/openadapt-desktop/releases/download/ffmpeg-runtime-v8.1.2-r1/openadapt-ffmpeg-8.1.2-r1-x86_64-pc-windows-msvc.zip" + }, + { + "archive_max_bytes": 8018223, + "archive_sha256": "05b36093f1bc9476f7116056de504224f160fdec75484fe63ec539d6744c1ba8", + "build_id": "ffmpeg-8.1.2-r1-x86_64-unknown-linux-gnu", + "files": [ + { + "destination": "LICENSES/FFmpeg-LGPL-2.1-or-later.txt", + "max_bytes": 1075093, + "member": "LICENSES/FFmpeg-LGPL-2.1-or-later.txt", + "sha256": "246041b6ecf9bc32d718a62c57877c78b5eb397b6467e74ed7ae2626ab189c30" + }, + { + "destination": "LICENSES/FFmpeg-LICENSE.md", + "max_bytes": 1052922, + "member": "LICENSES/FFmpeg-LICENSE.md", + "sha256": "2e1d16c72fd74e12063776371da757322f8b77589386532f4fd8634bde7de1af" + }, + { + "destination": "PROVENANCE/BUILD.json", + "max_bytes": 1049120, + "member": "PROVENANCE/BUILD.json", + "sha256": "521c36eca112ef5909fcdc81583b7dd83e3d1733e42d324263b000baafe4e80b" + }, + { + "destination": "PROVENANCE/SOURCE.json", + "max_bytes": 1048880, + "member": "PROVENANCE/SOURCE.json", + "sha256": "d0f106418cf0a8c6795fd7d6fc23302e3932c75d364c83cb669e1645d9a47822" + }, + { + "destination": "PROVENANCE/configure-args.txt", + "max_bytes": 1049241, + "member": "PROVENANCE/configure-args.txt", + "sha256": "8e54304fae7f8ceb79bd24ec50297d91a7f259540a23b4f48bb3752e430a6741" + }, + { + "destination": "PROVENANCE/ffmpeg-buildconf.txt", + "max_bytes": 1050510, + "member": "PROVENANCE/ffmpeg-buildconf.txt", + "sha256": "746ac0913c8cb07da88077f2aeb0ca9b20e1c76edc96693907021b9854c1e002" + }, + { + "destination": "PROVENANCE/ffmpeg-encoders.txt", + "max_bytes": 1048921, + "member": "PROVENANCE/ffmpeg-encoders.txt", + "sha256": "8b7a14e6a40028b66c156384dab983d5ab7d58cf1f212c578992ae42fc4456b5" + }, + { + "destination": "PROVENANCE/ffmpeg-muxers.txt", + "max_bytes": 1048774, + "member": "PROVENANCE/ffmpeg-muxers.txt", + "sha256": "af77eb6b41f7d51a4d8ffa03408ccda69a8f04143f3f1f5b16f56f56e78bbf81" + }, + { + "destination": "PROVENANCE/ffmpeg-version.txt", + "max_bytes": 1049687, + "member": "PROVENANCE/ffmpeg-version.txt", + "sha256": "79b556cc09f82aaf3921bc931ec743a87fbf7289809fa1d1fbee5ffe9332074a" + }, + { + "destination": "PROVENANCE/ffprobe-buildconf.txt", + "max_bytes": 1050485, + "member": "PROVENANCE/ffprobe-buildconf.txt", + "sha256": "c31dbe4257beb6f9f9405873caf21bd5aae5943bbcc4bdb59aa69d804262cdc2" + }, + { + "destination": "PROVENANCE/ffprobe-version.txt", + "max_bytes": 1049662, + "member": "PROVENANCE/ffprobe-version.txt", + "sha256": "b15a18891460c78f941fba1e1e103368fff45dfd293908471cf457b1f9b809b9" + }, + { + "destination": "PROVENANCE/native-dependencies.txt", + "max_bytes": 1048865, + "member": "PROVENANCE/native-dependencies.txt", + "sha256": "66d1a73f2850ce49e3a351c2f536118d5870f62c3a87a98f485da314b3a71cf9" + }, + { + "destination": "bin/ffmpeg", + "max_bytes": 7143856, + "member": "bin/ffmpeg", + "role": "ffmpeg", + "sha256": "d1bb7bea5173ee9ef20b9fa7f6a290d53ae2d19fbdc90f14f491215a48b1c2b1" + }, + { + "destination": "bin/ffprobe", + "max_bytes": 6766896, + "member": "bin/ffprobe", + "role": "ffprobe", + "sha256": "6fd9ffcfa0a870c82b7cd0bf7b87cb75c8d44b82a157a415af5d5c2d3bee0d76" + }, + { + "destination": "SHA256SUMS", + "max_bytes": 1049884, + "member": "SHA256SUMS", + "sha256": "9cf8c494e22e12a3b8e1ff6d15815a6686a55360c1dd489ffecf1e9285f87a65" + } + ], + "license": { + "expression": "LGPL-2.1-or-later", + "license_destination": "LICENSES/FFmpeg-LGPL-2.1-or-later.txt" + }, + "probe": { + "ffprobe_version_contains": "ffprobe version 8.1.2", + "forbidden_build_flags": [ + "--enable-gpl", + "--enable-nonfree" + ], + "required_build_flags": [ + "--disable-gpl", + "--disable-nonfree", + "--disable-version3", + "--disable-network" + ], + "required_encoders": [ + "mpeg4", + "png" + ], + "required_muxers": [ + "mp4", + "image2pipe" + ], + "version_contains": "ffmpeg version 8.1.2" + }, + "source": { + "build_workflow": ".github/workflows/ffmpeg-runtime.yml", + "sha256": "464beb5e7bf0c311e68b45ae2f04e9cc2af88851abb4082231742a74d97b524c", + "signature_url": "https://ffmpeg.org/releases/ffmpeg-8.1.2.tar.xz.asc", + "signing_key_fingerprint": "FCF986EA15E6E293A5644F10B4322F04D67658D8", + "url": "https://ffmpeg.org/releases/ffmpeg-8.1.2.tar.xz" + }, + "target": "x86_64-unknown-linux-gnu", + "url": "https://github.com/OpenAdaptAI/openadapt-desktop/releases/download/ffmpeg-runtime-v8.1.2-r1/openadapt-ffmpeg-8.1.2-r1-x86_64-unknown-linux-gnu.zip" + } + ] +} diff --git a/src-tauri/src/ffmpeg.rs b/src-tauri/src/ffmpeg.rs new file mode 100644 index 0000000..cb036f3 --- /dev/null +++ b/src-tauri/src/ffmpeg.rs @@ -0,0 +1,1259 @@ +//! First-use provisioning for the separately licensed FFmpeg runtime. +//! +//! FFmpeg is deliberately not linked into the MIT application and is not +//! bundled in the Tauri installer or frozen Python sidecar. A release-reviewed +//! manifest pins one archive and every extracted file for each supported +//! target. The archive is downloaded on first use, verified before extraction, +//! probed as a separate process, and atomically promoted into app-local data. +//! The Python sidecar receives only the final absolute executable path. + +use std::collections::HashSet; +use std::fs::{self, File}; +use std::io::{Read, Write}; +use std::path::{Component, Path, PathBuf}; +use std::process::Stdio; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tauri::{AppHandle, Emitter, Manager, State}; +use tokio::io::AsyncWriteExt; +use tokio::process::Command; +use url::Url; +use zip::ZipArchive; + +const EMBEDDED_MANIFEST: &str = include_str!("../ffmpeg-runtime-manifest.json"); +const MANIFEST_SCHEMA_VERSION: u32 = 1; +const PROVISION_TIMEOUT: Duration = Duration::from_secs(10 * 60); +const PROBE_TIMEOUT: Duration = Duration::from_secs(15); +const COMPLETE_MARKER: &str = ".complete.json"; +const EVENT_NAME: &str = "runtime://ffmpeg-status"; + +#[derive(Debug, Clone, Deserialize)] +struct RuntimeManifest { + schema_version: u32, + runtime: String, + runtime_version: String, + artifacts: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +struct RuntimeArtifact { + target: String, + build_id: String, + url: String, + archive_sha256: String, + archive_max_bytes: u64, + files: Vec, + probe: RuntimeProbe, + source: SourceProvenance, + license: LicenseProvenance, +} + +#[derive(Debug, Clone, Deserialize)] +struct RuntimeFile { + member: String, + destination: String, + sha256: String, + max_bytes: u64, + #[serde(default)] + role: Option, +} + +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +enum RuntimeRole { + Ffmpeg, + Ffprobe, +} + +#[derive(Debug, Clone, Deserialize)] +struct RuntimeProbe { + version_contains: String, + ffprobe_version_contains: String, + required_build_flags: Vec, + forbidden_build_flags: Vec, + required_encoders: Vec, + required_muxers: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +struct SourceProvenance { + url: String, + sha256: String, + signature_url: String, + signing_key_fingerprint: String, + build_workflow: String, +} + +#[derive(Debug, Clone, Deserialize)] +struct LicenseProvenance { + expression: String, + license_destination: String, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ProvisionPhase { + Checking, + Downloading, + Verifying, + Ready, + Error, + Unavailable, +} + +#[derive(Debug, Clone, Serialize)] +pub struct FfmpegStatus { + pub phase: ProvisionPhase, + pub source: String, + pub runtime_version: String, + pub target: String, + pub path: Option, + pub ffprobe_path: Option, + pub detail: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct CompleteMarker { + schema_version: u32, + target: String, + build_id: String, + archive_sha256: String, + ffmpeg_sha256: String, + ffprobe_sha256: String, + source_sha256: String, + installed_at_unix: u64, +} + +#[derive(Debug)] +struct PreparedInstall { + staging_dir: PathBuf, + paths: RuntimePaths, +} + +#[derive(Debug, Clone)] +pub struct RuntimePaths { + pub ffmpeg: PathBuf, + pub ffprobe: PathBuf, +} + +pub struct FfmpegManager { + tools_root: PathBuf, + artifact: Option, + override_paths: Option, + effective_paths: Option, + status: Mutex, + in_flight: AtomicBool, +} + +pub struct FfmpegHandle(pub Arc); + +impl FfmpegManager { + pub fn from_app(app: &AppHandle) -> Result { + let tools_root = app + .path() + .app_local_data_dir() + .map_err(|error| format!("cannot resolve app-local data directory: {error}"))? + .join("tools") + .join("ffmpeg"); + let ffmpeg_override = std::env::var_os("OPENADAPT_FFMPEG_PATH").map(PathBuf::from); + let ffprobe_override = std::env::var_os("OPENADAPT_FFPROBE_PATH").map(PathBuf::from); + Self::new(tools_root, ffmpeg_override, ffprobe_override) + } + + fn new( + tools_root: PathBuf, + ffmpeg_override: Option, + ffprobe_override: Option, + ) -> Result { + let manifest: RuntimeManifest = serde_json::from_str(EMBEDDED_MANIFEST) + .map_err(|error| format!("invalid embedded FFmpeg manifest: {error}"))?; + validate_manifest(&manifest)?; + + let target = current_target().to_owned(); + let artifact = manifest + .artifacts + .iter() + .find(|artifact| artifact.target == target) + .cloned(); + + let (override_paths, override_error) = + validate_override_paths(ffmpeg_override, ffprobe_override); + + let effective_paths = if let Some(paths) = override_paths.as_ref() { + Some(paths.clone()) + } else { + artifact.as_ref().and_then(|artifact| { + let ffmpeg = runtime_file(artifact, RuntimeRole::Ffmpeg).ok()?; + let ffprobe = runtime_file(artifact, RuntimeRole::Ffprobe).ok()?; + let root = tools_root.join(&artifact.build_id); + Some(RuntimePaths { + ffmpeg: root.join(&ffmpeg.destination), + ffprobe: root.join(&ffprobe.destination), + }) + }) + }; + + let status = if let Some(error) = override_error { + FfmpegStatus { + phase: ProvisionPhase::Error, + source: "override".into(), + runtime_version: manifest.runtime_version.clone(), + target: target.clone(), + path: None, + ffprobe_path: None, + detail: Some(error), + } + } else if override_paths.is_some() { + FfmpegStatus { + phase: ProvisionPhase::Checking, + source: "override".into(), + runtime_version: manifest.runtime_version.clone(), + target: target.clone(), + path: effective_paths + .as_ref() + .map(|paths| path_display(&paths.ffmpeg)), + ffprobe_path: effective_paths + .as_ref() + .map(|paths| path_display(&paths.ffprobe)), + detail: None, + } + } else if artifact.is_some() { + FfmpegStatus { + phase: ProvisionPhase::Checking, + source: "managed".into(), + runtime_version: manifest.runtime_version.clone(), + target: target.clone(), + path: effective_paths + .as_ref() + .map(|paths| path_display(&paths.ffmpeg)), + ffprobe_path: effective_paths + .as_ref() + .map(|paths| path_display(&paths.ffprobe)), + detail: None, + } + } else { + FfmpegStatus { + phase: ProvisionPhase::Unavailable, + source: "managed".into(), + runtime_version: manifest.runtime_version.clone(), + target: target.clone(), + path: None, + ffprobe_path: None, + detail: Some( + "No release-reviewed FFmpeg runtime is published for this target. \ + Set OPENADAPT_FFMPEG_PATH and OPENADAPT_FFPROBE_PATH to \ + absolute local executables." + .into(), + ), + } + }; + + Ok(Self { + tools_root, + artifact, + override_paths, + effective_paths, + status: Mutex::new(status), + in_flight: AtomicBool::new(false), + }) + } + + pub fn effective_paths(&self) -> Option { + self.effective_paths.clone() + } + + pub fn status(&self) -> FfmpegStatus { + self.status.lock().unwrap().clone() + } + + fn set_status(&self, app: &AppHandle, phase: ProvisionPhase, detail: Option) { + let mut status = self.status.lock().unwrap(); + status.phase = phase; + status.detail = detail; + let payload = status.clone(); + drop(status); + let _ = app.emit(EVENT_NAME, payload); + } + + async fn ensure_ready(&self, app: &AppHandle) -> Result<(), String> { + if let Some(paths) = self.override_paths.as_ref() { + self.set_status(app, ProvisionPhase::Verifying, None); + probe_executables(paths, &override_probe()).await?; + self.set_status(app, ProvisionPhase::Ready, None); + return Ok(()); + } + + let artifact = self + .artifact + .as_ref() + .ok_or_else(|| "no managed FFmpeg runtime is available for this target".to_owned())?; + let final_dir = self.tools_root.join(&artifact.build_id); + if cache_is_valid(&final_dir, artifact)? { + let paths = runtime_paths(&final_dir, artifact)?; + self.set_status(app, ProvisionPhase::Verifying, None); + probe_executables(&paths, &artifact.probe).await?; + self.set_status(app, ProvisionPhase::Ready, None); + return Ok(()); + } + + self.set_status(app, ProvisionPhase::Downloading, None); + fs::create_dir_all(&self.tools_root) + .map_err(|error| format!("cannot create FFmpeg tools directory: {error}"))?; + let archive_path = unique_sibling(&self.tools_root, "download", "zip"); + if let Err(error) = download_archive(artifact, &archive_path).await { + let _ = tokio::fs::remove_file(&archive_path).await; + return Err(error); + } + + self.set_status(app, ProvisionPhase::Verifying, None); + let tools_root = self.tools_root.clone(); + let archive_for_extract = archive_path.clone(); + let artifact_for_extract = artifact.clone(); + let prepared = tokio::task::spawn_blocking(move || { + prepare_install(&tools_root, &archive_for_extract, &artifact_for_extract) + }) + .await + .map_err(|error| format!("FFmpeg extraction worker failed: {error}"))??; + let _ = tokio::fs::remove_file(&archive_path).await; + + if let Err(error) = probe_executables(&prepared.paths, &artifact.probe).await { + let _ = fs::remove_dir_all(&prepared.staging_dir); + return Err(error); + } + + write_complete_marker(&prepared.staging_dir, artifact)?; + promote_install( + &self.tools_root, + &prepared.staging_dir, + &final_dir, + &artifact.build_id, + )?; + self.set_status(app, ProvisionPhase::Ready, None); + Ok(()) + } +} + +pub fn start_provisioning(app: AppHandle, manager: Arc) { + if manager.in_flight.swap(true, Ordering::SeqCst) { + return; + } + let manager_for_task = manager.clone(); + tauri::async_runtime::spawn(async move { + let result = + tokio::time::timeout(PROVISION_TIMEOUT, manager_for_task.ensure_ready(&app)).await; + let error = match result { + Ok(Ok(())) => None, + Ok(Err(error)) => Some(error), + Err(_) => Some("FFmpeg provisioning timed out".to_owned()), + }; + if let Some(error) = error { + manager_for_task.set_status(&app, ProvisionPhase::Error, Some(error)); + } + manager_for_task.in_flight.store(false, Ordering::SeqCst); + }); +} + +#[tauri::command] +pub fn ffmpeg_status(state: State<'_, FfmpegHandle>) -> FfmpegStatus { + state.0.status() +} + +#[tauri::command] +pub fn retry_ffmpeg_provisioning(app: AppHandle, state: State<'_, FfmpegHandle>) -> FfmpegStatus { + start_provisioning(app, state.0.clone()); + state.0.status() +} + +fn validate_manifest(manifest: &RuntimeManifest) -> Result<(), String> { + if manifest.schema_version != MANIFEST_SCHEMA_VERSION + || manifest.runtime != "ffmpeg" + || manifest.runtime_version.trim().is_empty() + { + return Err("unsupported FFmpeg runtime manifest header".into()); + } + + let mut targets = HashSet::new(); + for artifact in &manifest.artifacts { + if !targets.insert(artifact.target.as_str()) { + return Err(format!( + "duplicate FFmpeg artifact target: {}", + artifact.target + )); + } + validate_segment(&artifact.build_id, "build_id")?; + validate_sha256(&artifact.archive_sha256)?; + if artifact.archive_max_bytes == 0 { + return Err(format!( + "{} has an empty archive size limit", + artifact.target + )); + } + let url = Url::parse(&artifact.url) + .map_err(|error| format!("invalid FFmpeg artifact URL: {error}"))?; + if url.scheme() != "https" || url.username() != "" || url.password().is_some() { + return Err("FFmpeg artifact URL must be credential-free HTTPS".into()); + } + if artifact.files.is_empty() { + return Err(format!("{} has no pinned files", artifact.target)); + } + let mut destinations = HashSet::new(); + let mut roles = HashSet::new(); + for file in &artifact.files { + validate_relative_path(&file.member)?; + validate_relative_path(&file.destination)?; + validate_sha256(&file.sha256)?; + if file.max_bytes == 0 { + return Err(format!("{} has an empty file size limit", file.destination)); + } + if !destinations.insert(file.destination.as_str()) { + return Err(format!( + "duplicate FFmpeg destination: {}", + file.destination + )); + } + if let Some(role) = file.role { + if !roles.insert(role) { + return Err(format!("duplicate FFmpeg runtime role: {role:?}")); + } + } + } + if roles != HashSet::from([RuntimeRole::Ffmpeg, RuntimeRole::Ffprobe]) { + return Err(format!( + "{} must identify exactly one ffmpeg and one ffprobe executable", + artifact.target + )); + } + + if artifact.probe.version_contains.trim().is_empty() + || artifact.probe.ffprobe_version_contains.trim().is_empty() + || artifact.probe.required_encoders.is_empty() + || artifact.probe.required_muxers.is_empty() + { + return Err(format!( + "{} has an incomplete runtime probe", + artifact.target + )); + } + let source_url = Url::parse(&artifact.source.url) + .map_err(|error| format!("invalid FFmpeg source URL: {error}"))?; + let signature_url = Url::parse(&artifact.source.signature_url) + .map_err(|error| format!("invalid FFmpeg signature URL: {error}"))?; + if source_url.scheme() != "https" || signature_url.scheme() != "https" { + return Err("FFmpeg source and signature URLs must use HTTPS".into()); + } + validate_sha256(&artifact.source.sha256)?; + if artifact.source.signing_key_fingerprint.len() != 40 + || !artifact + .source + .signing_key_fingerprint + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + || artifact.source.build_workflow.trim().is_empty() + { + return Err(format!( + "{} has incomplete source provenance", + artifact.target + )); + } + if artifact.license.expression.trim().is_empty() + || !artifact + .files + .iter() + .any(|file| file.destination == artifact.license.license_destination) + { + return Err(format!( + "{} has incomplete license provenance", + artifact.target + )); + } + } + Ok(()) +} + +fn runtime_file(artifact: &RuntimeArtifact, role: RuntimeRole) -> Result<&RuntimeFile, String> { + artifact + .files + .iter() + .find(|file| file.role == Some(role)) + .ok_or_else(|| format!("{} does not identify {role:?}", artifact.target)) +} + +fn runtime_paths(root: &Path, artifact: &RuntimeArtifact) -> Result { + Ok(RuntimePaths { + ffmpeg: root.join(&runtime_file(artifact, RuntimeRole::Ffmpeg)?.destination), + ffprobe: root.join(&runtime_file(artifact, RuntimeRole::Ffprobe)?.destination), + }) +} + +fn validate_override_paths( + ffmpeg: Option, + ffprobe: Option, +) -> (Option, Option) { + let Some(ffmpeg) = ffmpeg else { + return if ffprobe.is_some() { + ( + None, + Some("OPENADAPT_FFPROBE_PATH requires OPENADAPT_FFMPEG_PATH".into()), + ) + } else { + (None, None) + }; + }; + let ffprobe = ffprobe.unwrap_or_else(|| { + let filename = if cfg!(windows) { + "ffprobe.exe" + } else { + "ffprobe" + }; + ffmpeg + .parent() + .unwrap_or_else(|| Path::new("")) + .join(filename) + }); + match ( + validate_override_path(&ffmpeg, "OPENADAPT_FFMPEG_PATH"), + validate_override_path(&ffprobe, "OPENADAPT_FFPROBE_PATH"), + ) { + (Ok(ffmpeg), Ok(ffprobe)) => (Some(RuntimePaths { ffmpeg, ffprobe }), None), + (Err(error), _) | (_, Err(error)) => (None, Some(error)), + } +} + +fn validate_override_path(path: &Path, variable: &str) -> Result { + if !path.is_absolute() { + return Err(format!("{variable} must be absolute")); + } + let canonical = + fs::canonicalize(path).map_err(|error| format!("{variable} is unavailable: {error}"))?; + let metadata = + fs::metadata(&canonical).map_err(|error| format!("cannot inspect {variable}: {error}"))?; + if !metadata.is_file() { + return Err(format!("{variable} must name a regular file")); + } + Ok(canonical) +} + +fn current_target() -> &'static str { + match (std::env::consts::OS, std::env::consts::ARCH) { + ("macos", "aarch64") => "aarch64-apple-darwin", + ("macos", "x86_64") => "x86_64-apple-darwin", + ("windows", "x86_64") => "x86_64-pc-windows-msvc", + ("linux", "x86_64") => "x86_64-unknown-linux-gnu", + _ => "unsupported", + } +} + +fn validate_segment(value: &str, label: &str) -> Result<(), String> { + if value.is_empty() + || value == "." + || value == ".." + || value.contains('/') + || value.contains('\\') + { + return Err(format!("invalid FFmpeg {label}: {value}")); + } + Ok(()) +} + +fn validate_relative_path(value: &str) -> Result<(), String> { + if value.is_empty() || value.contains('\\') { + return Err(format!("invalid FFmpeg archive path: {value}")); + } + let path = Path::new(value); + if path.is_absolute() + || path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err(format!("unsafe FFmpeg archive path: {value}")); + } + Ok(()) +} + +fn validate_sha256(value: &str) -> Result<(), String> { + if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(format!("invalid SHA-256 value: {value}")); + } + Ok(()) +} + +async fn download_archive(artifact: &RuntimeArtifact, destination: &Path) -> Result<(), String> { + let client = download_client()?; + let mut response = client + .get(&artifact.url) + .send() + .await + .map_err(|error| format!("FFmpeg download failed: {error}"))? + .error_for_status() + .map_err(|error| format!("FFmpeg download failed: {error}"))?; + if response + .content_length() + .is_some_and(|length| length > artifact.archive_max_bytes) + { + return Err("FFmpeg archive exceeds its pinned size limit".into()); + } + + let mut file = tokio::fs::File::create(destination) + .await + .map_err(|error| format!("cannot create FFmpeg download: {error}"))?; + let mut hasher = Sha256::new(); + let mut written = 0_u64; + while let Some(chunk) = response + .chunk() + .await + .map_err(|error| format!("FFmpeg download stream failed: {error}"))? + { + written = written + .checked_add(chunk.len() as u64) + .ok_or_else(|| "FFmpeg archive size overflow".to_owned())?; + if written > artifact.archive_max_bytes { + return Err("FFmpeg archive exceeds its pinned size limit".into()); + } + hasher.update(&chunk); + file.write_all(&chunk) + .await + .map_err(|error| format!("cannot write FFmpeg download: {error}"))?; + } + file.sync_all() + .await + .map_err(|error| format!("cannot sync FFmpeg download: {error}"))?; + let digest = hex::encode(hasher.finalize()); + if digest != artifact.archive_sha256.to_ascii_lowercase() { + return Err(format!( + "FFmpeg archive hash mismatch: expected {}, got {digest}", + artifact.archive_sha256 + )); + } + Ok(()) +} + +fn download_client() -> Result { + if rustls::crypto::CryptoProvider::get_default().is_none() { + rustls::crypto::ring::default_provider() + .install_default() + .map_err(|_| "cannot initialize FFmpeg downloader cryptography".to_owned())?; + } + reqwest::Client::builder() + .https_only(true) + .connect_timeout(Duration::from_secs(20)) + .timeout(PROVISION_TIMEOUT) + .user_agent("OpenAdapt-Desktop/ffmpeg-provisioner") + .build() + .map_err(|error| format!("cannot initialize FFmpeg downloader: {error}")) +} + +fn prepare_install( + tools_root: &Path, + archive_path: &Path, + artifact: &RuntimeArtifact, +) -> Result { + let staging_dir = unique_sibling(tools_root, &artifact.build_id, "staging"); + fs::create_dir_all(&staging_dir) + .map_err(|error| format!("cannot create FFmpeg staging directory: {error}"))?; + + let result = (|| { + let archive_file = File::open(archive_path) + .map_err(|error| format!("cannot open FFmpeg archive: {error}"))?; + let mut archive = ZipArchive::new(archive_file) + .map_err(|error| format!("invalid FFmpeg ZIP archive: {error}"))?; + reject_unexpected_archive_members(&mut archive, artifact)?; + + for pinned in &artifact.files { + let mut member = archive + .by_name(&pinned.member) + .map_err(|error| format!("missing FFmpeg member {}: {error}", pinned.member))?; + if member.is_dir() || member.size() > pinned.max_bytes { + return Err(format!( + "FFmpeg member {} violates its size/type contract", + pinned.member + )); + } + let destination = staging_dir.join(&pinned.destination); + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("cannot stage FFmpeg member: {error}"))?; + } + let mut output = File::create(&destination) + .map_err(|error| format!("cannot create staged FFmpeg member: {error}"))?; + let mut hasher = Sha256::new(); + let mut copied = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + let count = member + .read(&mut buffer) + .map_err(|error| format!("cannot read FFmpeg member: {error}"))?; + if count == 0 { + break; + } + copied = copied + .checked_add(count as u64) + .ok_or_else(|| "FFmpeg member size overflow".to_owned())?; + if copied > pinned.max_bytes { + return Err(format!( + "FFmpeg member {} exceeds its size limit", + pinned.member + )); + } + hasher.update(&buffer[..count]); + output + .write_all(&buffer[..count]) + .map_err(|error| format!("cannot write FFmpeg member: {error}"))?; + } + output + .sync_all() + .map_err(|error| format!("cannot sync FFmpeg member: {error}"))?; + let digest = hex::encode(hasher.finalize()); + if digest != pinned.sha256.to_ascii_lowercase() { + return Err(format!( + "FFmpeg member hash mismatch for {}: expected {}, got {digest}", + pinned.member, pinned.sha256 + )); + } + } + + let paths = runtime_paths(&staging_dir, artifact)?; + make_executable(&paths.ffmpeg)?; + make_executable(&paths.ffprobe)?; + Ok(PreparedInstall { + staging_dir: staging_dir.clone(), + paths, + }) + })(); + + if result.is_err() { + let _ = fs::remove_dir_all(&staging_dir); + } + result +} + +fn reject_unexpected_archive_members( + archive: &mut ZipArchive, + artifact: &RuntimeArtifact, +) -> Result<(), String> { + let expected: HashSet<&str> = artifact + .files + .iter() + .map(|file| file.member.as_str()) + .collect(); + let expected_directories: HashSet = artifact + .files + .iter() + .flat_map(|file| { + let mut ancestors = Vec::new(); + let mut path = PathBuf::new(); + for component in Path::new(&file.member).components() { + path.push(component.as_os_str()); + if path != Path::new(&file.member) { + ancestors.push(format!("{}/", path.to_string_lossy())); + } + } + ancestors + }) + .collect(); + + for index in 0..archive.len() { + let member = archive + .by_index(index) + .map_err(|error| format!("cannot inspect FFmpeg archive: {error}"))?; + let name = member.name(); + validate_relative_path(name.trim_end_matches('/'))?; + if member.is_dir() { + if !expected_directories.contains(name) { + return Err(format!("unexpected FFmpeg archive directory: {name}")); + } + } else if !expected.contains(name) { + return Err(format!("unexpected FFmpeg archive member: {name}")); + } + } + Ok(()) +} + +fn make_executable(path: &Path) -> Result<(), String> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut permissions = fs::metadata(path) + .map_err(|error| format!("cannot inspect staged FFmpeg: {error}"))? + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions) + .map_err(|error| format!("cannot mark staged FFmpeg executable: {error}"))?; + } + Ok(()) +} + +async fn probe_executables(paths: &RuntimePaths, probe: &RuntimeProbe) -> Result<(), String> { + let version = checked_output(&paths.ffmpeg, &["-version"]).await?; + if !version.contains(&probe.version_contains) { + return Err(format!( + "FFmpeg version probe did not contain {:?}", + probe.version_contains + )); + } + + let buildconf = checked_output(&paths.ffmpeg, &["-buildconf"]).await?; + for required in &probe.required_build_flags { + if !buildconf.contains(required) { + return Err(format!("FFmpeg is missing required build flag {required}")); + } + } + for forbidden in &probe.forbidden_build_flags { + if buildconf.contains(forbidden) { + return Err(format!("FFmpeg contains forbidden build flag {forbidden}")); + } + } + + let encoders = checked_output(&paths.ffmpeg, &["-hide_banner", "-encoders"]).await?; + let encoder_names = capability_names(&encoders); + for required in &probe.required_encoders { + if !encoder_names.contains(required.as_str()) { + return Err(format!("FFmpeg is missing required encoder {required}")); + } + } + + let muxers = checked_output(&paths.ffmpeg, &["-hide_banner", "-muxers"]).await?; + let muxer_names = capability_names(&muxers); + for required in &probe.required_muxers { + if !muxer_names.contains(required.as_str()) { + return Err(format!("FFmpeg is missing required muxer {required}")); + } + } + let probe_version = checked_output(&paths.ffprobe, &["-version"]).await?; + if !probe_version.contains(&probe.ffprobe_version_contains) { + return Err(format!( + "ffprobe version probe did not contain {:?}", + probe.ffprobe_version_contains + )); + } + let probe_buildconf = checked_output(&paths.ffprobe, &["-buildconf"]).await?; + for required in &probe.required_build_flags { + if !probe_buildconf.contains(required) { + return Err(format!("ffprobe is missing required build flag {required}")); + } + } + for forbidden in &probe.forbidden_build_flags { + if probe_buildconf.contains(forbidden) { + return Err(format!("ffprobe contains forbidden build flag {forbidden}")); + } + } + Ok(()) +} + +async fn checked_output(path: &Path, args: &[&str]) -> Result { + let mut command = Command::new(path); + command.args(args).stdin(Stdio::null()).kill_on_drop(true); + let output = tokio::time::timeout(PROBE_TIMEOUT, command.output()) + .await + .map_err(|_| format!("FFmpeg probe timed out: {}", args.join(" ")))? + .map_err(|error| format!("cannot execute FFmpeg probe: {error}"))?; + if !output.status.success() { + return Err(format!( + "FFmpeg probe failed ({}): {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(format!( + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + )) +} + +fn capability_names(output: &str) -> HashSet<&str> { + output + .lines() + .filter_map(|line| { + let mut fields = line.split_whitespace(); + let flags = fields.next()?; + let name = fields.next()?; + if flags + .bytes() + .all(|byte| byte.is_ascii_alphabetic() || byte == b'.') + { + Some(name) + } else { + None + } + }) + .collect() +} + +fn override_probe() -> RuntimeProbe { + RuntimeProbe { + version_contains: "ffmpeg version".into(), + ffprobe_version_contains: "ffprobe version".into(), + required_build_flags: Vec::new(), + forbidden_build_flags: Vec::new(), + required_encoders: vec!["mpeg4".into()], + required_muxers: vec!["mp4".into()], + } +} + +fn cache_is_valid(final_dir: &Path, artifact: &RuntimeArtifact) -> Result { + if !final_dir.is_dir() { + return Ok(false); + } + let marker_path = final_dir.join(COMPLETE_MARKER); + let marker: CompleteMarker = match fs::read(&marker_path) + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + { + Some(marker) => marker, + None => return Ok(false), + }; + let ffmpeg = runtime_file(artifact, RuntimeRole::Ffmpeg)?; + let ffprobe = runtime_file(artifact, RuntimeRole::Ffprobe)?; + if marker.schema_version != MANIFEST_SCHEMA_VERSION + || marker.target != artifact.target + || marker.build_id != artifact.build_id + || marker.archive_sha256 != artifact.archive_sha256 + || marker.ffmpeg_sha256 != ffmpeg.sha256 + || marker.ffprobe_sha256 != ffprobe.sha256 + || marker.source_sha256 != artifact.source.sha256 + { + return Ok(false); + } + for pinned in &artifact.files { + let path = final_dir.join(&pinned.destination); + if !path.is_file() || sha256_file(&path)? != pinned.sha256.to_ascii_lowercase() { + return Ok(false); + } + } + Ok(true) +} + +fn write_complete_marker(staging_dir: &Path, artifact: &RuntimeArtifact) -> Result<(), String> { + let ffmpeg = runtime_file(artifact, RuntimeRole::Ffmpeg)?; + let ffprobe = runtime_file(artifact, RuntimeRole::Ffprobe)?; + let marker = CompleteMarker { + schema_version: MANIFEST_SCHEMA_VERSION, + target: artifact.target.clone(), + build_id: artifact.build_id.clone(), + archive_sha256: artifact.archive_sha256.clone(), + ffmpeg_sha256: ffmpeg.sha256.clone(), + ffprobe_sha256: ffprobe.sha256.clone(), + source_sha256: artifact.source.sha256.clone(), + installed_at_unix: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + }; + let path = staging_dir.join(COMPLETE_MARKER); + let mut file = File::create(&path) + .map_err(|error| format!("cannot create FFmpeg completion marker: {error}"))?; + serde_json::to_writer_pretty(&mut file, &marker) + .map_err(|error| format!("cannot write FFmpeg completion marker: {error}"))?; + file.write_all(b"\n") + .map_err(|error| format!("cannot finish FFmpeg completion marker: {error}"))?; + file.sync_all() + .map_err(|error| format!("cannot sync FFmpeg completion marker: {error}")) +} + +fn promote_install( + tools_root: &Path, + staging_dir: &Path, + final_dir: &Path, + build_id: &str, +) -> Result<(), String> { + let quarantine = unique_sibling(tools_root, build_id, "replaced"); + let had_previous = final_dir.exists(); + if had_previous { + fs::rename(final_dir, &quarantine) + .map_err(|error| format!("cannot quarantine stale FFmpeg runtime: {error}"))?; + } + if let Err(error) = fs::rename(staging_dir, final_dir) { + if had_previous { + let _ = fs::rename(&quarantine, final_dir); + } + return Err(format!("cannot atomically install FFmpeg runtime: {error}")); + } + if had_previous { + let _ = fs::remove_dir_all(quarantine); + } + Ok(()) +} + +fn sha256_file(path: &Path) -> Result { + let mut file = + File::open(path).map_err(|error| format!("cannot hash {}: {error}", path_display(path)))?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let count = file + .read(&mut buffer) + .map_err(|error| format!("cannot hash {}: {error}", path_display(path)))?; + if count == 0 { + break; + } + hasher.update(&buffer[..count]); + } + Ok(hex::encode(hasher.finalize())) +} + +fn unique_sibling(root: &Path, stem: &str, extension: &str) -> PathBuf { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + root.join(format!(".{stem}-{}-{now}.{extension}", std::process::id())) +} + +fn path_display(path: &Path) -> String { + path.to_string_lossy().into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + use zip::write::SimpleFileOptions; + use zip::ZipWriter; + + fn digest(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) + } + + fn fixture_artifact(ffmpeg: &[u8], ffprobe: &[u8], license: &[u8]) -> RuntimeArtifact { + RuntimeArtifact { + target: current_target().into(), + build_id: "ffmpeg-8.1.2-r1-test".into(), + url: "https://example.invalid/ffmpeg.zip".into(), + archive_sha256: "a".repeat(64), + archive_max_bytes: 10_000, + files: vec![ + RuntimeFile { + member: "bin/ffmpeg".into(), + destination: if cfg!(windows) { + "bin/ffmpeg.exe".into() + } else { + "bin/ffmpeg".into() + }, + sha256: digest(ffmpeg), + max_bytes: 1_000, + role: Some(RuntimeRole::Ffmpeg), + }, + RuntimeFile { + member: "bin/ffprobe".into(), + destination: if cfg!(windows) { + "bin/ffprobe.exe".into() + } else { + "bin/ffprobe".into() + }, + sha256: digest(ffprobe), + max_bytes: 1_000, + role: Some(RuntimeRole::Ffprobe), + }, + RuntimeFile { + member: "LICENSES/FFmpeg.txt".into(), + destination: "LICENSES/FFmpeg.txt".into(), + sha256: digest(license), + max_bytes: 1_000, + role: None, + }, + ], + probe: RuntimeProbe { + version_contains: "ffmpeg version 8.1.2".into(), + ffprobe_version_contains: "ffprobe version 8.1.2".into(), + required_build_flags: vec!["--disable-gpl".into()], + forbidden_build_flags: vec!["--enable-nonfree".into()], + required_encoders: vec!["mpeg4".into()], + required_muxers: vec!["mp4".into()], + }, + source: SourceProvenance { + url: "https://ffmpeg.org/releases/ffmpeg-8.1.2.tar.xz".into(), + sha256: "b".repeat(64), + signature_url: "https://ffmpeg.org/releases/ffmpeg-8.1.2.tar.xz.asc".into(), + signing_key_fingerprint: "FCF986EA15E6E293A5644F10B4322F04D67658D8".into(), + build_workflow: ".github/workflows/ffmpeg-runtime.yml".into(), + }, + license: LicenseProvenance { + expression: "LGPL-2.1-or-later".into(), + license_destination: "LICENSES/FFmpeg.txt".into(), + }, + } + } + + fn write_fixture_zip( + path: &Path, + ffmpeg: &[u8], + ffprobe: &[u8], + license: &[u8], + extra: Option<(&str, &[u8])>, + ) { + let file = File::create(path).unwrap(); + let mut archive = ZipWriter::new(file); + let options = SimpleFileOptions::default(); + archive.start_file("bin/ffmpeg", options).unwrap(); + archive.write_all(ffmpeg).unwrap(); + archive.start_file("bin/ffprobe", options).unwrap(); + archive.write_all(ffprobe).unwrap(); + archive.start_file("LICENSES/FFmpeg.txt", options).unwrap(); + archive.write_all(license).unwrap(); + if let Some((name, bytes)) = extra { + archive.start_file(name, options).unwrap(); + archive.write_all(bytes).unwrap(); + } + archive.finish().unwrap(); + } + + #[test] + fn embedded_manifest_contains_complete_reviewed_release() { + let manifest: RuntimeManifest = serde_json::from_str(EMBEDDED_MANIFEST).unwrap(); + validate_manifest(&manifest).unwrap(); + let targets = manifest + .artifacts + .iter() + .map(|artifact| artifact.target.as_str()) + .collect::>(); + assert_eq!( + targets, + HashSet::from([ + "aarch64-apple-darwin", + "x86_64-apple-darwin", + "x86_64-pc-windows-msvc", + "x86_64-unknown-linux-gnu", + ]) + ); + } + + #[test] + fn manifest_requires_safe_exact_files_and_complete_provenance() { + let mut manifest = RuntimeManifest { + schema_version: 1, + runtime: "ffmpeg".into(), + runtime_version: "8.1.2-r1".into(), + artifacts: vec![fixture_artifact(b"ffmpeg", b"ffprobe", b"license")], + }; + validate_manifest(&manifest).unwrap(); + + manifest.artifacts[0].files[0].destination = "../ffmpeg".into(); + assert!(validate_manifest(&manifest) + .unwrap_err() + .contains("unsafe FFmpeg archive path")); + manifest.artifacts[0].files[0].destination = "bin/ffmpeg".into(); + manifest.artifacts[0].source.signing_key_fingerprint = "unknown".into(); + assert!(validate_manifest(&manifest) + .unwrap_err() + .contains("incomplete source provenance")); + } + + #[test] + fn exact_archive_members_extract_and_hash_validate() { + let temp = tempfile::tempdir().unwrap(); + let archive = temp.path().join("runtime.zip"); + let ffmpeg = b"not executed in this extraction test"; + let ffprobe = b"not executed either"; + let license = b"LGPL text"; + write_fixture_zip(&archive, ffmpeg, ffprobe, license, None); + let artifact = fixture_artifact(ffmpeg, ffprobe, license); + + let prepared = prepare_install(temp.path(), &archive, &artifact).unwrap(); + assert_eq!( + fs::read(prepared.staging_dir.join("bin/ffmpeg")).unwrap(), + ffmpeg + ); + assert_eq!( + fs::read(prepared.staging_dir.join("bin/ffprobe")).unwrap(), + ffprobe + ); + assert_eq!( + fs::read(prepared.staging_dir.join("LICENSES/FFmpeg.txt")).unwrap(), + license + ); + } + + #[test] + fn unexpected_or_hash_drifted_archive_members_fail_closed() { + let temp = tempfile::tempdir().unwrap(); + let ffmpeg = b"binary"; + let ffprobe = b"probe"; + let license = b"license"; + let artifact = fixture_artifact(ffmpeg, ffprobe, license); + + let extra_archive = temp.path().join("extra.zip"); + write_fixture_zip( + &extra_archive, + ffmpeg, + ffprobe, + license, + Some(("../escape", b"bad")), + ); + assert!(prepare_install(temp.path(), &extra_archive, &artifact) + .unwrap_err() + .contains("unsafe FFmpeg archive path")); + + let drift_archive = temp.path().join("drift.zip"); + write_fixture_zip(&drift_archive, b"changed", ffprobe, license, None); + assert!(prepare_install(temp.path(), &drift_archive, &artifact) + .unwrap_err() + .contains("hash mismatch")); + } + + #[test] + fn capability_parser_requires_exact_names() { + let output = " V..... mpeg4 MPEG-4\n V..... msmpeg4v3 Microsoft\n E mp4 MP4\n"; + let names = capability_names(output); + assert!(names.contains("mpeg4")); + assert!(names.contains("mp4")); + assert!(!names.contains("msmpeg4")); + } + + #[test] + fn override_path_must_be_absolute_regular_file() { + assert!(validate_override_path(Path::new("ffmpeg"), "FFMPEG").is_err()); + let temp = tempfile::tempdir().unwrap(); + assert!(validate_override_path(temp.path(), "FFMPEG").is_err()); + let executable = temp.path().join(if cfg!(windows) { + "ffmpeg.exe" + } else { + "ffmpeg" + }); + fs::write(&executable, b"fixture").unwrap(); + assert_eq!( + validate_override_path(&executable, "FFMPEG").unwrap(), + fs::canonicalize(executable).unwrap() + ); + } + + #[test] + fn downloader_initializes_its_tls_provider() { + download_client().unwrap(); + assert!(rustls::crypto::CryptoProvider::get_default().is_some()); + } + + #[test] + #[ignore = "set OPENADAPT_FFMPEG_PROOF_ARCHIVE and _MANIFEST_ENTRY"] + fn locally_built_managed_runtime_extracts_and_probes() { + let archive = PathBuf::from( + std::env::var_os("OPENADAPT_FFMPEG_PROOF_ARCHIVE") + .expect("OPENADAPT_FFMPEG_PROOF_ARCHIVE"), + ); + let entry_path = PathBuf::from( + std::env::var_os("OPENADAPT_FFMPEG_PROOF_MANIFEST_ENTRY") + .expect("OPENADAPT_FFMPEG_PROOF_MANIFEST_ENTRY"), + ); + let artifact: RuntimeArtifact = + serde_json::from_slice(&fs::read(entry_path).unwrap()).unwrap(); + let manifest = RuntimeManifest { + schema_version: 1, + runtime: "ffmpeg".into(), + runtime_version: "8.1.2-r1".into(), + artifacts: vec![artifact.clone()], + }; + validate_manifest(&manifest).unwrap(); + + let temp = tempfile::tempdir().unwrap(); + let prepared = prepare_install(temp.path(), &archive, &artifact).unwrap(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime + .block_on(probe_executables(&prepared.paths, &artifact.probe)) + .unwrap(); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 0acf8ee..cc0bb96 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -10,6 +10,7 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod commands; +mod ffmpeg; mod pairing; mod sidecar; mod tray; @@ -61,6 +62,11 @@ fn main() { .manage(SidecarHandle(engine.clone())) .setup(move |app| { tray::setup_tray(app)?; + let ffmpeg = Arc::new( + ffmpeg::FfmpegManager::from_app(&app.handle().clone()) + .map_err(std::io::Error::other)?, + ); + app.manage(ffmpeg::FfmpegHandle(ffmpeg.clone())); // Show the main window (config keeps it hidden until the frontend is // ready so there is no white flash). @@ -71,8 +77,13 @@ fn main() { // Spawn the frozen Python engine sidecar. Guarded: if the binary is // absent (frontend-only dev) the app still runs; the UI shows an // "engine offline" state. - sidecar::spawn(&app.handle().clone(), engine.clone()); + sidecar::spawn( + &app.handle().clone(), + engine.clone(), + ffmpeg.effective_paths(), + ); pairing::setup(app, engine.clone(), pairing_links.clone())?; + ffmpeg::start_provisioning(app.handle().clone(), ffmpeg); Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -80,6 +91,8 @@ fn main() { commands::engine_invoke, commands::sidecar_status, commands::open_external, + ffmpeg::ffmpeg_status, + ffmpeg::retry_ffmpeg_provisioning, // typed convenience commands (forward to the sidecar) commands::start_recording, commands::stop_recording, diff --git a/src-tauri/src/pairing.rs b/src-tauri/src/pairing.rs index e326bf6..12d22fe 100644 --- a/src-tauri/src/pairing.rs +++ b/src-tauri/src/pairing.rs @@ -252,7 +252,7 @@ mod tests { #[test] fn rejects_malformed_duplicate_and_unknown_fields() { for raw in [ - format!("openadapt://connect?pairing=short&host=https://app.openadapt.ai"), + "openadapt://connect?pairing=short&host=https://app.openadapt.ai".to_string(), format!("openadapt://connect?pairing={SECRET}"), format!( "openadapt://connect?pairing={SECRET}&pairing={SECRET}&host=https://app.openadapt.ai" diff --git a/src-tauri/src/sidecar.rs b/src-tauri/src/sidecar.rs index 8ed6789..b7240c1 100644 --- a/src-tauri/src/sidecar.rs +++ b/src-tauri/src/sidecar.rs @@ -31,6 +31,8 @@ use tauri_plugin_shell::process::{CommandChild, CommandEvent}; use tauri_plugin_shell::ShellExt; use tokio::sync::oneshot; +use crate::ffmpeg::RuntimePaths; + /// The frozen sidecar binary base name (see `tauri.conf.json` externalBin). const SIDECAR_NAME: &str = "openadapt-engine"; /// Fast IPC queries should fail quickly; workflow operations may include a @@ -65,6 +67,7 @@ pub struct SidecarInner { pending: Mutex>>, running: AtomicBool, seq: AtomicU64, + runtime_paths: Mutex>, } /// Managed-state wrapper so Tauri commands can reach the sidecar. @@ -144,12 +147,13 @@ impl SidecarInner { /// Guarded: if the frozen binary cannot be resolved or spawned, we log and /// return — the shell keeps running so the app builds and renders without a /// sidecar present (common during frontend development). -pub fn spawn(app: &AppHandle, inner: Arc) { +pub fn spawn(app: &AppHandle, inner: Arc, runtime_paths: Option) { + *inner.runtime_paths.lock().unwrap() = runtime_paths; spawn_with_attempt(app.clone(), inner, 0); } fn spawn_with_attempt(app: AppHandle, inner: Arc, attempt: u32) { - let command = match app.shell().sidecar(SIDECAR_NAME) { + let mut command = match app.shell().sidecar(SIDECAR_NAME) { Ok(cmd) => cmd, Err(e) => { eprintln!("[sidecar] '{SIDECAR_NAME}' not available (frontend-only mode): {e}"); @@ -157,6 +161,11 @@ fn spawn_with_attempt(app: AppHandle, inner: Arc, attempt: u32) { return; } }; + if let Some(paths) = inner.runtime_paths.lock().unwrap().as_ref() { + command = command + .env("OPENADAPT_FFMPEG_PATH", paths.ffmpeg.as_os_str()) + .env("OPENADAPT_FFPROBE_PATH", paths.ffprobe.as_os_str()); + } let (mut rx, child) = match command.spawn() { Ok(pair) => pair, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 7310d61..8c7aaf3 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -50,7 +50,9 @@ "externalBin": [ "binaries/openadapt-engine" ], - "macOS": {}, + "macOS": { + "entitlements": "Entitlements.plist" + }, "windows": { "digestAlgorithm": "sha256", "timestampUrl": "http://timestamp.digicert.com", diff --git a/src/lib/engine.ts b/src/lib/engine.ts index d358514..6c8e0ec 100644 --- a/src/lib/engine.ts +++ b/src/lib/engine.ts @@ -11,6 +11,7 @@ import { invoke } from "@tauri-apps/api/core"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import type { FfmpegRuntimeStatus } from "./types"; /** Commands the frontend sends to the engine (Tauri cmd === engine IPC cmd). */ export const CMD = { @@ -115,6 +116,45 @@ export async function sidecarRunning(): Promise { } } +const FFMPEG_BROWSER_FALLBACK: FfmpegRuntimeStatus = { + phase: "ready", + source: "managed", + runtime_version: "browser-preview", + target: "browser-preview", +}; + +/** Status of the separately provisioned local video runtime. */ +export async function ffmpegRuntimeStatus(): Promise { + if (!inTauri()) return FFMPEG_BROWSER_FALLBACK; + try { + return await invoke("ffmpeg_status"); + } catch (error) { + return { + phase: "error", + source: "managed", + runtime_version: "unknown", + target: "unknown", + detail: String(error), + }; + } +} + +/** Retry automatic first-use runtime provisioning. */ +export async function retryFfmpegRuntime(): Promise { + if (!inTauri()) return FFMPEG_BROWSER_FALLBACK; + return invoke("retry_ffmpeg_provisioning"); +} + +/** Subscribe to native runtime provisioning progress. */ +export function onFfmpegRuntimeStatus( + handler: (payload: FfmpegRuntimeStatus) => void, +): Promise { + if (!inTauri()) return Promise.resolve(() => {}); + return listen("runtime://ffmpeg-status", (event) => + handler(event.payload), + ); +} + /** Open a URL in the system browser (login deep-link, cloud dashboard, panes). */ export async function openExternal(url: string): Promise { try { diff --git a/src/lib/types.ts b/src/lib/types.ts index 9d9b95b..31d05dd 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -83,6 +83,22 @@ export interface PermissionStatus { input_monitoring: boolean; } +export interface FfmpegRuntimeStatus { + phase: + | "checking" + | "downloading" + | "verifying" + | "ready" + | "error" + | "unavailable"; + source: "managed" | "override"; + runtime_version: string; + target: string; + path?: string | null; + ffprobe_path?: string | null; + detail?: string | null; +} + // Runner lane (EXPERIMENTAL — outbound dispatch loop, spec §2). export type RunnerState = diff --git a/src/screens/Onboarding.tsx b/src/screens/Onboarding.tsx index 94d36e5..512bcc0 100644 --- a/src/screens/Onboarding.tsx +++ b/src/screens/Onboarding.tsx @@ -2,8 +2,15 @@ // OS permission gate and the honest "get past the OS warning" copy. Mirrors // the cloud seamless flow. import { useEffect, useState } from "react"; -import { CMD, engineTry, openExternal } from "../lib/engine"; -import type { PermissionStatus } from "../lib/types"; +import { + CMD, + engineTry, + ffmpegRuntimeStatus, + onFfmpegRuntimeStatus, + openExternal, + retryFfmpegRuntime, +} from "../lib/engine"; +import type { FfmpegRuntimeStatus, PermissionStatus } from "../lib/types"; import { Button, Card, CardHead, Callout, Pill } from "../ui/primitives"; import { OsWarning } from "../ui/OsWarning"; @@ -26,6 +33,12 @@ export function Onboarding({ onStart }: { onStart: () => void }) { }); const [checked, setChecked] = useState(false); const [requestingInput, setRequestingInput] = useState(false); + const [videoRuntime, setVideoRuntime] = useState({ + phase: "checking", + source: "managed", + runtime_version: "8.1.2-r1", + target: "detecting", + }); async function refresh() { const p = await engineTry( @@ -43,6 +56,12 @@ export function Onboarding({ onStart }: { onStart: () => void }) { useEffect(() => { void refresh(); + void ffmpegRuntimeStatus().then(setVideoRuntime); + let unlisten: (() => void) | undefined; + void onFfmpegRuntimeStatus(setVideoRuntime).then((stop) => { + unlisten = stop; + }); + return () => unlisten?.(); }, []); async function requestInputMonitoring() { @@ -59,11 +78,16 @@ export function Onboarding({ onStart }: { onStart: () => void }) { } } - const ready = + const permissionsReady = !MAC || (perms.screen_recording && perms.accessibility && perms.input_monitoring); + const videoReady = videoRuntime.phase === "ready"; + const ready = permissionsReady && videoReady; + const videoBusy = ["checking", "downloading", "verifying"].includes( + videoRuntime.phase, + ); return (
@@ -85,9 +109,16 @@ export function Onboarding({ onStart }: { onStart: () => void }) { Re-check permissions
- {!ready && checked && ( + {!permissionsReady && checked && (

Grant the permissions below to begin.

)} + {permissionsReady && !videoReady && ( +

+ {videoBusy + ? "Preparing the local video engine…" + : "The local video engine needs attention below."} +

+ )}
@@ -142,6 +173,59 @@ export function Onboarding({ onStart }: { onStart: () => void }) { )} + + +
+
+ + {videoReady + ? "ready" + : videoBusy + ? "preparing" + : "needs attention"} + + + + {videoBusy + ? "OpenAdapt is preparing video recording" + : videoReady + ? "Ready for video recording" + : "Video recording is not ready"} + + {!videoBusy && !videoReady && ( + + )} +
+ {videoRuntime.detail && !videoReady && ( + {videoRuntime.detail} + )} +

+ Video recording runs locally. Workflow data is not sent anywhere + during setup. +

+
+
+ diff --git a/tests/test_build_frozen_engine.py b/tests/test_build_frozen_engine.py index fc432ab..0e25a2d 100644 --- a/tests/test_build_frozen_engine.py +++ b/tests/test_build_frozen_engine.py @@ -31,12 +31,15 @@ def test_developer_id_signs_embedded_binaries_with_tauri_identity(tmp_path: Path index = command.index("--codesign-identity") assert command[index + 1] == "Developer ID Application: OpenAdapt AI (TEAM123)" + entitlements = command.index("--osx-entitlements-file") + assert command[entitlements + 1] == str(build.ROOT / "src-tauri" / "Entitlements.plist") def test_adhoc_build_does_not_enable_hardened_runtime_inside_onefile(tmp_path: Path) -> None: command = _build_command("-", tmp_path) assert "--codesign-identity" not in command + assert "--osx-entitlements-file" not in command for module in build.EXCLUDED_MODULES: assert ["--exclude-module", module] == command[ command.index(module) - 1 : command.index(module) + 1 @@ -54,6 +57,18 @@ def test_frozen_runtime_bundles_required_third_party_notices(tmp_path: Path) -> f"{build.RAPIDOCR_NOTICE_DIR / 'NOTICE'}:third_party/rapidocr", f"{tmp_path / 'frozen-notices'}:third_party/python", ] + assert ["--collect-data", "engine"] == command[ + command.index("engine") - 1 : command.index("engine") + 1 + ] + assert ["--hidden-import", "onnxruntime"] == command[ + command.index("onnxruntime") - 1 : command.index("onnxruntime") + 1 + ] + assert ["--hidden-import", "shapely"] == command[ + command.index("shapely") - 1 : command.index("shapely") + 1 + ] + assert ["--hidden-import", "numpy.core.multiarray"] == command[ + command.index("numpy.core.multiarray") - 1 : command.index("numpy.core.multiarray") + 1 + ] def test_missing_onnxruntime_notice_fails_closed(tmp_path: Path) -> None: @@ -94,10 +109,10 @@ def test_python_distribution_guard_runs_without_build_extra(tmp_path: Path) -> N def test_windows_frozen_inventory_member_paths_are_normalized() -> None: - windows_inventory = repr("third_party\\rapidocr\\LICENSE") + "\n" + windows_inventory = repr("third_party\\onnxruntime\\LICENSE") + "\n" normalized = verify.normalized_inventory(windows_inventory) - assert "third_party/rapidocr/LICENSE" in normalized + assert "third_party/onnxruntime/LICENSE" in normalized assert "//" not in normalized member_keys = verify.frozen_member_keys([r"third_party\python\NOTICE-INVENTORY.json"]) assert member_keys["third_party/python/NOTICE-INVENTORY.json"] == ( @@ -108,6 +123,20 @@ def test_windows_frozen_inventory_member_paths_are_normalized() -> None: def test_frozen_inventory_rejects_copyleft_module_names() -> None: assert verify.FORBIDDEN_FROZEN_MEMBERS.search("'oa_atomacos._a11y'") assert verify.FORBIDDEN_FROZEN_MEMBERS.search("'pynput.keyboard._darwin'") + assert verify.FORBIDDEN_FROZEN_MEMBERS.search("'scipy.fftpack'") + assert verify.FORBIDDEN_FROZEN_MEMBERS.search("'av._core'") + assert verify.FORBIDDEN_FROZEN_MEMBERS.search("libquadmath.0.dylib") + assert verify.FORBIDDEN_FROZEN_MEMBERS.search("libx264.165.dylib") + # Independently licensed media/vision components are governed by their + # separate runtime boundary; generic libav names are not blanket-banned. + assert not verify.FORBIDDEN_FROZEN_MEMBERS.search("cv2/.dylibs/libavcodec.61.dylib") + assert verify.FORBIDDEN_EMBEDDED_VISION_MEMBERS.search("cv2/.dylibs/libavcodec.61.dylib") + assert verify.FORBIDDEN_EMBEDDED_VISION_MEMBERS.search("'rapidocr_onnxruntime.main'") + assert verify.FORBIDDEN_EMBEDDED_VISION_MEMBERS.search( + "opencv_python-5.0.0.93.dist-info/LICENSE.txt" + ) + assert not verify.FORBIDDEN_FROZEN_MEMBERS.search("'java.util'") + assert not verify.FORBIDDEN_FROZEN_MEMBERS.search("'scipytools.helper'") def test_frozen_notice_inventory_binds_concrete_archive_bytes( @@ -233,6 +262,30 @@ def test_frozen_notice_inventory_rejects_copyleft_metadata() -> None: ) +def test_frozen_notice_inventory_rejects_managed_vision_package() -> None: + inventory = json.dumps( + { + "schema_version": 2, + "runtime_roots": list(verify.FROZEN_RUNTIME_ROOTS), + "packages": [ + { + "name": "opencv-python", + "version": "5.0.0.93", + "license_evidence": ["Apache-2.0"], + "notices": [], + } + ], + } + ).encode() + + with pytest.raises(ValueError, match="separately provisioned package"): + verify.validate_frozen_notice_inventory( + inventory, + members=set(), + extract_member=lambda member: b"", + ) + + def test_frozen_notice_inventory_rejects_metadata_only_package() -> None: inventory = json.dumps( { diff --git a/tests/test_engine/test_main.py b/tests/test_engine/test_main.py index 9024319..8cdfc8f 100644 --- a/tests/test_engine/test_main.py +++ b/tests/test_engine/test_main.py @@ -3,6 +3,7 @@ from __future__ import annotations import sys +from types import ModuleType from engine import __version__ from engine import main as engine_main @@ -66,6 +67,58 @@ def main() -> None: assert observed == [["engine", "install", "chromium"]] +def test_embedded_flow_version_stays_offline(monkeypatch, capsys) -> None: + managed = ModuleType("engine.managed_vision") + + def unexpected_provision() -> None: + raise AssertionError("version discovery must not provision optional vision") + + managed.ensure_managed_vision_runtime = unexpected_provision # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "engine.managed_vision", managed) + monkeypatch.setattr(engine_main, "_configure_frozen_browser_cache", lambda: None) + monkeypatch.setattr(engine_main, "_normalize_flow_auto_scrub_capability", lambda: None) + monkeypatch.setattr(engine_main, "_embedded_flow_version", lambda: "1.20.1") + monkeypatch.setattr(sys, "argv", ["engine", "__openadapt_flow__", "--version"]) + + engine_main._run_embedded_flow() + + assert capsys.readouterr().out == "openadapt-flow 1.20.1\n" + + +def test_embedded_flow_command_provisions_vision_before_import( + monkeypatch, +) -> None: + calls: list[str] = [] + monkeypatch.setattr(engine_main, "_configure_frozen_browser_cache", lambda: None) + monkeypatch.setattr(engine_main, "_normalize_flow_auto_scrub_capability", lambda: None) + monkeypatch.setattr(engine_main.sys, "frozen", True, raising=False) + monkeypatch.setattr( + sys, + "argv", + ["engine", "__openadapt_flow__", "lint", "bundle"], + ) + + managed = ModuleType("engine.managed_vision") + + def provision() -> None: + calls.append("provision") + + managed.ensure_managed_vision_runtime = provision # type: ignore[attr-defined] + flow = ModuleType("openadapt_flow.__main__") + + def flow_main() -> None: + calls.append("flow") + + flow.main = flow_main # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "engine.managed_vision", managed) + monkeypatch.setitem(sys.modules, "openadapt_flow.__main__", flow) + + engine_main._run_embedded_flow() + + assert calls == ["provision", "flow"] + assert sys.argv == ["engine", "lint", "bundle"] + + def test_incomplete_auto_scrubber_matches_flow_auto_fallback(monkeypatch) -> None: monkeypatch.delenv("OPENADAPT_FLOW_SCRUB", raising=False) monkeypatch.setattr( diff --git a/tests/test_ffmpeg_runtime_packaging.py b/tests/test_ffmpeg_runtime_packaging.py index 6c00e2e..ad9e3fe 100644 --- a/tests/test_ffmpeg_runtime_packaging.py +++ b/tests/test_ffmpeg_runtime_packaging.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import re import zipfile from pathlib import Path @@ -87,6 +88,42 @@ def test_runtime_manifest_refuses_missing_probe_or_license(tmp_path: Path) -> No manifest_entry(bundle, archive, "target", "build") +def test_embedded_runtime_manifest_pins_complete_reviewed_release() -> None: + manifest = json.loads( + ( + Path(__file__).resolve().parents[1] + / "src-tauri" + / "ffmpeg-runtime-manifest.json" + ).read_text() + ) + assert manifest["schema_version"] == 1 + assert manifest["runtime"] == "ffmpeg" + assert manifest["runtime_version"] == "8.1.2-r1" + artifacts = manifest["artifacts"] + assert {artifact["target"] for artifact in artifacts} == { + "aarch64-apple-darwin", + "x86_64-apple-darwin", + "x86_64-pc-windows-msvc", + "x86_64-unknown-linux-gnu", + } + for artifact in artifacts: + assert artifact["url"].startswith( + "https://github.com/OpenAdaptAI/openadapt-desktop/releases/download/" + "ffmpeg-runtime-v8.1.2-r1/" + ) + assert re.fullmatch(r"[0-9a-f]{64}", artifact["archive_sha256"]) + assert artifact["source"]["sha256"] == SOURCE_SHA256 + assert ( + artifact["source"]["signing_key_fingerprint"] + == SIGNING_KEY_FINGERPRINT + ) + assert { + file.get("role") + for file in artifact["files"] + if file.get("role") is not None + } == {"ffmpeg", "ffprobe"} + + def test_runtime_workflow_is_pinned_attested_and_separate_from_installers() -> None: root = Path(__file__).resolve().parents[1] workflow = (root / ".github" / "workflows" / "ffmpeg-runtime.yml").read_text() diff --git a/tests/test_frozen_notices.py b/tests/test_frozen_notices.py index a948424..4d10725 100644 --- a/tests/test_frozen_notices.py +++ b/tests/test_frozen_notices.py @@ -3,6 +3,7 @@ import hashlib import json from email.message import Message +from importlib.metadata import distribution from pathlib import Path, PurePosixPath import pytest @@ -100,6 +101,44 @@ def test_frozen_runtime_closure_excludes_build_tools(tmp_path: Path) -> None: assert "pyinstaller" not in closure +def test_frozen_runtime_closure_excludes_managed_vision_wheels( + tmp_path: Path, +) -> None: + distributions = { + "openadapt-desktop": FakeDistribution( + tmp_path / "desktop", + "openadapt-desktop", + requires=["openadapt-flow"], + ), + "openadapt-flow": FakeDistribution( + tmp_path / "flow", + "openadapt-flow", + requires=["opencv-python-headless", "rapidocr-onnxruntime"], + ), + "opencv-python-headless": FakeDistribution( + tmp_path / "opencv-headless", + "opencv-python-headless", + ), + "rapidocr-onnxruntime": FakeDistribution( + tmp_path / "rapidocr", + "rapidocr-onnxruntime", + requires=["opencv-python", "onnxruntime"], + ), + "opencv-python": FakeDistribution(tmp_path / "opencv", "opencv-python"), + "onnxruntime": FakeDistribution(tmp_path / "onnxruntime", "onnxruntime"), + } + + closure = notices.frozen_runtime_closure( + distribution_getter=distributions.__getitem__, + ) + + assert set(closure) == { + "onnxruntime", + "openadapt-desktop", + "openadapt-flow", + } + + def test_dependency_closure_terminates_on_cycles(tmp_path: Path) -> None: distributions = { "desktop": FakeDistribution( @@ -255,6 +294,152 @@ def test_notice_bundle_rejects_copyleft_before_staging(tmp_path: Path) -> None: ) +@pytest.mark.parametrize("name", ["av", "scipy"]) +def test_known_binary_runtime_boundary_rejects_permissive_wrapper_metadata( + tmp_path: Path, + name: str, +) -> None: + forbidden = FakeDistribution( + tmp_path / name, + name, + license_expression="BSD-3-Clause", + notice_files={"dist-info/LICENSE": "permissive wrapper terms\n"}, + ) + + with pytest.raises(RuntimeError, match="copyleft distribution"): + notices.prepare_notice_bundle( + tmp_path / "bundle", + root_license=tmp_path / "LICENSE", + closure={name: forbidden}, + required_notice_tokens={}, + ) + + +def test_reviewed_matplotlib_dual_license_uses_exact_ftl_evidence() -> None: + matplotlib = distribution("matplotlib") + evidence = notices.license_evidence(matplotlib) + + assert notices.COPYLEFT_LICENSE_RE.search("\n".join(evidence)) + assert not notices.has_unapproved_copyleft_evidence( + "matplotlib", + str(matplotlib.version), + evidence, + ) + + +def test_reviewed_dual_license_rejects_any_metadata_drift() -> None: + assert notices.has_unapproved_copyleft_evidence( + "matplotlib", + "3.11.1", + ["License: FTL OR GPL-2.0-or-later\nchanged"], + ) + + +def test_flatbuffers_uses_exact_reviewed_upstream_notice() -> None: + flatbuffers = distribution("flatbuffers") + + sources = notices._reviewed_external_notice_sources( # noqa: SLF001 + "flatbuffers", + flatbuffers, + ) + + assert len(sources) == 1 + source_member, source = sources[0] + assert notices.REVIEWED_EXTERNAL_NOTICE_FILES[ + ("flatbuffers", str(flatbuffers.version)) + ]["source_commit"] in source_member + assert hashlib.sha256(source.read_bytes()).hexdigest() == ( + "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30" + ) + + +def test_reviewed_external_notice_rejects_byte_drift(tmp_path: Path) -> None: + flatbuffers = distribution("flatbuffers") + notice = tmp_path / "notices" / "flatbuffers" / "LICENSE" + notice.parent.mkdir(parents=True) + notice.write_text("changed terms\n") + + with pytest.raises(RuntimeError, match="notice bytes require review"): + notices._reviewed_external_notice_sources( # noqa: SLF001 + "flatbuffers", + flatbuffers, + notice_root=tmp_path / "notices", + ) + + +def test_reviewed_external_notice_is_not_a_version_wildcard(tmp_path: Path) -> None: + flatbuffers = FakeDistribution( + tmp_path / "installed", + "flatbuffers", + version="25.12.20", + license_expression="Apache 2.0", + ) + + assert ( + notices._reviewed_external_notice_sources( # noqa: SLF001 + "flatbuffers", + flatbuffers, + notice_root=tmp_path / "notices", + ) + == [] + ) + + +def test_loguru_uses_exact_reviewed_upstream_notice() -> None: + loguru = distribution("loguru") + + sources = notices._reviewed_external_notice_sources( # noqa: SLF001 + "loguru", + loguru, + ) + + assert len(sources) == 1 + source_member, source = sources[0] + assert notices.REVIEWED_EXTERNAL_NOTICE_FILES[("loguru", str(loguru.version))][ + "source_commit" + ] in source_member + assert hashlib.sha256(source.read_bytes()).hexdigest() == ( + "b35d026cc7aca9d5859a02eb87ddf7a386a24c986838651bd1f283f94e003327" + ) + + +def test_rapidocr_uses_exact_reviewed_upstream_notice() -> None: + rapidocr = distribution("rapidocr-onnxruntime") + + sources = notices._reviewed_external_notice_sources( # noqa: SLF001 + "rapidocr-onnxruntime", + rapidocr, + ) + + assert len(sources) == 1 + assert hashlib.sha256(sources[0][1].read_bytes()).hexdigest() == ( + "3e0af25fdd06aa9586ae97adb00ea927ebe5a3805ac77d2d3a81ce5f55693333" + ) + + +def test_notice_discovery_ignores_binary_symbol_named_copying(tmp_path: Path) -> None: + pyobjc = FakeDistribution( + tmp_path / "pyobjc", + "pyobjc-core", + notice_files={ + ( + "PyObjCTest/copying.cpython-312-darwin.so.dSYM/" + "Contents/Resources/DWARF/copying.cpython-312-darwin.so" + ): "binary-like symbol payload", + }, + ) + + assert notices._notice_sources(pyobjc) == [] # noqa: SLF001 + + +def test_unreviewed_dual_license_is_not_a_blanket_exception() -> None: + assert notices.has_unapproved_copyleft_evidence( + "another-package", + "1.0.0", + ["License-Expression: MIT OR GPL-2.0-only"], + ) + + def test_first_party_mit_fallback_is_explicit_and_concrete( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_managed_vision.py b/tests/test_managed_vision.py new file mode 100644 index 0000000..d84dd4d --- /dev/null +++ b/tests/test_managed_vision.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +import hashlib +import io +import json +import zipfile +from pathlib import Path + +import pytest + +from engine import managed_vision as vision + + +def _wheel(tmp_path: Path, name: str, members: dict[str, bytes]) -> tuple[vision.Wheel, Path]: + archive = tmp_path / f"{name}.whl" + with zipfile.ZipFile(archive, "w") as package: + for member, payload in members.items(): + package.writestr(member, payload) + payload = archive.read_bytes() + return ( + vision.Wheel( + distribution=name, + version="1.0.0", + url=f"https://files.pythonhosted.org/packages/test/{archive.name}", + sha256=hashlib.sha256(payload).hexdigest(), + bytes=len(payload), + license_expression="MIT", + ), + archive, + ) + + +def test_embedded_manifest_covers_exact_supported_targets() -> None: + payload = json.loads(vision.MANIFEST_PATH.read_text()) + + assert payload["schema_version"] == 1 + assert payload["runtime_version"] == "rapidocr-1.4.4-opencv-5.0.0.93-r2" + assert {artifact["target"] for artifact in payload["artifacts"]} == (vision.SUPPORTED_TARGETS) + for target in vision.SUPPORTED_TARGETS: + contract = vision.load_contract(target=target) + assert [wheel.distribution for wheel in contract.wheels] == [ + "rapidocr-onnxruntime", + "opencv-python", + ] + assert all( + wheel.url.startswith("https://files.pythonhosted.org/") for wheel in contract.wheels + ) + + +@pytest.mark.parametrize( + ("system", "machine", "expected"), + [ + ("Darwin", "arm64", "aarch64-apple-darwin"), + ("Darwin", "x86_64", "x86_64-apple-darwin"), + ("Windows", "AMD64", "x86_64-pc-windows-msvc"), + ("Linux", "x86_64", "x86_64-unknown-linux-gnu"), + ], +) +def test_current_target_is_explicit( + system: str, + machine: str, + expected: str, +) -> None: + assert vision.current_target(system=system, machine=machine) == expected + + +def test_extract_wheels_hashes_members(tmp_path: Path) -> None: + opencv = _wheel( + tmp_path, + "opencv-python", + { + "cv2/__init__.py": b"__version__ = '1.0.0'\n", + "opencv_python-1.0.0.dist-info/LICENSE.txt": b"terms\n", + }, + ) + rapidocr = _wheel( + tmp_path, + "rapidocr-onnxruntime", + { + "rapidocr_onnxruntime/__init__.py": b"", + "rapidocr_onnxruntime-1.0.0.dist-info/LICENSE": b"terms\n", + }, + ) + staging = tmp_path / "staging" + staging.mkdir() + + files = vision._extract_wheels((opencv, rapidocr), staging) # noqa: SLF001 + + assert {record["member"] for record in files} == { + "cv2/__init__.py", + "opencv_python-1.0.0.dist-info/LICENSE.txt", + "rapidocr_onnxruntime/__init__.py", + "rapidocr_onnxruntime-1.0.0.dist-info/LICENSE", + } + for record in files: + path = staging / str(record["member"]) + assert hashlib.sha256(path.read_bytes()).hexdigest() == record["sha256"] + + +def test_install_runtime_notices_copies_exact_bytes_and_inventory( + tmp_path: Path, +) -> None: + notice_root = tmp_path / "notices" + notice_root.mkdir() + (notice_root / "LICENSE").write_bytes(b"Apache-2.0 terms\n") + (notice_root / "NOTICE").write_bytes(b"model attribution\n") + staging = tmp_path / "runtime" + staging.mkdir() + + records = vision._install_runtime_notices( # noqa: SLF001 + staging, + notice_root=notice_root, + ) + + assert {record["member"] for record in records} == { + "rapidocr_onnxruntime-1.4.4.dist-info/licenses/LICENSE", + "rapidocr_onnxruntime-1.4.4.dist-info/licenses/NOTICE", + } + for record in records: + path = staging / str(record["member"]) + assert hashlib.sha256(path.read_bytes()).hexdigest() == record["sha256"] + + +def test_extract_wheels_rejects_traversal(tmp_path: Path) -> None: + malicious = _wheel( + tmp_path, + "opencv-python", + {"../outside": b"nope"}, + ) + staging = tmp_path / "staging" + staging.mkdir() + + with pytest.raises(vision.ManagedVisionRuntimeError, match="unsafe"): + vision._extract_wheels((malicious,), staging) # noqa: SLF001 + + +def test_download_rejects_hash_drift(tmp_path: Path) -> None: + payload = b"wrong bytes" + wheel = vision.Wheel( + distribution="opencv-python", + version="1.0.0", + url="https://files.pythonhosted.org/packages/test/opencv.whl", + sha256="0" * 64, + bytes=len(payload), + license_expression="MIT", + ) + + class Response(io.BytesIO): + def geturl(self) -> str: + return wheel.url + + def __enter__(self): + return self + + def __exit__(self, *_args): + self.close() + + with pytest.raises(vision.ManagedVisionRuntimeError, match="hash"): + vision._download( # noqa: SLF001 + wheel, + tmp_path / "download.whl", + opener=lambda *_args, **_kwargs: Response(payload), + ) + + +def test_ensure_reuses_only_hash_valid_cache( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + source_dir = tmp_path / "source" + source_dir.mkdir() + wheels = ( + _wheel( + source_dir, + "rapidocr-onnxruntime", + {"rapidocr_onnxruntime/__init__.py": b""}, + ), + _wheel( + source_dir, + "opencv-python", + {"cv2/__init__.py": b"__version__ = '1.0.0'\n"}, + ), + ) + contract = vision.RuntimeContract( + runtime_version="test-r1", + target="aarch64-apple-darwin", + wheels=tuple(wheel for wheel, _ in wheels), + manifest_sha256="a" * 64, + ) + monkeypatch.setattr(vision, "load_contract", lambda: contract) + monkeypatch.setenv("OPENADAPT_VISION_RUNTIME_ROOT", str(tmp_path / "runtime")) + activations: list[Path] = [] + monkeypatch.setattr(vision, "_activate", lambda path, _contract: activations.append(path)) + downloads = 0 + + def fake_download(wheel: vision.Wheel, destination: Path) -> None: + nonlocal downloads + downloads += 1 + source = next(path for candidate, path in wheels if candidate == wheel) + destination.write_bytes(source.read_bytes()) + + monkeypatch.setattr(vision, "_download", fake_download) + + first = vision.ensure_managed_vision_runtime(status=lambda _message: None) + second = vision.ensure_managed_vision_runtime(status=lambda _message: None) + + assert first == second + assert downloads == 2 + assert activations == [first, first] + (first / "cv2" / "__init__.py").write_text("tampered\n") + + repaired = vision.ensure_managed_vision_runtime(status=lambda _message: None) + + assert repaired == first + assert downloads == 4 + assert (repaired / "cv2" / "__init__.py").read_text() == ("__version__ = '1.0.0'\n") + assert list(first.parent.glob(f"{first.name}.invalid-*")) diff --git a/tests/test_native_release.py b/tests/test_native_release.py index ce55b73..3edf3dd 100644 --- a/tests/test_native_release.py +++ b/tests/test_native_release.py @@ -125,9 +125,9 @@ def test_freshness_workflow_syncs_engine_releases_into_the_native_lane() -> None "src-tauri/tauri.conf.json", ): assert protected_path in provenance_gate - publish_step = freshness.split( - "- name: Commit the sync, tag desktop-v*, and push", 1 - )[1].split(" supersede-published-native:", 1)[0] + publish_step = freshness.split("- name: Commit the sync, tag desktop-v*, and push", 1)[1].split( + " supersede-published-native:", 1 + )[0] assert 'git push --atomic origin HEAD:main "refs/tags/${NATIVE_TAG}"' in publish_step assert publish_step.count("git push origin") == 1 # No builds here: the existing native release workflow owns the matrix, @@ -165,14 +165,15 @@ def test_supersession_edits_notes_only_and_never_deletes() -> None: def test_updater_feed_is_disabled_until_signing_key_lifecycle_exists() -> None: config = json.loads((ROOT / "src-tauri/tauri.conf.json").read_text()) - assert config["plugins"] == { - "deep-link": {"desktop": {"schemes": ["openadapt"]}} - } + assert config["plugins"] == {"deep-link": {"desktop": {"schemes": ["openadapt"]}}} assert "updater" not in config["plugins"] assert config["bundle"]["targets"] == ["dmg", "msi", "nsis", "deb", "appimage"] # Target releases inherit APPLE_SIGNING_IDENTITY and keep hardened runtime. # The explicit ad-hoc overlay is only for unsigned beta artifacts. assert "signingIdentity" not in config["bundle"]["macOS"] + assert config["bundle"]["macOS"]["entitlements"] == "Entitlements.plist" + entitlements = (ROOT / "src-tauri" / config["bundle"]["macOS"]["entitlements"]).read_text() + assert "com.apple.security.cs.disable-library-validation" in entitlements adhoc = json.loads((ROOT / "src-tauri/tauri.adhoc.conf.json").read_text()) assert adhoc["bundle"]["macOS"] == { "signingIdentity": "-", diff --git a/tests/test_public_metadata.py b/tests/test_public_metadata.py index f4d4f81..d0d5397 100644 --- a/tests/test_public_metadata.py +++ b/tests/test_public_metadata.py @@ -69,6 +69,10 @@ def test_semantic_release_refreshes_lock_and_builds_before_tagging() -> None: assert "git add uv.lock" in build_command assert "uv build --wheel --sdist" in build_command assert "check_release_consistency.py --require-dist" in build_command + assert "verify_build_artifact.py python-distribution" in build_command + assert build_command.index("uv build --wheel --sdist") < build_command.index( + "verify_build_artifact.py python-distribution" + ) assert "uv lock" not in build_command assert "$PACKAGE_NAME" not in build_command @@ -160,13 +164,13 @@ def test_beta_release_notes_describe_the_bundled_flow_runtime() -> None: for dependency in build_dependencies if dependency.startswith("openadapt-flow==") ] - assert flow_dependencies == ["openadapt-flow==1.20.0"] - assert "openadapt-capture>=1.0.1" in dependencies + assert flow_dependencies == ["openadapt-flow==1.20.1"] + assert "openadapt-capture>=1.0.4" in dependencies assert "openadapt-privacy>=1.0.0" in dependencies assert "Development Status :: 4 - Beta" in classifiers assert "Development Status :: 2 - Pre-Alpha" not in classifiers - assert bundled_flow_version() == "1.20.0" - assert bundled_flow_banner() == "openadapt-flow 1.20.0" + assert bundled_flow_version() == "1.20.1" + assert bundled_flow_banner() == "openadapt-flow 1.20.1" assert 'name = "playwright"\nversion = "1.61.0"' in lock assert flow_dependencies[0] in notes assert "playwright==1.61.0" in notes diff --git a/third_party/README.md b/third_party/README.md index 279e332..4587116 100644 --- a/third_party/README.md +++ b/third_party/README.md @@ -1,10 +1,12 @@ # Third-party native-runtime notices These records cover third-party files embedded in the self-contained Desktop -engine. The build copies the applicable installed ONNX Runtime license and -third-party notice verbatim and bundles this repository's pinned RapidOCR -license and attribution alongside the OCR models. CI inventories the actual -PyInstaller archive and refuses an artifact missing any of these files. +engine. The build copies the applicable installed ONNX Runtime notices and this +repository's pinned RapidOCR license and attribution verbatim. The RapidOCR and +OpenCV package bytes are not embedded: Desktop downloads their exact reviewed +wheels on first vision use, verifies every byte, and copies the bundled +RapidOCR notices beside the extracted runtime. CI inventories the actual +PyInstaller archive and refuses an artifact missing any required notice. ## ONNX Runtime @@ -40,7 +42,8 @@ does not download mutable notice text separately. - Pinned license SHA-256: `3e0af25fdd06aa9586ae97adb00ea927ebe5a3805ac77d2d3a81ce5f55693333` - License: Apache-2.0 -- Modification status: the three embedded model files are unmodified +- Modification status: the three model files in the exact upstream wheel are + unmodified - Model SHA-256 values: - `ch_PP-OCRv4_det_infer.onnx`: `d2a7720d45a54257208b1e13e36a8479894cb74155a5efe29462512d42f49da9` @@ -52,3 +55,56 @@ does not download mutable notice text separately. The copied `rapidocr/LICENSE` is byte-for-byte identical to the pinned upstream license. `rapidocr/NOTICE` records the upstream model attribution; it is an OpenAdapt-authored notice and does not modify the upstream license. + +## FlatBuffers Python runtime + +- Distribution: `flatbuffers==25.12.19` +- Upstream repository: `https://github.com/google/flatbuffers` +- Upstream tag and commit: `v25.12.19`, + `7e163021e59cca4f8e1e35a7c828b5c6b7915953` +- License source path: `LICENSE` +- Pinned license SHA-256: + `cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30` +- License: Apache-2.0 +- Modification status: copied without modification + +The FlatBuffers 25.12.19 Python wheel declares Apache 2.0 but omits a concrete +license file. The frozen-runtime build therefore includes this exact, +hash-verified upstream license only for that exact distribution version. A +version, metadata, source, or license-byte change fails closed for review. + +## Loguru Python runtime + +- Distribution: `loguru==0.7.3` +- Upstream repository: `https://github.com/Delgan/loguru` +- Upstream tag and commit: `0.7.3`, + `ae3bfd1b85b6b4a3db535f69b975687c79498be4` +- License source path: `LICENSE` +- Pinned license SHA-256: + `b35d026cc7aca9d5859a02eb87ddf7a386a24c986838651bd1f283f94e003327` +- License: MIT +- Modification status: copied without modification + +The Loguru 0.7.3 wheel declares its MIT license through package metadata but +omits a concrete license file. The frozen-runtime build applies the same exact +version, metadata, commit, and byte-hash gate used for FlatBuffers. + +## PyObjC macOS runtime + +- Distributions: `pyobjc-core==12.2.1` and + `pyobjc-framework-ApplicationServices==12.2.1` +- Upstream repository: `https://github.com/ronaldoussoren/pyobjc` +- Upstream tag and commit: `v12.2.1`, + `cb525f3e7f433c851b68725dea54bc70d35681b8` +- Source paths: `pyobjc-core/License.txt` and + `pyobjc-framework-ApplicationServices/License.txt` +- Pinned license SHA-256: + `0ca04b07928d4872b9d9bb22187ca0426dd8bfab08f26eada0999a71dc81aaff` +- License: MIT +- Modification status: the two source files are byte-identical; copied without + modification as `pyobjc/LICENSE.txt` + +The two applicable macOS wheels omit a concrete license file. The +frozen-runtime build supplies their shared exact upstream license only when +both package version and license metadata match the reviewed records. Symbol +files whose names merely contain `copying` are not treated as license notices. diff --git a/third_party/flatbuffers/LICENSE b/third_party/flatbuffers/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/third_party/flatbuffers/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third_party/loguru/LICENSE b/third_party/loguru/LICENSE new file mode 100644 index 0000000..5285f42 --- /dev/null +++ b/third_party/loguru/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/pyobjc/LICENSE.txt b/third_party/pyobjc/LICENSE.txt new file mode 100644 index 0000000..bc7b17b --- /dev/null +++ b/third_party/pyobjc/LICENSE.txt @@ -0,0 +1,10 @@ +(This is the MIT license, note that libffi-src is a separate product with its own license) + +Copyright 2002, 2003 - Bill Bumgarner, Ronald Oussoren, Steve Majewski, Lele Gaifax, et.al. +Copyright 2003-2025 - Ronald Oussoren + + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/uv.lock b/uv.lock index 21fcab0..0a9e20e 100644 --- a/uv.lock +++ b/uv.lock @@ -53,56 +53,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, ] -[[package]] -name = "av" -version = "16.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/78/cd/3a83ffbc3cc25b39721d174487fb0d51a76582f4a1703f98e46170ce83d4/av-16.1.0.tar.gz", hash = "sha256:a094b4fd87a3721dacf02794d3d2c82b8d712c85b9534437e82a8a978c175ffd", size = 4285203, upload-time = "2026-01-11T07:31:33.772Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/48/d0/b71b65d1b36520dcb8291a2307d98b7fc12329a45614a303ff92ada4d723/av-16.1.0-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:e88ad64ee9d2b9c4c5d891f16c22ae78e725188b8926eb88187538d9dd0b232f", size = 26927747, upload-time = "2026-01-09T20:18:16.976Z" }, - { url = "https://files.pythonhosted.org/packages/2f/79/720a5a6ccdee06eafa211b945b0a450e3a0b8fc3d12922f0f3c454d870d2/av-16.1.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:cb296073fa6935724de72593800ba86ae49ed48af03960a4aee34f8a611f442b", size = 21492232, upload-time = "2026-01-09T20:18:19.266Z" }, - { url = "https://files.pythonhosted.org/packages/8e/4f/a1ba8d922f2f6d1a3d52419463ef26dd6c4d43ee364164a71b424b5ae204/av-16.1.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:720edd4d25aa73723c1532bb0597806d7b9af5ee34fc02358782c358cfe2f879", size = 39291737, upload-time = "2026-01-09T20:18:21.513Z" }, - { url = "https://files.pythonhosted.org/packages/1a/31/fc62b9fe8738d2693e18d99f040b219e26e8df894c10d065f27c6b4f07e3/av-16.1.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:c7f2bc703d0df260a1fdf4de4253c7f5500ca9fc57772ea241b0cb241bcf972e", size = 40846822, upload-time = "2026-01-09T20:18:24.275Z" }, - { url = "https://files.pythonhosted.org/packages/53/10/ab446583dbce730000e8e6beec6ec3c2753e628c7f78f334a35cad0317f4/av-16.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d69c393809babada7d54964d56099e4b30a3e1f8b5736ca5e27bd7be0e0f3c83", size = 40675604, upload-time = "2026-01-09T20:18:26.866Z" }, - { url = "https://files.pythonhosted.org/packages/31/d7/1003be685277005f6d63fd9e64904ee222fe1f7a0ea70af313468bb597db/av-16.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:441892be28582356d53f282873c5a951592daaf71642c7f20165e3ddcb0b4c63", size = 42015955, upload-time = "2026-01-09T20:18:29.461Z" }, - { url = "https://files.pythonhosted.org/packages/2f/4a/fa2a38ee9306bf4579f556f94ecbc757520652eb91294d2a99c7cf7623b9/av-16.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:273a3e32de64819e4a1cd96341824299fe06f70c46f2288b5dc4173944f0fd62", size = 31750339, upload-time = "2026-01-09T20:18:32.249Z" }, - { url = "https://files.pythonhosted.org/packages/9c/84/2535f55edcd426cebec02eb37b811b1b0c163f26b8d3f53b059e2ec32665/av-16.1.0-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:640f57b93f927fba8689f6966c956737ee95388a91bd0b8c8b5e0481f73513d6", size = 26945785, upload-time = "2026-01-09T20:18:34.486Z" }, - { url = "https://files.pythonhosted.org/packages/b6/17/ffb940c9e490bf42e86db4db1ff426ee1559cd355a69609ec1efe4d3a9eb/av-16.1.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:ae3fb658eec00852ebd7412fdc141f17f3ddce8afee2d2e1cf366263ad2a3b35", size = 21481147, upload-time = "2026-01-09T20:18:36.716Z" }, - { url = "https://files.pythonhosted.org/packages/15/c1/e0d58003d2d83c3921887d5c8c9b8f5f7de9b58dc2194356a2656a45cfdc/av-16.1.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:27ee558d9c02a142eebcbe55578a6d817fedfde42ff5676275504e16d07a7f86", size = 39517197, upload-time = "2026-01-11T09:57:31.937Z" }, - { url = "https://files.pythonhosted.org/packages/32/77/787797b43475d1b90626af76f80bfb0c12cfec5e11eafcfc4151b8c80218/av-16.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:7ae547f6d5fa31763f73900d43901e8c5fa6367bb9a9840978d57b5a7ae14ed2", size = 41174337, upload-time = "2026-01-11T09:57:35.792Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ac/d90df7f1e3b97fc5554cf45076df5045f1e0a6adf13899e10121229b826c/av-16.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8cf065f9d438e1921dc31fc7aa045790b58aee71736897866420d80b5450f62a", size = 40817720, upload-time = "2026-01-11T09:57:39.039Z" }, - { url = "https://files.pythonhosted.org/packages/80/6f/13c3a35f9dbcebafd03fe0c4cbd075d71ac8968ec849a3cfce406c35a9d2/av-16.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a345877a9d3cc0f08e2bc4ec163ee83176864b92587afb9d08dff50f37a9a829", size = 42267396, upload-time = "2026-01-11T09:57:42.115Z" }, - { url = "https://files.pythonhosted.org/packages/c8/b9/275df9607f7fb44317ccb1d4be74827185c0d410f52b6e2cd770fe209118/av-16.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:f49243b1d27c91cd8c66fdba90a674e344eb8eb917264f36117bf2b6879118fd", size = 31752045, upload-time = "2026-01-11T09:57:45.106Z" }, - { url = "https://files.pythonhosted.org/packages/75/2a/63797a4dde34283dd8054219fcb29294ba1c25d68ba8c8c8a6ae53c62c45/av-16.1.0-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:ce2a1b3d8bf619f6c47a9f28cfa7518ff75ddd516c234a4ee351037b05e6a587", size = 26916715, upload-time = "2026-01-11T09:57:47.682Z" }, - { url = "https://files.pythonhosted.org/packages/d2/c4/0b49cf730d0ae8cda925402f18ae814aef351f5772d14da72dd87ff66448/av-16.1.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:408dbe6a2573ca58a855eb8cd854112b33ea598651902c36709f5f84c991ed8e", size = 21452167, upload-time = "2026-01-11T09:57:50.606Z" }, - { url = "https://files.pythonhosted.org/packages/51/23/408806503e8d5d840975aad5699b153aaa21eb6de41ade75248a79b7a37f/av-16.1.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:57f657f86652a160a8a01887aaab82282f9e629abf94c780bbdbb01595d6f0f7", size = 39215659, upload-time = "2026-01-11T09:57:53.757Z" }, - { url = "https://files.pythonhosted.org/packages/c4/19/a8528d5bba592b3903f44c28dab9cc653c95fcf7393f382d2751a1d1523e/av-16.1.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:adbad2b355c2ee4552cac59762809d791bda90586d134a33c6f13727fb86cb3a", size = 40874970, upload-time = "2026-01-11T09:57:56.802Z" }, - { url = "https://files.pythonhosted.org/packages/e8/24/2dbcdf0e929ad56b7df078e514e7bd4ca0d45cba798aff3c8caac097d2f7/av-16.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f42e1a68ec2aebd21f7eb6895be69efa6aa27eec1670536876399725bbda4b99", size = 40530345, upload-time = "2026-01-11T09:58:00.421Z" }, - { url = "https://files.pythonhosted.org/packages/54/27/ae91b41207f34e99602d1c72ab6ffd9c51d7c67e3fbcd4e3a6c0e54f882c/av-16.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:58fe47aeaef0f100c40ec8a5de9abbd37f118d3ca03829a1009cf288e9aef67c", size = 41972163, upload-time = "2026-01-11T09:58:03.756Z" }, - { url = "https://files.pythonhosted.org/packages/fc/7a/22158fb923b2a9a00dfab0e96ef2e8a1763a94dd89e666a5858412383d46/av-16.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:565093ebc93b2f4b76782589564869dadfa83af5b852edebedd8fee746457d06", size = 31729230, upload-time = "2026-01-11T09:58:07.254Z" }, - { url = "https://files.pythonhosted.org/packages/7f/f1/878f8687d801d6c4565d57ebec08449c46f75126ebca8e0fed6986599627/av-16.1.0-cp313-cp313t-macosx_11_0_x86_64.whl", hash = "sha256:574081a24edb98343fd9f473e21ae155bf61443d4ec9d7708987fa597d6b04b2", size = 27008769, upload-time = "2026-01-11T09:58:10.266Z" }, - { url = "https://files.pythonhosted.org/packages/30/f1/bd4ce8c8b5cbf1d43e27048e436cbc9de628d48ede088a1d0a993768eb86/av-16.1.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:9ab00ea29c25ebf2ea1d1e928d7babb3532d562481c5d96c0829212b70756ad0", size = 21590588, upload-time = "2026-01-11T09:58:12.629Z" }, - { url = "https://files.pythonhosted.org/packages/1d/dd/c81f6f9209201ff0b5d5bed6da6c6e641eef52d8fbc930d738c3f4f6f75d/av-16.1.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:a84a91188c1071f238a9523fd42dbe567fb2e2607b22b779851b2ce0eac1b560", size = 40638029, upload-time = "2026-01-11T09:58:15.399Z" }, - { url = "https://files.pythonhosted.org/packages/15/4d/07edff82b78d0459a6e807e01cd280d3180ce832efc1543de80d77676722/av-16.1.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:c2cd0de4dd022a7225ff224fde8e7971496d700be41c50adaaa26c07bb50bf97", size = 41970776, upload-time = "2026-01-11T09:58:19.075Z" }, - { url = "https://files.pythonhosted.org/packages/da/9d/1f48b354b82fa135d388477cd1b11b81bdd4384bd6a42a60808e2ec2d66b/av-16.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0816143530624a5a93bc5494f8c6eeaf77549b9366709c2ac8566c1e9bff6df5", size = 41764751, upload-time = "2026-01-11T09:58:22.788Z" }, - { url = "https://files.pythonhosted.org/packages/2f/c7/a509801e98db35ec552dd79da7bdbcff7104044bfeb4c7d196c1ce121593/av-16.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e3a28053af29644696d0c007e897d19b1197585834660a54773e12a40b16974c", size = 43034355, upload-time = "2026-01-11T09:58:26.125Z" }, - { url = "https://files.pythonhosted.org/packages/36/8b/e5f530d9e8f640da5f5c5f681a424c65f9dd171c871cd255d8a861785a6e/av-16.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2e3e67144a202b95ed299d165232533989390a9ea3119d37eccec697dc6dbb0c", size = 31947047, upload-time = "2026-01-11T09:58:31.867Z" }, - { url = "https://files.pythonhosted.org/packages/df/18/8812221108c27d19f7e5f486a82c827923061edf55f906824ee0fcaadf50/av-16.1.0-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:39a634d8e5a87e78ea80772774bfd20c0721f0d633837ff185f36c9d14ffede4", size = 26916179, upload-time = "2026-01-11T09:58:36.506Z" }, - { url = "https://files.pythonhosted.org/packages/38/ef/49d128a9ddce42a2766fe2b6595bd9c49e067ad8937a560f7838a541464e/av-16.1.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:0ba32fb9e9300948a7fa9f8a3fc686e6f7f77599a665c71eb2118fdfd2c743f9", size = 21460168, upload-time = "2026-01-11T09:58:39.231Z" }, - { url = "https://files.pythonhosted.org/packages/e6/a9/b310d390844656fa74eeb8c2750e98030877c75b97551a23a77d3f982741/av-16.1.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:ca04d17815182d34ce3edc53cbda78a4f36e956c0fd73e3bab249872a831c4d7", size = 39210194, upload-time = "2026-01-11T09:58:42.138Z" }, - { url = "https://files.pythonhosted.org/packages/0c/7b/e65aae179929d0f173af6e474ad1489b5b5ad4c968a62c42758d619e54cf/av-16.1.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ee0e8de2e124a9ef53c955fe2add6ee7c56cc8fd83318265549e44057db77142", size = 40811675, upload-time = "2026-01-11T09:58:45.871Z" }, - { url = "https://files.pythonhosted.org/packages/54/3f/5d7edefd26b6a5187d6fac0f5065ee286109934f3dea607ef05e53f05b31/av-16.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:22bf77a2f658827043a1e184b479c3bf25c4c43ab32353677df2d119f080e28f", size = 40543942, upload-time = "2026-01-11T09:58:49.759Z" }, - { url = "https://files.pythonhosted.org/packages/1b/24/f8b17897b67be0900a211142f5646a99d896168f54d57c81f3e018853796/av-16.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2dd419d262e6a71cab206d80bbf28e0a10d0f227b671cdf5e854c028faa2d043", size = 41924336, upload-time = "2026-01-11T09:58:53.344Z" }, - { url = "https://files.pythonhosted.org/packages/1c/cf/d32bc6bbbcf60b65f6510c54690ed3ae1c4ca5d9fafbce835b6056858686/av-16.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:53585986fd431cd436f290fba662cfb44d9494fbc2949a183de00acc5b33fa88", size = 31735077, upload-time = "2026-01-11T09:58:56.684Z" }, - { url = "https://files.pythonhosted.org/packages/53/f4/9b63dc70af8636399bd933e9df4f3025a0294609510239782c1b746fc796/av-16.1.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:76f5ed8495cf41e1209a5775d3699dc63fdc1740b94a095e2485f13586593205", size = 27014423, upload-time = "2026-01-11T09:58:59.703Z" }, - { url = "https://files.pythonhosted.org/packages/d1/da/787a07a0d6ed35a0888d7e5cfb8c2ffa202f38b7ad2c657299fac08eb046/av-16.1.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:8d55397190f12a1a3ae7538be58c356cceb2bf50df1b33523817587748ce89e5", size = 21595536, upload-time = "2026-01-11T09:59:02.508Z" }, - { url = "https://files.pythonhosted.org/packages/d8/f4/9a7d8651a611be6e7e3ab7b30bb43779899c8cac5f7293b9fb634c44a3f3/av-16.1.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:9d51d9037437218261b4bbf9df78a95e216f83d7774fbfe8d289230b5b2e28e2", size = 40642490, upload-time = "2026-01-11T09:59:05.842Z" }, - { url = "https://files.pythonhosted.org/packages/6b/e4/eb79bc538a94b4ff93cd4237d00939cba797579f3272490dd0144c165a21/av-16.1.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:0ce07a89c15644407f49d942111ca046e323bbab0a9078ff43ee57c9b4a50dad", size = 41976905, upload-time = "2026-01-11T09:59:09.169Z" }, - { url = "https://files.pythonhosted.org/packages/5e/f5/f6db0dd86b70167a4d55ee0d9d9640983c570d25504f2bde42599f38241e/av-16.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cac0c074892ea97113b53556ff41c99562db7b9f09f098adac1f08318c2acad5", size = 41770481, upload-time = "2026-01-11T09:59:12.74Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8b/33651d658e45e16ab7671ea5fcf3d20980ea7983234f4d8d0c63c65581a5/av-16.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7dec3dcbc35a187ce450f65a2e0dda820d5a9e6553eea8344a1459af11c98649", size = 43036824, upload-time = "2026-01-11T09:59:16.507Z" }, - { url = "https://files.pythonhosted.org/packages/83/41/7f13361db54d7e02f11552575c0384dadaf0918138f4eaa82ea03a9f9580/av-16.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6f90dc082ff2068ddbe77618400b44d698d25d9c4edac57459e250c16b33d700", size = 31948164, upload-time = "2026-01-11T09:59:19.501Z" }, -] - [[package]] name = "backports-tarfile" version = "1.2.0" @@ -337,6 +287,88 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" }, ] +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" }, + { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, + { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, + { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, + { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, + { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" }, + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, + { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, + { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, + { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, + { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, + { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, + { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, + { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, + { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, + { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, + { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, +] + [[package]] name = "cryptography" version = "44.0.3" @@ -378,6 +410,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/87/62/d69eb4a8ee231f4bf733a92caf9da13f1c81a44e874b1d4080c25ecbb723/cryptography-44.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:5d20cc348cca3a8aa7312f42ab953a56e15323800ca3ab0706b8cd452a3a056c", size = 3134369, upload-time = "2025-05-02T19:35:58.907Z" }, ] +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + [[package]] name = "deprecated" version = "1.3.1" @@ -408,12 +449,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/91/e0d457ee03ec33d79ee2cd8d212debb1bc21dfb99728ae35efdb5832dc22/dotty_dict-1.3.1-py3-none-any.whl", hash = "sha256:5022d234d9922f13aa711b4950372a06a6d64cb6d6db9ba43d0ba133ebfce31f", size = 7014, upload-time = "2022-07-09T18:50:55.058Z" }, ] -[[package]] -name = "evdev" -version = "1.9.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a5/f5/397b61091120a9ca5001041dd7bf76c385b3bfd67a0e5bcb74b852bd22a4/evdev-1.9.3.tar.gz", hash = "sha256:2c140e01ac8437758fa23fe5c871397412461f42d421aa20241dc8fe8cfccbc9", size = 32723, upload-time = "2026-02-05T21:54:24.987Z" } - [[package]] name = "fire" version = "0.7.1" @@ -435,12 +470,52 @@ wheels = [ ] [[package]] -name = "future" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a7/b2/4140c69c6a66432916b26158687e821ba631a4c9273c474343badf84d3ba/future-1.0.0.tar.gz", hash = "sha256:bd2968309307861edae1458a4f8a4f3598c03be43b97521076aebf5d94c07b05", size = 1228490, upload-time = "2024-02-21T11:52:38.461Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/da/71/ae30dadffc90b9006d77af76b393cb9dfbfc9629f339fc1574a1c52e6806/future-1.0.0-py3-none-any.whl", hash = "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216", size = 491326, upload-time = "2024-02-21T11:52:35.956Z" }, +name = "fonttools" +version = "4.63.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/2b/a7f1545bdf5da69c4bda0cea2a5781f0ad2a6623e0277267672db43c5fe6/fonttools-4.63.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b8ae05d9eacf6081414d759c0a352769ac28ce31280d6bb8e77b03f9e3c449f", size = 2881793, upload-time = "2026-05-14T12:02:56.645Z" }, + { url = "https://files.pythonhosted.org/packages/49/50/965308c703f085f225db2886813b27e015b8b3438c350b22dd65b52c2a2c/fonttools-4.63.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:79cdc9f567aec74a72918fd060283911406750cbc9fd28c1316023deb6ce31a9", size = 2428130, upload-time = "2026-05-14T12:02:58.891Z" }, + { url = "https://files.pythonhosted.org/packages/d8/38/6937fbd7f2dc3a6b48725851bc2c15ec949b9af14d9bbcb5fe83cdf9bdf9/fonttools-4.63.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b", size = 5111952, upload-time = "2026-05-14T12:03:01.263Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/a81f20050a3115b57d62c8e781446949512eac36690dc384ccea65ff4cc1/fonttools-4.63.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76ac49f929aecaf82d83250b8347e099d7aecba0f4726c1d9b6df3b8bb5fe18", size = 5082308, upload-time = "2026-05-14T12:03:03.211Z" }, + { url = "https://files.pythonhosted.org/packages/67/00/cdd9d4944ca6ae280d01e69cc37bde3bf663630b837a6fc6d2cd65d80e0e/fonttools-4.63.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dcf076a4474fe0d7367e5bbf5b052c7284fa1feca729c04176ce513521afd8a0", size = 5087932, upload-time = "2026-05-14T12:03:05.147Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f1/0aa0dbea778c75adbef223c42019fd47d22262b905974d62d829545d485f/fonttools-4.63.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7dd683fef0663e9f0f45cf541d788d24caa3ec9db50796b588e1757d8b3bc007", size = 5213271, upload-time = "2026-05-14T12:03:07.238Z" }, + { url = "https://files.pythonhosted.org/packages/a8/99/253e4056e1f0e67b9390125a154b73b5eb73ad521bece95c004858fdeec2/fonttools-4.63.0-cp311-cp311-win32.whl", hash = "sha256:afefc1ed0a59785a7fb06ea7e1678e849c193e1e387db783579bc7b3056fcfcb", size = 2304473, upload-time = "2026-05-14T12:03:09.271Z" }, + { url = "https://files.pythonhosted.org/packages/08/60/defa5e69641db890a63be281f41345f4c33b157824eaf0b9fad3e08b0dcb/fonttools-4.63.0-cp311-cp311-win_amd64.whl", hash = "sha256:063e08bd17bd5a90127a14123de0d6a952dbc847695fd98b63c043d58057f90c", size = 2356389, upload-time = "2026-05-14T12:03:11.53Z" }, + { url = "https://files.pythonhosted.org/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02", size = 2881131, upload-time = "2026-05-14T12:03:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0", size = 2426704, upload-time = "2026-05-14T12:03:15.801Z" }, + { url = "https://files.pythonhosted.org/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af", size = 5044298, upload-time = "2026-05-14T12:03:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8", size = 4999800, upload-time = "2026-05-14T12:03:20.161Z" }, + { url = "https://files.pythonhosted.org/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b", size = 4982666, upload-time = "2026-05-14T12:03:22.87Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598, upload-time = "2026-05-14T12:03:25.168Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263", size = 2292575, upload-time = "2026-05-14T12:03:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272", size = 2343211, upload-time = "2026-05-14T12:03:30.057Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8d/d8fec3dcde2963f8c908fb315e5ff2cd0ac34f82394bbbf73a2aa5145ce3/fonttools-4.63.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd", size = 2876062, upload-time = "2026-05-14T12:03:32.554Z" }, + { url = "https://files.pythonhosted.org/packages/ef/71/d935dc54e4ff121bfdd11e08702db63a7e6f25af21d8a3d7b7212df53641/fonttools-4.63.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59", size = 2424594, upload-time = "2026-05-14T12:03:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/8e/40/e76320afa1df918e146155ef239b1719ee266092e96f5423bfd075affba1/fonttools-4.63.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d", size = 5024840, upload-time = "2026-05-14T12:03:36.745Z" }, + { url = "https://files.pythonhosted.org/packages/ce/36/0b805d8c485f872f65a509cbe3b58a5d0d17bee855333b54a150c79d3061/fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68", size = 4975801, upload-time = "2026-05-14T12:03:38.833Z" }, + { url = "https://files.pythonhosted.org/packages/c8/26/2cee03d0aa083ab022da5c07aff9ed3f689da1defb81ad6917c9627896da/fonttools-4.63.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be", size = 4965009, upload-time = "2026-05-14T12:03:41.494Z" }, + { url = "https://files.pythonhosted.org/packages/7e/48/cc4b66d9058c0d0982c833fad10127c4b0e9324606aafa41382295ca4102/fonttools-4.63.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27", size = 5105892, upload-time = "2026-05-14T12:03:43.525Z" }, + { url = "https://files.pythonhosted.org/packages/d8/1f/a98a30a814b9ddef3a2e706025f90b9e0bc94890e6cb15254bc86547d11a/fonttools-4.63.0-cp313-cp313-win32.whl", hash = "sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380", size = 2291313, upload-time = "2026-05-14T12:03:45.594Z" }, + { url = "https://files.pythonhosted.org/packages/92/46/5177b01f3b4abfdd4409f31cca4ab279c9343a26efbe9ec78c97fc612e02/fonttools-4.63.0-cp313-cp313-win_amd64.whl", hash = "sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b", size = 2342299, upload-time = "2026-05-14T12:03:47.414Z" }, + { url = "https://files.pythonhosted.org/packages/27/d2/23d25e3f247b328be58d04a4c9f894178a0d1eda7d42867cfb388adaf416/fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745", size = 2875338, upload-time = "2026-05-14T12:03:50.052Z" }, + { url = "https://files.pythonhosted.org/packages/cd/58/7dfa0c761cb3b2964e2a84c4dc986c926a87de0cb9fb60d5b28ded3f2914/fonttools-4.63.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03", size = 2422661, upload-time = "2026-05-14T12:03:52.154Z" }, + { url = "https://files.pythonhosted.org/packages/dd/87/64cfa18a7a1621d17b7f4502b2b0ed8a135a90c3db51ea590ee99043e76b/fonttools-4.63.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49", size = 5010526, upload-time = "2026-05-14T12:03:54.647Z" }, + { url = "https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b", size = 4923946, upload-time = "2026-05-14T12:03:56.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/872e6e233b8c5e8b41413796ff18b7fe479661bd40147e071b450dfad7a1/fonttools-4.63.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6", size = 4962489, upload-time = "2026-05-14T12:03:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/30/c4/83c24f2ec38b90cfda84bf4b1a1f49df80e84a1db4e7ac6e0d41bf23bc39/fonttools-4.63.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4", size = 5071870, upload-time = "2026-05-14T12:04:02.122Z" }, + { url = "https://files.pythonhosted.org/packages/de/40/3ae22b60ff1d41ce0bd044b31238cdc72cef99f28b976f1e128ebd618c9b/fonttools-4.63.0-cp314-cp314-win32.whl", hash = "sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616", size = 2295026, upload-time = "2026-05-14T12:04:04.47Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5", size = 2347454, upload-time = "2026-05-14T12:04:06.752Z" }, + { url = "https://files.pythonhosted.org/packages/49/4e/652d1580c5f4e39f7d103b0c793e4773129ad633dce4addd0cf4dfebde02/fonttools-4.63.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001", size = 2958152, upload-time = "2026-05-14T12:04:08.706Z" }, + { url = "https://files.pythonhosted.org/packages/0e/55/ad864c9a9b219f552eb46b32cd7906c466e5a578ba0c3abfcc0fe7413eb6/fonttools-4.63.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e", size = 2460809, upload-time = "2026-05-14T12:04:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2b/0aa8db70f18cf52e49b4ed5ecec68547f981160bf5ded3b5aed6faa0a6f9/fonttools-4.63.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096", size = 5148649, upload-time = "2026-05-14T12:04:12.747Z" }, + { url = "https://files.pythonhosted.org/packages/7f/63/18e4369c25043096f1048e0c9915951adc4f842bd81c6b18155824d6fa99/fonttools-4.63.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f", size = 4932147, upload-time = "2026-05-14T12:04:14.806Z" }, + { url = "https://files.pythonhosted.org/packages/a1/3f/67f3eac2ffd8a98446c5022f8ed3864eac878a5ff7af8df4c8286dba16cc/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40", size = 5027237, upload-time = "2026-05-14T12:04:17.675Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ba/4e6214cb38a7b04779e97bb7636de9a5c7f20af7018d03dee0b64c08510a/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196", size = 5053933, upload-time = "2026-05-14T12:04:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/34/3b/214dcc19ee31d3d38fb5ad2755c11ef0514e5dc300bbaf41c0b69f393799/fonttools-4.63.0-cp314-cp314t-win32.whl", hash = "sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8", size = 2359326, upload-time = "2026-05-14T12:04:24.22Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1e/3ff1a9b523058c2eeb6a9d50f5574e2a738200d0d94107d5bc4105e8da3f/fonttools-4.63.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419", size = 2425829, upload-time = "2026-05-14T12:04:26.829Z" }, + { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, ] [[package]] @@ -599,21 +674,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] -[[package]] -name = "imagehash" -version = "4.3.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "pillow" }, - { name = "pywavelets" }, - { name = "scipy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cd/de/5c0189b0582e21583c2a213081c35a2501c0f9e51f21f6a52f55fbb9a4ff/ImageHash-4.3.2.tar.gz", hash = "sha256:e54a79805afb82a34acde4746a16540503a9636fd1ffb31d8e099b29bbbf8156", size = 303190, upload-time = "2025-02-01T08:45:39.328Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/31/2c/5f0903a53a62029875aaa3884c38070cc388248a2c1b9aa935632669e5a7/ImageHash-4.3.2-py2.py3-none-any.whl", hash = "sha256:02b0f965f8c77cd813f61d7d39031ea27d4780e7ebcad56c6cd6a709acc06e5f", size = 296657, upload-time = "2025-02-01T08:45:36.102Z" }, -] - [[package]] name = "importlib-metadata" version = "9.0.0" @@ -813,6 +873,112 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, ] +[[package]] +name = "kiwisolver" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/dd/a495a9c104be1c476f0386e714252caf2b7eca883915422a64c50b88c6f5/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c", size = 122798, upload-time = "2026-03-09T13:12:58.963Z" }, + { url = "https://files.pythonhosted.org/packages/11/60/37b4047a2af0cf5ef6d8b4b26e91829ae6fc6a2d1f74524bcb0e7cd28a32/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb", size = 66216, upload-time = "2026-03-09T13:13:00.155Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/510dc933d87767584abfe03efa445889996c70c2990f6f87c3ebaa0a18c5/kiwisolver-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac", size = 63911, upload-time = "2026-03-09T13:13:01.671Z" }, + { url = "https://files.pythonhosted.org/packages/80/46/bddc13df6c2a40741e0cc7865bb1c9ed4796b6760bd04ce5fae3928ef917/kiwisolver-1.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27", size = 1438209, upload-time = "2026-03-09T13:13:03.385Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d6/76621246f5165e5372f02f5e6f3f48ea336a8f9e96e43997d45b240ed8cd/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398", size = 1248888, upload-time = "2026-03-09T13:13:05.231Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c1/31559ec6fb39a5b48035ce29bb63ade628f321785f38c384dee3e2c08bc1/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db", size = 1266304, upload-time = "2026-03-09T13:13:06.743Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ef/1cb8276f2d29cc6a41e0a042f27946ca347d3a4a75acf85d0a16aa6dcc82/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc", size = 1319650, upload-time = "2026-03-09T13:13:08.607Z" }, + { url = "https://files.pythonhosted.org/packages/4c/e4/5ba3cecd7ce6236ae4a80f67e5d5531287337d0e1f076ca87a5abe4cd5d0/kiwisolver-1.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679", size = 970949, upload-time = "2026-03-09T13:13:10.299Z" }, + { url = "https://files.pythonhosted.org/packages/5a/69/dc61f7ae9a2f071f26004ced87f078235b5507ab6e5acd78f40365655034/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309", size = 2199125, upload-time = "2026-03-09T13:13:11.841Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7b/abbe0f1b5afa85f8d084b73e90e5f801c0939eba16ac2e49af7c61a6c28d/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2", size = 2293783, upload-time = "2026-03-09T13:13:14.399Z" }, + { url = "https://files.pythonhosted.org/packages/8a/80/5908ae149d96d81580d604c7f8aefd0e98f4fd728cf172f477e9f2a81744/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c", size = 1960726, upload-time = "2026-03-09T13:13:16.047Z" }, + { url = "https://files.pythonhosted.org/packages/84/08/a78cb776f8c085b7143142ce479859cfec086bd09ee638a317040b6ef420/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08", size = 2464738, upload-time = "2026-03-09T13:13:17.897Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e1/65584da5356ed6cb12c63791a10b208860ac40a83de165cb6a6751a686e3/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4", size = 2270718, upload-time = "2026-03-09T13:13:19.421Z" }, + { url = "https://files.pythonhosted.org/packages/be/6c/28f17390b62b8f2f520e2915095b3c94d88681ecf0041e75389d9667f202/kiwisolver-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b", size = 73480, upload-time = "2026-03-09T13:13:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0e/2ee5debc4f77a625778fec5501ff3e8036fe361b7ee28ae402a485bb9694/kiwisolver-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac", size = 64930, upload-time = "2026-03-09T13:13:21.997Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, + { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, + { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, + { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, + { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, + { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, + { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" }, + { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" }, + { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" }, + { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" }, + { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" }, + { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" }, + { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" }, + { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" }, + { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" }, + { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" }, + { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, + { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, + { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" }, + { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" }, + { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" }, + { url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" }, + { url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" }, + { url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" }, + { url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" }, + { url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" }, + { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" }, + { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, + { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, + { url = "https://files.pythonhosted.org/packages/e9/eb/5fcbbbf9a0e2c3a35effb88831a483345326bbc3a030a3b5b69aee647f84/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232", size = 59532, upload-time = "2026-03-09T13:15:47.047Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9b/e17104555bb4db148fd52327feea1e96be4b88e8e008b029002c281a21ab/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a", size = 57420, upload-time = "2026-03-09T13:15:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/48/44/2b5b95b7aa39fb2d8d9d956e0f3d5d45aef2ae1d942d4c3ffac2f9cfed1a/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737", size = 79892, upload-time = "2026-03-09T13:15:49.694Z" }, + { url = "https://files.pythonhosted.org/packages/52/7d/7157f9bba6b455cfb4632ed411e199fc8b8977642c2b12082e1bd9e6d173/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16", size = 77603, upload-time = "2026-03-09T13:15:50.945Z" }, + { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" }, +] + [[package]] name = "loguru" version = "0.7.3" @@ -936,6 +1102,70 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "matplotlib" +version = "3.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/64/f9a391af28f518b11ad45a8a712353c94a0aefce09d3703200e5c54b610a/matplotlib-3.11.1.tar.gz", hash = "sha256:69647db5746941c793d6e445a4cd349323ffb87d9cc958c2ad84a659b4832d30", size = 32612045, upload-time = "2026-07-18T03:39:46.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/d0/791aa183dd88491555cf7d4be0b52b0bcf6c3c2a2c22c815a2e819bf53e2/matplotlib-3.11.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:b7cf158e7add54a8d51ac9b5a84abd6d4e13ed4951b4f25f1c5139f41c2addb2", size = 9440302, upload-time = "2026-07-18T03:38:03.844Z" }, + { url = "https://files.pythonhosted.org/packages/35/74/82bbdf683a301f4478384c8aaba6903631a2ca18294b2d7655c9a542bffb/matplotlib-3.11.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d2ace7273b9a5061a3b420918a16fae1f2dc5dfee1abcc13aba71b5d94b1820c", size = 9268549, upload-time = "2026-07-18T03:38:06.144Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f0/9b4298911303f74e6d83e64a81d996c0616405ec95046fac7f17e4258b9e/matplotlib-3.11.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee55e9041211bf84302ab55ec3965df18dd90ae19f8b58332a7feaf208bfe83", size = 10024922, upload-time = "2026-07-18T03:38:08.236Z" }, + { url = "https://files.pythonhosted.org/packages/84/6f/0bc3c3d05b021db44c14bc379a7c0df7d57302aa15380c16fd4e63fd6a9b/matplotlib-3.11.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f4bdeea33a8d15a071dbfe6d119451b1d719c733ac666d65357082901a9099", size = 10832170, upload-time = "2026-07-18T03:38:10.276Z" }, + { url = "https://files.pythonhosted.org/packages/db/4d/e375f39acdb2af5a9342730618608e39790ec842e6f1b392863028781459/matplotlib-3.11.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b4c78ceb2f11bcac7389d305cda17aeb1f4586a857854ab5780bd3dd8dbfc407", size = 10916701, upload-time = "2026-07-18T03:38:12.512Z" }, + { url = "https://files.pythonhosted.org/packages/bc/be/fa26ed085b41298f64a8f9b7592c671bbf1acc8b0df124c1c5de96b859f8/matplotlib-3.11.1-cp311-cp311-win_amd64.whl", hash = "sha256:7f33a781e12b1e53b278deb2f5373c2e55ec4f10727be3440c0cfb5cda9f944f", size = 9315331, upload-time = "2026-07-18T03:38:14.949Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/eb5bdf3b6e191b200db298b08bbc1638b7f3c82cdc8680f9d88bf72559ae/matplotlib-3.11.1-cp311-cp311-win_arm64.whl", hash = "sha256:67e4c3cd578c65ebd81bdc09a1b6592ceafee6dfafe116dc85dfcb647b5bbb18", size = 9003475, upload-time = "2026-07-18T03:38:17.205Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6c/7ef7ebcb2bd9739b2b66b18b076e077f44bb46fdbe28ca0506edb3c62c79/matplotlib-3.11.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e15ef41507f3d525f46154ac9e3ae785dacde9f20e593a25de8986267892ef74", size = 9453849, upload-time = "2026-07-18T03:38:19.593Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f8/6d0c312c8d9738e7d9677f09fe5c986b3239e651a7b73a2deb38b65e4a71/matplotlib-3.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:21a67b961a6d597bca54fae826cd20695ba4a6e4d05424a08da6e13e3176fd6b", size = 9283113, upload-time = "2026-07-18T03:38:21.95Z" }, + { url = "https://files.pythonhosted.org/packages/c9/cf/b4ad2cc81b6672ea29ea04e64e350a9f9b493b0908ccd884c67eeff8f7b2/matplotlib-3.11.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ba8f811b8ddfac493734d6af0b2dff96919d0c28ca0d641858dab4262777c6ea", size = 10035615, upload-time = "2026-07-18T03:38:24.315Z" }, + { url = "https://files.pythonhosted.org/packages/88/90/4e10e033d9b66589d8ed98b84c95cdbb57033d57c1f41339d7393dbd2f2e/matplotlib-3.11.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c52f7ad20ef476806ed212380b1d54d20310c8b86bdc2c9a68b51f0024a44472", size = 10842559, upload-time = "2026-07-18T03:38:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/88/eb/799612d0f8cd3e816a10fec59329fca52cd2353264df80378dfc541ae855/matplotlib-3.11.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8b14eb22961fe865efb0e4ff167e333e428908b00115a8d800ccb65ee108e481", size = 10927532, upload-time = "2026-07-18T03:38:28.532Z" }, + { url = "https://files.pythonhosted.org/packages/88/89/56649bbaa2fd12e20f3be03dbcc135b0c8676d88bac17977599e3eb442a0/matplotlib-3.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:88a2a27dd9691ae448dfae4b26f59036be90c3c28757edd3553a29559d00859f", size = 9333886, upload-time = "2026-07-18T03:38:30.477Z" }, + { url = "https://files.pythonhosted.org/packages/c1/11/4d124efbbad677b7b7552f6f85a3bd432d4232f95400cea98fcd2ae36ef3/matplotlib-3.11.1-cp312-cp312-win_arm64.whl", hash = "sha256:480194afceca4df2f137c2721227d3cba67121fbf4397b69cee7f83714b0a58a", size = 9007545, upload-time = "2026-07-18T03:38:32.833Z" }, + { url = "https://files.pythonhosted.org/packages/04/6c/4798363b7fb5644e309fe1fac30216e9146c9f70859d80d588c18caf5317/matplotlib-3.11.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6771b0cd7838c6a857a7209814158c0ad09bfef878db3033dd82d70ad101f191", size = 9454341, upload-time = "2026-07-18T03:38:35.001Z" }, + { url = "https://files.pythonhosted.org/packages/59/98/6acadbe7f98df19d274bc107ac58bb439fa75df82c33dc110d71a4a8501f/matplotlib-3.11.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2abdee5ffa2fe11b2d19f7a5c63b785fb7c28cc46c7bc1814156341d9d1a33e1", size = 9283627, upload-time = "2026-07-18T03:38:37.061Z" }, + { url = "https://files.pythonhosted.org/packages/24/ea/65cec46fe241390ccea1b1754207ee28eb71c5ab866bd5f22fe47e538fa4/matplotlib-3.11.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0a19dcf73406d3746d25a5ed42d713604c9a3e024d129b102852b0d941cb9f3", size = 10035860, upload-time = "2026-07-18T03:38:39.663Z" }, + { url = "https://files.pythonhosted.org/packages/c7/10/63fdccccbabe002fb0960876baabc5e3f24d9c1bb4cfb25651457f74b3a0/matplotlib-3.11.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7389b77ed2ab0552f46d9a90b81b7b8e6dfcdc42adc36c37a0865799843e0e3e", size = 10843594, upload-time = "2026-07-18T03:38:42.144Z" }, + { url = "https://files.pythonhosted.org/packages/98/51/a1155945bff7b91381875022ac1522c5dfdac0d006be8e7df389b3134eae/matplotlib-3.11.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c90be0b73568da4f662afac580956a76e308437e641b4a45aa08925eeb67d95f", size = 10927962, upload-time = "2026-07-18T03:38:44.302Z" }, + { url = "https://files.pythonhosted.org/packages/0d/3a/3d5e1f42dc761bf53401a62a83ff93389b37de9d2c093b2a3aa49ac34f1b/matplotlib-3.11.1-cp313-cp313-win_amd64.whl", hash = "sha256:68408341f2312836fbbdf6b3c78047f65b2d8752f5fd221c3e72d348f5b34f8b", size = 9334074, upload-time = "2026-07-18T03:38:46.616Z" }, + { url = "https://files.pythonhosted.org/packages/e2/db/3f5ea5a5b64060ef5e1ff60a19170423e41ce21b8497a6fe15a36e0b43e3/matplotlib-3.11.1-cp313-cp313-win_arm64.whl", hash = "sha256:0c1f44890d435c1b4ef52f701ad5828cb450ea97bcc83918fda6be74965d6cd2", size = 9007662, upload-time = "2026-07-18T03:38:49.112Z" }, + { url = "https://files.pythonhosted.org/packages/98/6e/c7ae5e0531425b69c0826b00ebbc264c85cab853f1cd6e096c9983c2cdc1/matplotlib-3.11.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:5e510088c27a89d53580a752f959146893563e63c330e161d159b0fee652af6f", size = 9503790, upload-time = "2026-07-18T03:38:51.527Z" }, + { url = "https://files.pythonhosted.org/packages/92/79/15be162e0a2ed546939674e2e97d0e33ec2447d86d4d4e611fa295bb178c/matplotlib-3.11.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:1524e2bdd48a93557aa47ddcfe9c225dfdd57d5a01a5c49128c20f0632980ee1", size = 9336148, upload-time = "2026-07-18T03:38:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7f/36ffe144fc4aacfe0e3ed2318f72b6755d1e73b041d619b4d393e60f5a66/matplotlib-3.11.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:11664c551345553db92e61cae6cf1376f138f8c47cafdf13b64b18f3e3e9e464", size = 10049244, upload-time = "2026-07-18T03:38:55.911Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/55812d68c0a840d3a463638f48c00ab1fe338518ec49a640cb6473b444af/matplotlib-3.11.1-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e1f8922ba31959cf6a9dfb51be64b7f7bc582801a3957dc0c2f3afcd3537adf", size = 10860798, upload-time = "2026-07-18T03:38:58.282Z" }, + { url = "https://files.pythonhosted.org/packages/7a/64/cca444b4eb5e6c768c44fc5e1f0b5211f20ca2b282778051996e996a2bdf/matplotlib-3.11.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83235693abde86e5e0129998f80ee39fc7f58e6d56a88fafb28a9278833e9d5f", size = 10943282, upload-time = "2026-07-18T03:39:00.465Z" }, + { url = "https://files.pythonhosted.org/packages/e5/0f/a49c329d394f2e9ef38506982107e8b04ecf94dd41a9d8423ff82cc737c7/matplotlib-3.11.1-cp313-cp313t-win_amd64.whl", hash = "sha256:9a076f4fc5cdc43fdf510f5981418d25c2db4973418d9f22d8bb3dc8045ada78", size = 9383532, upload-time = "2026-07-18T03:39:02.468Z" }, + { url = "https://files.pythonhosted.org/packages/e4/50/103e86afb806d8f64d04ede14e4cfc09dbfc25f512421ff85fdd6ebd59cf/matplotlib-3.11.1-cp313-cp313t-win_arm64.whl", hash = "sha256:216fbb93a74add02ddb4cb38ef5348f59ac00b3e84567eaf16598772d40e150a", size = 9059665, upload-time = "2026-07-18T03:39:04.607Z" }, + { url = "https://files.pythonhosted.org/packages/35/04/3079499fa8cb661ea66d13d6439d5a3ae6710a7afd5c7f72e08914f275f8/matplotlib-3.11.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:30c492d4ba9448595b6fd8708c6725963f8148e25c0d8842948da5b05f0ee8d3", size = 9456022, upload-time = "2026-07-18T03:39:07.041Z" }, + { url = "https://files.pythonhosted.org/packages/53/a2/69acfe84ec1f32930e801a5782a07fc5c79c8c6599a507b806d859d5da8e/matplotlib-3.11.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ac104be2768ffdd8655db9e71b768cbb45f2b9aa7b450cf1595e8f65d3822319", size = 9285475, upload-time = "2026-07-18T03:39:09.562Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b3/31b15a2ca56d4ddd6aaa1c884c2f51cf9a61cfaf5ca6f6fbd6343d38e6df/matplotlib-3.11.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be943cb68bc6660ead58c55b3aa6366cba2ef7feb06460fbcce32360376f19f", size = 10847102, upload-time = "2026-07-18T03:39:11.532Z" }, + { url = "https://files.pythonhosted.org/packages/64/0d/a17e966e620545c1548125af0b29ac812dd17b197a18a7462ac12fa859ee/matplotlib-3.11.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5af0dcda57d471440a7b5b623e70e0a61003518443d9098f211a96ecfbbc25be", size = 11131087, upload-time = "2026-07-18T03:39:13.764Z" }, + { url = "https://files.pythonhosted.org/packages/97/c5/5e100efdd67abb7de20befaa333612ef9bfc63417fb71398f904f25d083c/matplotlib-3.11.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3d3fd84082b1afbd9398466c81309e20045be20d48fe0fb18c43504d164cbbb2", size = 10929036, upload-time = "2026-07-18T03:39:16.888Z" }, + { url = "https://files.pythonhosted.org/packages/ce/04/d719a0a36930ecc8dfc801ff340f9dcfc4223f8ca5d39d06b4020032fff8/matplotlib-3.11.1-cp314-cp314-win_amd64.whl", hash = "sha256:9601a1e90be21e4884c53b4f3dc3ee0544654946f9975258d691f1c2e2f119c6", size = 9489571, upload-time = "2026-07-18T03:39:19.449Z" }, + { url = "https://files.pythonhosted.org/packages/48/65/facabdc2f1f6caba7e856db64dfedddca25f7608df07d96a1c8fd114fd3b/matplotlib-3.11.1-cp314-cp314-win_arm64.whl", hash = "sha256:ae30c6109848ac0f9fa36c5d6270938487614c47ba31860bd5361266dabc5685", size = 9164486, upload-time = "2026-07-18T03:39:21.424Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/18da6cd01cf96354534f98c468a25380c68ce582a2c9dd0cae12b04af4f2/matplotlib-3.11.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:dadfe80797174e2984aae3be0b77594a3c72d2c0a40fbd4a0de48d2728caf3ae", size = 9504876, upload-time = "2026-07-18T03:39:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/b0/f0b63555a18b79d038c81fd6126f35fc4dfce0eaff48d96103348c7cf935/matplotlib-3.11.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:89b193b255f4f6f7948dbcee3691f4f341ab05d9a8874a67b45ddb4182922eda", size = 9336120, upload-time = "2026-07-18T03:39:25.797Z" }, + { url = "https://files.pythonhosted.org/packages/c6/dd/f210ec7c4a6f198d5567237048a93d0811fb5a1f1691f13320e592f95b41/matplotlib-3.11.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:191163532cdefcb1571ca38a6d7e6474baccde64495783e6ba47aa07ec4b9bbb", size = 10858033, upload-time = "2026-07-18T03:39:27.999Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d2/d6d5324507c5fbb316db48e258c09c2807f3de03d9af47017e120070926f/matplotlib-3.11.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9fdf1c818ab05d0e74002091ddaf414478a3a449ec9d51c8976d45be7e3a01e2", size = 11141827, upload-time = "2026-07-18T03:39:30.092Z" }, + { url = "https://files.pythonhosted.org/packages/0f/68/3c22e9320bdce2c4d2f1320643ef706db7a24cb7420eea28b97a2d67f5a8/matplotlib-3.11.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b937b9dba5f5f6c1e31c47abe2186c865c0914fd18f2ce0dfc39c9adcef5951d", size = 10943061, upload-time = "2026-07-18T03:39:32.356Z" }, + { url = "https://files.pythonhosted.org/packages/f6/4a/907ed190ee81a9df581e0ed5456134fc0f7cb55ffcfda2f9e54ca900761c/matplotlib-3.11.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f2912f647f3fbe1ccf085f91e213936f9101bead81a5e670565b1f1b3712f4fb", size = 9540074, upload-time = "2026-07-18T03:39:34.789Z" }, + { url = "https://files.pythonhosted.org/packages/23/d4/97c19b77e0a6e3b48581185bb65088f431cd20186076cc0f650a1757ea46/matplotlib-3.11.1-cp314-cp314t-win_arm64.whl", hash = "sha256:54d47b8ae8b579633a3902ca5b4ad6c1e132a5626d64447b2e22a66394e79987", size = 9213472, upload-time = "2026-07-18T03:39:37.141Z" }, + { url = "https://files.pythonhosted.org/packages/ee/38/ceb1d637c4db6d06141f3739e93af3321e7caaabe69b57ae48ffe3ee95b1/matplotlib-3.11.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:427258425f9a3fc4ed79a91f9e9b9aaf5a82cb6571e85dc14063cc6fbb993741", size = 9438045, upload-time = "2026-07-18T03:39:39.491Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/72ad8b58602d3a6ef1dfc4b65ecd01634ab65a2bdf494c9fe0e966dbf081/matplotlib-3.11.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:1ac697e591c11b6ad04679a73c2d2f9980fe9d9f0311fb414a2e329706343dfb", size = 9266127, upload-time = "2026-07-18T03:39:41.597Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6d/69552382fcc8e93d1f2763ef2665980a900a48b7f3a4c57ed290726d1cbc/matplotlib-3.11.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e4b9ac2f1f607ecda2af90a5232beee2af7582fce1cc30c4b6a1b012dc21ee99", size = 10019439, upload-time = "2026-07-18T03:39:43.78Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -1051,26 +1281,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/e5/b7d20451657664b07986c2f6e3be564433f5dcaf3482d68eaecd79afaf03/numpy-2.4.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be71bf1edb48ebbbf7f6337b5bfd2f895d1902f6335a5830b20141fc126ffba0", size = 12502577, upload-time = "2026-01-31T23:13:07.08Z" }, ] -[[package]] -name = "oa-atomacos" -version = "3.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "future" }, - { name = "pyautogui" }, - { name = "pygetwindow" }, - { name = "pyobjc-core" }, - { name = "pyobjc-framework-applicationservices" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-coretext" }, - { name = "pyobjc-framework-quartz" }, - { name = "pyscreeze" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ec/fc/6af1e93224d042059b70586f228da82c0135da5f6c1dee5832c7d339ac66/oa_atomacos-3.2.0.tar.gz", hash = "sha256:6516fa7ad818fa990c2b678966f0821b808696ef2e756a35060b15d1b4e17578", size = 37702, upload-time = "2023-08-04T22:45:17.703Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/67/8d/664b0a60140afcc289df2a1e9aa2c01c0d6b0ec9f5a060a173316c4f8ffb/oa_atomacos-3.2.0-py2.py3-none-any.whl", hash = "sha256:76b9b78344afd313e8e456ca446f89560eb036342fbb6b68adaa53c4548411ac", size = 27211, upload-time = "2023-08-04T22:45:15.597Z" }, -] - [[package]] name = "onnxruntime" version = "1.20.1" @@ -1136,32 +1346,31 @@ wheels = [ [[package]] name = "openadapt-capture" -version = "1.0.1" +version = "1.0.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "alembic" }, - { name = "av" }, { name = "fire" }, { name = "loguru" }, + { name = "matplotlib" }, { name = "mss" }, { name = "numpy" }, - { name = "oa-atomacos", marker = "sys_platform == 'darwin'" }, { name = "openai" }, { name = "pillow" }, { name = "psutil" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pympler" }, - { name = "pynput" }, + { name = "pyobjc-framework-applicationservices", marker = "sys_platform == 'darwin'" }, { name = "sounddevice" }, { name = "soundfile" }, { name = "sqlalchemy" }, { name = "tqdm" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9c/a7/2f084a8b807119c7d56c90b50261f6fa933f28d79496330c7b96c7bc38b4/openadapt_capture-1.0.1.tar.gz", hash = "sha256:51f5b47e7e7e06c03476f67dbab4f434fbd6b2e2fad3b0a5759059105aef647c", size = 11915649, upload-time = "2026-07-23T19:04:58.67Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/b7/d7002739f0eca1bcdaa748c4e47154b8c0c785387ae3f1f584d13f8062e1/openadapt_capture-1.0.4.tar.gz", hash = "sha256:990b93ed81f58c55ae0b5ee2dd09bcf1714740e7d995768d119ca742fb2ce9fc", size = 11976924, upload-time = "2026-07-24T01:17:58.008Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/a2/a30a5dd98f106a012d44072c6387934e4fbe9bd31bdb06171165f29c3daf/openadapt_capture-1.0.1-py3-none-any.whl", hash = "sha256:b0b73171ad619c534c24f61836a4c6fb3d0b91799e57e26c42b273d9b72039fe", size = 149087, upload-time = "2026-07-23T19:04:56.656Z" }, + { url = "https://files.pythonhosted.org/packages/25/39/31677da3654aae8671c80abcca51ac805bf3331e62d95aa1c825a274dfb1/openadapt_capture-1.0.4-py3-none-any.whl", hash = "sha256:a5e93317e054f5faef6dd88ce9a50878770b16cffca97e21d59e6c171b4c6808", size = 185407, upload-time = "2026-07-24T01:17:56.207Z" }, ] [[package]] @@ -1208,9 +1417,9 @@ requires-dist = [ { name = "loguru" }, { name = "onnxruntime", marker = "platform_machine == 'x86_64' and sys_platform == 'darwin' and extra == 'build'", specifier = "==1.20.1" }, { name = "onnxruntime", marker = "(platform_machine != 'x86_64' and extra == 'build') or (sys_platform != 'darwin' and extra == 'build')", specifier = "==1.27.0" }, - { name = "openadapt-capture", specifier = ">=1.0.1" }, + { name = "openadapt-capture", specifier = ">=1.0.4" }, { name = "openadapt-desktop", extras = ["enterprise"], marker = "extra == 'full'" }, - { name = "openadapt-flow", marker = "extra == 'build'", specifier = "==1.20.0" }, + { name = "openadapt-flow", marker = "extra == 'build'", specifier = "==1.20.1" }, { name = "openadapt-privacy", specifier = ">=1.0.0" }, { name = "psutil" }, { name = "pydantic", specifier = ">=2.0" }, @@ -1226,13 +1435,12 @@ provides-extras = ["enterprise", "full", "build", "dev"] [[package]] name = "openadapt-flow" -version = "1.20.0" +version = "1.20.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "httpx" }, { name = "idna" }, - { name = "imagehash" }, { name = "numpy" }, { name = "opencv-python-headless" }, { name = "pillow" }, @@ -1241,9 +1449,9 @@ dependencies = [ { name = "pyyaml" }, { name = "rapidocr-onnxruntime" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1d/ec/236c997059ca9c3d7df0ac9cb6a05c3cf899f8a14d8c9ae8963edd8bf112/openadapt_flow-1.20.0.tar.gz", hash = "sha256:7123e80b0fc6160ec499f4cdf5aa14777219561cdd8dd06dc18b8adea3971a9f", size = 18846003, upload-time = "2026-07-23T10:24:18.688Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/c6/89fdb1162a3e2473b88349d010a4c85d51630f29267c1e135bbffe893e09/openadapt_flow-1.20.1.tar.gz", hash = "sha256:b65dbe853f25f8d259ba848eb5c7ae3494ef12c472ae0d9c3758687fee1650a1", size = 18851535, upload-time = "2026-07-24T00:15:56.415Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/05/c848efc8ff4678d02580964a3eabab6d264b6cd518f823040787288c4af9/openadapt_flow-1.20.0-py3-none-any.whl", hash = "sha256:0dba8f53f54bda782c5290cbb046c8879e5baa9ed1f61cac9609440f3ab9690b", size = 1181510, upload-time = "2026-07-23T10:24:16.6Z" }, + { url = "https://files.pythonhosted.org/packages/ba/12/e7b2630b231fa85ebe559e6934298dd5bfa8b4ae36dec884114dd5de85e7/openadapt_flow-1.20.1-py3-none-any.whl", hash = "sha256:9b8b7677bcb39f1c0448e012ec9791b6c44dce4e0a8c04734165e663c99cc8f3", size = 1182939, upload-time = "2026-07-24T00:15:54.163Z" }, ] [[package]] @@ -1502,19 +1710,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, ] -[[package]] -name = "pyautogui" -version = "0.9.41" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pillow" }, - { name = "pygetwindow" }, - { name = "pymsgbox" }, - { name = "pyscreeze" }, - { name = "pytweening" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/19/ef/438d80abd396fd2d124bd37c07c765f913723c54197c4c809d85c8ff5a43/PyAutoGUI-0.9.41.tar.gz", hash = "sha256:1b57b1c8eeeff74bdbbe91162607ca7ac951e918b250072dcd3dd30c3f73da7c", size = 50145, upload-time = "2019-01-07T19:10:15.883Z" } - [[package]] name = "pyclipper" version = "1.4.0" @@ -1699,15 +1894,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, ] -[[package]] -name = "pygetwindow" -version = "0.0.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyrect" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/01/ed/56d4a369c6e18f6b239d9ef37b3222ba308bfebf949571b2611ff7d64f1d/PyGetWindow-0.0.4.tar.gz", hash = "sha256:e51e173f2ce8a6a9260fde7f0cf4d15df99ea0d622fad67a400f3e9d6a8a67f2", size = 8898, upload-time = "2019-01-29T02:11:49.715Z" } - [[package]] name = "pygments" version = "2.19.2" @@ -1769,48 +1955,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/4f/a6a2e2b202d7fd97eadfe90979845b8706676b41cbd3b42ba75adf329d1f/Pympler-1.1-py3-none-any.whl", hash = "sha256:5b223d6027d0619584116a0cbc28e8d2e378f7a79c1e5e024f9ff3b673c58506", size = 165766, upload-time = "2024-06-28T19:56:05.087Z" }, ] -[[package]] -name = "pymsgbox" -version = "2.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ae/6a/e80da7594ee598a776972d09e2813df2b06b3bc29218f440631dfa7c78a8/pymsgbox-2.0.1.tar.gz", hash = "sha256:98d055c49a511dcc10fa08c3043e7102d468f5e4b3a83c6d3c61df722c7d798d", size = 20768, upload-time = "2025-09-09T00:38:56.863Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/3e/08c8cac81b2b2f7502746e6b9c8e5b0ec6432cd882c605560fc409aaf087/pymsgbox-2.0.1-py3-none-any.whl", hash = "sha256:5de8ec19bca2ca7e6c09d39c817c83f17c75cee80275235f43a9931db699f73b", size = 9994, upload-time = "2025-09-09T00:38:55.672Z" }, -] - -[[package]] -name = "pynput" -version = "1.8.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "evdev", marker = "'linux' in sys_platform" }, - { name = "pyobjc-framework-applicationservices", marker = "sys_platform == 'darwin'" }, - { name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" }, - { name = "python-xlib", marker = "'linux' in sys_platform" }, - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f0/c3/dccf44c68225046df5324db0cc7d563a560635355b3e5f1d249468268a6f/pynput-1.8.1.tar.gz", hash = "sha256:70d7c8373ee98911004a7c938742242840a5628c004573d84ba849d4601df81e", size = 82289, upload-time = "2025-03-17T17:12:01.481Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/59/4f/ac3fa906ae8a375a536b12794128c5efacade9eaa917a35dfd27ce0c7400/pynput-1.8.1-py2.py3-none-any.whl", hash = "sha256:42dfcf27404459ca16ca889c8fb8ffe42a9fe54f722fd1a3e130728e59e768d2", size = 91693, upload-time = "2025-03-17T17:12:00.094Z" }, -] - [[package]] name = "pyobjc-core" -version = "12.1" +version = "12.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b8/b6/d5612eb40be4fd5ef88c259339e6313f46ba67577a95d86c3470b951fce0/pyobjc_core-12.1.tar.gz", hash = "sha256:2bb3903f5387f72422145e1466b3ac3f7f0ef2e9960afa9bcd8961c5cbf8bd21", size = 1000532, upload-time = "2025-11-14T10:08:28.292Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/b1/729f7458a63758bd21716648a8abcd9a0c8f2d2e9897763c8a1a1c7fd31b/pyobjc_core-12.2.1.tar.gz", hash = "sha256:7a7b9b018402342cf32bf1956366896350fbe5c0478cb3ef59778f77abed7f07", size = 1063383, upload-time = "2026-06-19T16:19:39.357Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/df/d2b290708e9da86d6e7a9a2a2022b91915cf2e712a5a82e306cb6ee99792/pyobjc_core-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c918ebca280925e7fcb14c5c43ce12dcb9574a33cccb889be7c8c17f3bcce8b6", size = 671263, upload-time = "2025-11-14T09:31:35.231Z" }, - { url = "https://files.pythonhosted.org/packages/64/5a/6b15e499de73050f4a2c88fff664ae154307d25dc04da8fb38998a428358/pyobjc_core-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:818bcc6723561f207e5b5453efe9703f34bc8781d11ce9b8be286bb415eb4962", size = 678335, upload-time = "2025-11-14T09:32:20.107Z" }, - { url = "https://files.pythonhosted.org/packages/f4/d2/29e5e536adc07bc3d33dd09f3f7cf844bf7b4981820dc2a91dd810f3c782/pyobjc_core-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:01c0cf500596f03e21c23aef9b5f326b9fb1f8f118cf0d8b66749b6cf4cbb37a", size = 677370, upload-time = "2025-11-14T09:33:05.273Z" }, - { url = "https://files.pythonhosted.org/packages/1b/f0/4b4ed8924cd04e425f2a07269943018d43949afad1c348c3ed4d9d032787/pyobjc_core-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:177aaca84bb369a483e4961186704f64b2697708046745f8167e818d968c88fc", size = 719586, upload-time = "2025-11-14T09:33:53.302Z" }, - { url = "https://files.pythonhosted.org/packages/25/98/9f4ed07162de69603144ff480be35cd021808faa7f730d082b92f7ebf2b5/pyobjc_core-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:844515f5d86395b979d02152576e7dee9cc679acc0b32dc626ef5bda315eaa43", size = 670164, upload-time = "2025-11-14T09:34:37.458Z" }, - { url = "https://files.pythonhosted.org/packages/62/50/dc076965c96c7f0de25c0a32b7f8aa98133ed244deaeeacfc758783f1f30/pyobjc_core-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:453b191df1a4b80e756445b935491b974714456ae2cbae816840bd96f86db882", size = 712204, upload-time = "2025-11-14T09:35:24.148Z" }, + { url = "https://files.pythonhosted.org/packages/92/87/16564ef5e4568ee0edd9e712d8111dc8b67621d6bb6ff430646ee2d637dd/pyobjc_core-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:24b76a63caf0b5369d4a377c7c0438cd70df81539057af3db839bfaa3579e04a", size = 6484662, upload-time = "2026-06-19T16:04:44.979Z" }, + { url = "https://files.pythonhosted.org/packages/8c/88/300ad283bed0c971c52dcac6f70113e138169d4ce6d856ddd03d16081e51/pyobjc_core-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a64232bb27ed101d4adc7d42b0e64a6d3331aac7bee7861c037a6777a163f10b", size = 6433347, upload-time = "2026-06-19T16:04:49.341Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1e/b9b0ddffae66996b8779f1f7958adc9f21c13a0448cd3be8d7fe589b5b0f/pyobjc_core-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:af101222762665a4125157906cb4b23f5d5a63d3851d5e0504f72a1eaaa2cfd2", size = 6436004, upload-time = "2026-06-19T16:04:53.257Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/bd309ede07784c6e5fac4b440c90a5f72a66da7859ed303a9392fe8a5f3f/pyobjc_core-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:efe465e3ecc6fc73f7c7622620345d134a8d34564ab1c29d8247e45f4ed55071", size = 6687044, upload-time = "2026-06-19T16:04:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/bd/8a/cfa4f56939d554dbb342ec6e5226a441e2f552bc2002a0ddf7705bb11bef/pyobjc_core-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2b8fc0531c27277325e113ac00b8a72a82e6145f0a88175b9425d8de814ff69a", size = 6429289, upload-time = "2026-06-19T16:05:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/42/74/446c89bc18103aaa4a00d1fb85ff8acace9a0dc3f362d9678ebf7571e275/pyobjc_core-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9bef500f979e22d54f9da3aaebf6a48f873234b324858bd69256055a318955c7", size = 6690181, upload-time = "2026-06-19T16:05:06.201Z" }, + { url = "https://files.pythonhosted.org/packages/99/c7/0121ee4c616af07ad2de8cd1a286f6978dc9a227eb58b7c2e875cb68a1df/pyobjc_core-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:047c226eeb58a2993ace5e8904e71cc9426ee20d064c617f8fbf32717d37093e", size = 6487078, upload-time = "2026-06-19T16:05:10.093Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a8/cb9fcc150f97d0bf22a2028f88b24cc35949beb1bcc7b8bc5c17d4401677/pyobjc_core-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1188613805336270279570467e4455b74cb6c0f60913ac74c917ee1c37cfaecb", size = 6733064, upload-time = "2026-06-19T16:05:14.313Z" }, ] [[package]] name = "pyobjc-framework-applicationservices" -version = "12.1" +version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, @@ -1818,84 +1981,86 @@ dependencies = [ { name = "pyobjc-framework-coretext" }, { name = "pyobjc-framework-quartz" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/be/6a/d4e613c8e926a5744fc47a9e9fea08384a510dc4f27d844f7ad7a2d793bd/pyobjc_framework_applicationservices-12.1.tar.gz", hash = "sha256:c06abb74f119bc27aeb41bf1aef8102c0ae1288aec1ac8665ea186a067a8945b", size = 103247, upload-time = "2025-11-14T10:08:52.18Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/4d/0ebdd8144aba94b8fe9828ccee5616a4bf53d1f8bc51cff55f3cce86d695/pyobjc_framework_applicationservices-12.2.1.tar.gz", hash = "sha256:048ea663c9ac75c44a15dc7d5b8d78cbb4c97bf1c76e83835e8d5498e184001f", size = 109342, upload-time = "2026-06-19T16:19:46.149Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/86/d07eff705ff909a0ffa96d14fc14026e9fc9dd716233648c53dfd5056b8e/pyobjc_framework_applicationservices-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:bdddd492eeac6d14ff2f5bd342aba29e30dffa72a2d358c08444da22129890e2", size = 32784, upload-time = "2025-11-14T09:36:08.755Z" }, - { url = "https://files.pythonhosted.org/packages/37/a7/55fa88def5c02732c4b747606ff1cbce6e1f890734bbd00f5596b21eaa02/pyobjc_framework_applicationservices-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c8f6e2fb3b3e9214ab4864ef04eee18f592b46a986c86ea0113448b310520532", size = 32835, upload-time = "2025-11-14T09:36:11.855Z" }, - { url = "https://files.pythonhosted.org/packages/fc/21/79e42ee836f1010f5fe9e97d2817a006736bd287c15a3674c399190a2e77/pyobjc_framework_applicationservices-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bd1f4dbb38234a24ae6819f5e22485cf7dd3dd4074ff3bf9a9fdb4c01a3b4a38", size = 32859, upload-time = "2025-11-14T09:36:15.208Z" }, - { url = "https://files.pythonhosted.org/packages/66/3a/0f1d4dcf2345e875e5ea9761d5a70969e241d24089133d21f008dde596f5/pyobjc_framework_applicationservices-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:8a5d2845249b6a85ba9e320a9848468c3f8cd6f59605a9a43f406a7810eaa830", size = 33115, upload-time = "2025-11-14T09:36:18.384Z" }, - { url = "https://files.pythonhosted.org/packages/40/44/3196b40fec68b4413c92875311f17ccf4c3ff7d2e53676f8fc18ad29bd18/pyobjc_framework_applicationservices-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f43c9a24ad97a9121276d4d571aa04a924282c80d7291cfb3b29839c3e2013a8", size = 32997, upload-time = "2025-11-14T09:36:21.58Z" }, - { url = "https://files.pythonhosted.org/packages/fd/bb/dab21d2210d3ef7dd0616df7e8ea89b5d8d62444133a25f76e649a947168/pyobjc_framework_applicationservices-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1f72e20009a4ebfd5ed5b23dc11c1528ad6b55cc63ee71952ddb2a5e5f1cb7da", size = 33238, upload-time = "2025-11-14T09:36:24.751Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8a/5a9310929bb303c31c04a1c6dc7b3213c9bd964a692d024a00c0643af2c8/pyobjc_framework_applicationservices-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dabec481217b0d0c1ea835e9fef6b0681381b14b3f16a30d4a9d801ee3852dd2", size = 32715, upload-time = "2026-06-19T16:05:38.448Z" }, + { url = "https://files.pythonhosted.org/packages/bf/89/39a7462006afbc06c69029fe4181b7359a9da25ae7864ef75f9d3ffb9272/pyobjc_framework_applicationservices-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f519ced13888d03410cd7da1f08fc56ee2944099e607216cef7ca26ecfdef61b", size = 32764, upload-time = "2026-06-19T16:05:39.26Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6e/8e928d5e3025529ed92c6eb5fd88a5e6e485cc6df945c541f29b4af7f2c6/pyobjc_framework_applicationservices-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8749290f796e6cca341d443769b79329dde5d157bcc4413c1f7fdb68ea4a8e48", size = 32782, upload-time = "2026-06-19T16:05:40.284Z" }, + { url = "https://files.pythonhosted.org/packages/50/a5/c7b5a31777fe2ce7c07b9c16941ff4fbf0a150bf755164d96228d32ccb4f/pyobjc_framework_applicationservices-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9ee11677fbd6a0987234814c7dde88ffd11242e8c1f76952e6654ea07f2370ac", size = 33048, upload-time = "2026-06-19T16:05:41.308Z" }, + { url = "https://files.pythonhosted.org/packages/8b/47/cd2bd76b862686c0aa78568ed9dff175764353c209a3096c72d6e2a9b151/pyobjc_framework_applicationservices-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:a1c0ee536cb8bd7f5a811165ec323a9207b1e8dad9534fe2081f767fb90b0411", size = 32921, upload-time = "2026-06-19T16:05:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/6989a96f8501aa3a16513d08b2c4c78ca906a10b6a4e5a33c4c59fd1bea9/pyobjc_framework_applicationservices-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0d55dd5be19e4a1363662bc8b48894d45714d07f0ee3958665fc9ea7df0f61b7", size = 33163, upload-time = "2026-06-19T16:05:43.256Z" }, + { url = "https://files.pythonhosted.org/packages/5c/41/66f2bcd12454a85f0479393f5825021332ee84b49583c7954cd20310cab5/pyobjc_framework_applicationservices-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:e91c84238b2f68f608473854bcabb8770f15ad837e7561d217f24a1e482e42be", size = 32915, upload-time = "2026-06-19T16:05:44.151Z" }, + { url = "https://files.pythonhosted.org/packages/81/cf/04b6b1eb181fa3071e9743bab7551f5d2ec3f650ac74bab790f21717ba51/pyobjc_framework_applicationservices-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:09bfa27765d4c74155323fd8185b7aba6d639ce06c0a9f00ee2d9e7dce3d7800", size = 33158, upload-time = "2026-06-19T16:05:45.017Z" }, ] [[package]] name = "pyobjc-framework-cocoa" -version = "12.1" +version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/02/a3/16ca9a15e77c061a9250afbae2eae26f2e1579eb8ca9462ae2d2c71e1169/pyobjc_framework_cocoa-12.1.tar.gz", hash = "sha256:5556c87db95711b985d5efdaaf01c917ddd41d148b1e52a0c66b1a2e2c5c1640", size = 2772191, upload-time = "2025-11-14T10:13:02.069Z" } +sdist = { url = "https://files.pythonhosted.org/packages/51/34/fbe38a204643aa4e1b91391cdce07a34da565a69171ebcad08de7438a556/pyobjc_framework_cocoa-12.2.1.tar.gz", hash = "sha256:b94b37fe5730e5ae1fb0052912cd174e6ec329b0bfba4a012ae5db1014b5864b", size = 3125751, upload-time = "2026-06-19T16:20:05.159Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/07/5760735c0fffc65107e648eaf7e0991f46da442ac4493501be5380e6d9d4/pyobjc_framework_cocoa-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f52228bcf38da64b77328787967d464e28b981492b33a7675585141e1b0a01e6", size = 383812, upload-time = "2025-11-14T09:40:53.169Z" }, - { url = "https://files.pythonhosted.org/packages/95/bf/ee4f27ec3920d5c6fc63c63e797c5b2cc4e20fe439217085d01ea5b63856/pyobjc_framework_cocoa-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:547c182837214b7ec4796dac5aee3aa25abc665757b75d7f44f83c994bcb0858", size = 384590, upload-time = "2025-11-14T09:41:17.336Z" }, - { url = "https://files.pythonhosted.org/packages/ad/31/0c2e734165abb46215797bd830c4bdcb780b699854b15f2b6240515edcc6/pyobjc_framework_cocoa-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5a3dcd491cacc2f5a197142b3c556d8aafa3963011110102a093349017705118", size = 384689, upload-time = "2025-11-14T09:41:41.478Z" }, - { url = "https://files.pythonhosted.org/packages/23/3b/b9f61be7b9f9b4e0a6db18b3c35c4c4d589f2d04e963e2174d38c6555a92/pyobjc_framework_cocoa-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:914b74328c22d8ca261d78c23ef2befc29776e0b85555973927b338c5734ca44", size = 388843, upload-time = "2025-11-14T09:42:05.719Z" }, - { url = "https://files.pythonhosted.org/packages/59/bb/f777cc9e775fc7dae77b569254570fe46eb842516b3e4fe383ab49eab598/pyobjc_framework_cocoa-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:03342a60fc0015bcdf9b93ac0b4f457d3938e9ef761b28df9564c91a14f0129a", size = 384932, upload-time = "2025-11-14T09:42:29.771Z" }, - { url = "https://files.pythonhosted.org/packages/58/27/b457b7b37089cad692c8aada90119162dfb4c4a16f513b79a8b2b022b33b/pyobjc_framework_cocoa-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6ba1dc1bfa4da42d04e93d2363491275fb2e2be5c20790e561c8a9e09b8cf2cc", size = 388970, upload-time = "2025-11-14T09:42:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d6/dc66ea8519a0475efbccf73f82cc28066339bb300a27f5e1bf91ab1d7002/pyobjc_framework_cocoa-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dc6da84f4fc62cc25463bbb85e77a57b8d5ac6caf9a60702daf2edb601332f15", size = 387298, upload-time = "2026-06-19T16:07:37.412Z" }, + { url = "https://files.pythonhosted.org/packages/f7/cf/1b3b32b2f28f66cc053c3438ef4e6df36a1591945bf05e7399da18d74553/pyobjc_framework_cocoa-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:28b9b8bab1c36efb94744786918752d0c1842f5fbb67e7d5ca97b5f736512080", size = 388113, upload-time = "2026-06-19T16:07:38.9Z" }, + { url = "https://files.pythonhosted.org/packages/cc/46/68e8e4d926a2f70fed0437047bc3f9fe08af8fe620d94d80656ebc3cfa9b/pyobjc_framework_cocoa-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3b74a78fa7803e547b32e5e8ec1b49987b52fe318383e793bc6cd49b80efbd9f", size = 388183, upload-time = "2026-06-19T16:07:40.483Z" }, + { url = "https://files.pythonhosted.org/packages/2e/f3/dfc9af4c9eb2e5389c860ad5ef252be9fe456db09f39d537555dc5057aa1/pyobjc_framework_cocoa-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:dc2eaca2f13c7bcd8e41e51a372e47825dea9dd3126108760eed7ba883d2945c", size = 392275, upload-time = "2026-06-19T16:07:42.078Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c8/b90baa8f3592eded79b4be98fb59d2b8dc16b62361e34292bd95806ebd9f/pyobjc_framework_cocoa-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b386c324d64ae565c1f6b7dfb77be68f640a1c7c23caa6966ab661131f519561", size = 388357, upload-time = "2026-06-19T16:07:43.364Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/64a94651b9294702d55e748d94de30e25bc59d0784526be7643f4467eccd/pyobjc_framework_cocoa-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a6c584e2af0813cb2f6103b184e632665a26f58c1bd5b08ffd6e95a19c617f7b", size = 392404, upload-time = "2026-06-19T16:07:44.955Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cc/26e8a7bf1f5e8caa38b7f80d486296f9fd3c97e71ad7e5444ef22e802758/pyobjc_framework_cocoa-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:b6023657b8d6cc049a21bd6b4752425f2f53c42f9f0b02d64c7608cc484bf103", size = 388589, upload-time = "2026-06-19T16:07:46.276Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/eedf743a303ea742b8e082afe3613fb4d6618bc1a48cf2568b004ce906f7/pyobjc_framework_cocoa-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:c685ccd8e266a07cf912a2c5a13b1f2eff2a868a1aff163b4801b4687bd425e1", size = 392691, upload-time = "2026-06-19T16:07:47.477Z" }, ] [[package]] name = "pyobjc-framework-coretext" -version = "12.1" +version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, { name = "pyobjc-framework-quartz" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/29/da/682c9c92a39f713bd3c56e7375fa8f1b10ad558ecb075258ab6f1cdd4a6d/pyobjc_framework_coretext-12.1.tar.gz", hash = "sha256:e0adb717738fae395dc645c9e8a10bb5f6a4277e73cba8fa2a57f3b518e71da5", size = 90124, upload-time = "2025-11-14T10:14:38.596Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/9c/4c7f452059dc1d3845b8e627b9113c247a997b9b07518e848c2ab7ff3149/pyobjc_framework_coretext-12.2.1.tar.gz", hash = "sha256:af740e784d7c592c34025ec7165f4f6c1a69b5a2d9075f06e41e4f77c212aed2", size = 97349, upload-time = "2026-06-19T16:20:22.508Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/81/7b8efc41e743adfa2d74b92dec263c91bcebfb188d2a8f5eea1886a195ff/pyobjc_framework_coretext-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4f6742ba5b0bb7629c345e99eff928fbfd9e9d3d667421ac1a2a43bdb7ba9833", size = 29990, upload-time = "2025-11-14T09:47:01.206Z" }, - { url = "https://files.pythonhosted.org/packages/cd/0f/ddf45bf0e3ba4fbdc7772de4728fd97ffc34a0b5a15e1ab1115b202fe4ae/pyobjc_framework_coretext-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d246fa654bdbf43bae3969887d58f0b336c29b795ad55a54eb76397d0e62b93c", size = 30108, upload-time = "2025-11-14T09:47:04.228Z" }, - { url = "https://files.pythonhosted.org/packages/20/a2/a3974e3e807c68e23a9d7db66fc38ac54f7ecd2b7a9237042006699a76e1/pyobjc_framework_coretext-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7cbb2c28580e6704ce10b9a991ccd9563a22b3a75f67c36cf612544bd8b21b5f", size = 30110, upload-time = "2025-11-14T09:47:07.518Z" }, - { url = "https://files.pythonhosted.org/packages/0f/5d/85e059349e9cfbd57269a1f11f56747b3ff5799a3bcbd95485f363c623d8/pyobjc_framework_coretext-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:14100d1e39efb30f57869671fb6fce8d668f80c82e25e7930fb364866e5c0dab", size = 30697, upload-time = "2025-11-14T09:47:10.932Z" }, - { url = "https://files.pythonhosted.org/packages/ef/c3/adf9d306e9ead108167ab7a974ab7d171dbacf31c72fad63e12585f58023/pyobjc_framework_coretext-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:782a1a9617ea267c05226e9cd81a8dec529969a607fe1e037541ee1feb9524e9", size = 30095, upload-time = "2025-11-14T09:47:13.893Z" }, - { url = "https://files.pythonhosted.org/packages/bd/ca/6321295f47a47b0fca7de7e751ddc0ddc360413f4e506335fe9b0f0fb085/pyobjc_framework_coretext-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:7afe379c5a870fa3e66e6f65231c3c1732d9ccd2cd2a4904b2cd5178c9e3c562", size = 30702, upload-time = "2025-11-14T09:47:17.292Z" }, + { url = "https://files.pythonhosted.org/packages/13/53/c262cf5052c648c48b3f7562fcce188fb78ff94e44cf1c48fdfc62fdfcce/pyobjc_framework_coretext-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:02a448675d28005fdb88fb3c585572b02871e9e5d4d08f88334ec937ea5de6e7", size = 30022, upload-time = "2026-06-19T16:09:50.37Z" }, + { url = "https://files.pythonhosted.org/packages/c5/11/c1298c2ec3b0cd19a457a1fd0da47898f894a13df5516f80dc04d1a7a4d9/pyobjc_framework_coretext-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ac2ead13dfa4379a1566129d0e8a8ea778a2bcac9ac360a583360fd4f1ba39c6", size = 30123, upload-time = "2026-06-19T16:09:51.183Z" }, + { url = "https://files.pythonhosted.org/packages/05/8c/154e8f34923b24aade64a20eca2b759f8f67e109654308103080751f246f/pyobjc_framework_coretext-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5c5a3c6e2d905a17efb15572dad97ce582feeab5c3b92537015445e0e0bb46de", size = 30116, upload-time = "2026-06-19T16:09:52.219Z" }, + { url = "https://files.pythonhosted.org/packages/01/61/f53458c8f7fe74008e342946eca1fa82b777b284d4e13d8bd2e3e5724cab/pyobjc_framework_coretext-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5c979058c77df8cd3dac5fd7db4c484f9886fbe09e2687bfaf269a856f631f78", size = 30659, upload-time = "2026-06-19T16:09:53.034Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d8/d1178bb1ba3bb7a0d7a55db460aa89f2a8b232ed7eaf76cb402923cacf2d/pyobjc_framework_coretext-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:d0b3b0467a23dbc2a39d0839e7100cc98b429fb7d52a471bd65477f46bb4c9e5", size = 30100, upload-time = "2026-06-19T16:09:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/12/c3/780d739909e6d8ddd9e8786fd19e8ea10ccfcc7275df987e349af0fb33b1/pyobjc_framework_coretext-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:3d4a92fa657180cd0e900b98535da4f0f4d8c76a7077730a507e50d52d2853e3", size = 30642, upload-time = "2026-06-19T16:09:54.736Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/867197c6cf2396b33e95d6175d7fdc6f789314859fb107560ae9b19c7b14/pyobjc_framework_coretext-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:7a17034fd0a08cf58323b7234a385ce0ec355509d414df69e3f8f88df26de1fc", size = 30098, upload-time = "2026-06-19T16:09:55.679Z" }, + { url = "https://files.pythonhosted.org/packages/10/a3/f4e6d1a38cd4db8a1275eddb287f3cdc2c01c48f80b30e89cc58cfd92156/pyobjc_framework_coretext-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:28980144af75598654f6997b2bbb427885f4f5df90aa4d840f452ba5778ab155", size = 30669, upload-time = "2026-06-19T16:09:56.521Z" }, ] [[package]] name = "pyobjc-framework-quartz" -version = "12.1" +version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/94/18/cc59f3d4355c9456fc945eae7fe8797003c4da99212dd531ad1b0de8a0c6/pyobjc_framework_quartz-12.1.tar.gz", hash = "sha256:27f782f3513ac88ec9b6c82d9767eef95a5cf4175ce88a1e5a65875fee799608", size = 3159099, upload-time = "2025-11-14T10:21:24.31Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/f6/2a8b84dbf1fe7c04dd96ea73d991678d4e09a909f51971ecc51629bb2ab4/pyobjc_framework_quartz-12.2.1.tar.gz", hash = "sha256:b3b8b6f71e66147f8ff9e6213864cc8527e3a0b1ee90835b93ce221f4802d9b0", size = 3215521, upload-time = "2026-06-19T16:21:30.199Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ef/dcd22b743e38b3c430fce4788176c2c5afa8bfb01085b8143b02d1e75201/pyobjc_framework_quartz-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:19f99ac49a0b15dd892e155644fe80242d741411a9ed9c119b18b7466048625a", size = 217795, upload-time = "2025-11-14T09:59:46.922Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9b/780f057e5962f690f23fdff1083a4cfda5a96d5b4d3bb49505cac4f624f2/pyobjc_framework_quartz-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7730cdce46c7e985535b5a42c31381af4aa6556e5642dc55b5e6597595e57a16", size = 218798, upload-time = "2025-11-14T10:00:01.236Z" }, - { url = "https://files.pythonhosted.org/packages/ba/2d/e8f495328101898c16c32ac10e7b14b08ff2c443a756a76fd1271915f097/pyobjc_framework_quartz-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:629b7971b1b43a11617f1460cd218bd308dfea247cd4ee3842eb40ca6f588860", size = 219206, upload-time = "2025-11-14T10:00:15.623Z" }, - { url = "https://files.pythonhosted.org/packages/67/43/b1f0ad3b842ab150a7e6b7d97f6257eab6af241b4c7d14cb8e7fde9214b8/pyobjc_framework_quartz-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:53b84e880c358ba1ddcd7e8d5ea0407d760eca58b96f0d344829162cda5f37b3", size = 224317, upload-time = "2025-11-14T10:00:30.703Z" }, - { url = "https://files.pythonhosted.org/packages/4a/00/96249c5c7e5aaca5f688ca18b8d8ad05cd7886ebd639b3c71a6a4cadbe75/pyobjc_framework_quartz-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:42d306b07f05ae7d155984503e0fb1b701fecd31dcc5c79fe8ab9790ff7e0de0", size = 219558, upload-time = "2025-11-14T10:00:45.476Z" }, - { url = "https://files.pythonhosted.org/packages/4d/a6/708a55f3ff7a18c403b30a29a11dccfed0410485a7548c60a4b6d4cc0676/pyobjc_framework_quartz-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0cc08fddb339b2760df60dea1057453557588908e42bdc62184b6396ce2d6e9a", size = 224580, upload-time = "2025-11-14T10:01:00.091Z" }, + { url = "https://files.pythonhosted.org/packages/b9/08/527d1ff856e2f2446b5887be01989cc08f9adaf3de7d4eb13d07826c362f/pyobjc_framework_quartz-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60f29408b4f9ed5391a29c6b63e2aa56ddfb8b66b3fb47962930427981e14462", size = 217998, upload-time = "2026-06-19T16:16:02.978Z" }, + { url = "https://files.pythonhosted.org/packages/14/fc/d7c7b3134cdbd1a487f3f77b5be125d87a6c9e7d9411035739d99335cc0c/pyobjc_framework_quartz-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:de9c8cca7e95290c8d540466af11c7cdfe3a5458e6f56c34006d5b45243f9ed9", size = 219000, upload-time = "2026-06-19T16:16:04.29Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4b/861f91a1565d3189ee899e177b915551fb9a7e2ca25414025a8974f04e74/pyobjc_framework_quartz-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:54c9bc7f507192691841ee4eba5bf36990b259df83ac728efed2d7ea1cd021e4", size = 219403, upload-time = "2026-06-19T16:16:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b5/b27010d2f288737f627f74be6d5549f49c841542365c84b9a3011fe39ce7/pyobjc_framework_quartz-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:bfc0d2badd819823d21df8069dcf9544ce360ed747a8895c51bdb25d8d125f45", size = 224458, upload-time = "2026-06-19T16:16:07.252Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5d/85ffd9d433989205d572a50d625c63b29c05e0c5235a725f15ae1023672c/pyobjc_framework_quartz-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ceb56939c337b36d9d81185ade31f77dc52c85cf79bb16e53e9b32f54b6bb3f5", size = 219769, upload-time = "2026-06-19T16:16:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/e2/d6/b917e4b63d72ea84a27121076f3033f23f6497c0e6ce8d304766c899897f/pyobjc_framework_quartz-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8105c98b798f2bf81c05c54bddeeadbf62f0b5dfec13bd6e719dd2cdf7e1cddf", size = 224717, upload-time = "2026-06-19T16:16:10.215Z" }, + { url = "https://files.pythonhosted.org/packages/04/e2/f3c1ed3228f7430ef5ade23db6f1fcbae99290f177ce5653348fd9e05f4d/pyobjc_framework_quartz-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:bbc214f1a216b5d3651bc832d0ac4589f029f3f37cd6cbb370aac12a7c77942c", size = 219825, upload-time = "2026-06-19T16:16:11.433Z" }, + { url = "https://files.pythonhosted.org/packages/66/2a/2c99a5ad2fe0a11600ea123b8e9a08ff138fcb2ad1e13e376f4bd4aa1d96/pyobjc_framework_quartz-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:ca61624a0b0e6286d8a0f97f47eb9011e4e81e9a339db436d48af527e7065bb1", size = 224770, upload-time = "2026-06-19T16:16:13.035Z" }, ] [[package]] -name = "pyrect" -version = "0.2.0" +name = "pyparsing" +version = "3.3.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cb/04/2ba023d5f771b645f7be0c281cdacdcd939fe13d1deb331fc5ed1a6b3a98/PyRect-0.2.0.tar.gz", hash = "sha256:f65155f6df9b929b67caffbd57c0947c5ae5449d3b580d178074bffb47a09b78", size = 17219, upload-time = "2022-03-16T04:45:52.36Z" } - -[[package]] -name = "pyscreeze" -version = "0.1.19" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pillow" }, +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f3/27/073bf07400943e38b06ba40def60ec489d114fd7356c2db5a2f793454312/PyScreeze-0.1.19.tar.gz", hash = "sha256:9dfa4ff43d39538f12d4aa2ace446ede87f91a64b486cc819e5f72ba87aaa994", size = 21048, upload-time = "2019-01-05T18:56:07.015Z" } [[package]] name = "pytest" @@ -1984,83 +2149,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/75/24ad6ed3832e4616ea9d97fe9644d5efb98c9014f25cd6c83e8dc10ef574/python_semantic_release-9.21.0-py3-none-any.whl", hash = "sha256:1ecf9753283835f1c6cda4702e419d9702863a51b03fa11955429139234f063c", size = 132564, upload-time = "2025-02-23T20:45:59.939Z" }, ] -[[package]] -name = "python-xlib" -version = "0.33" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/86/f5/8c0653e5bb54e0cbdfe27bf32d41f27bc4e12faa8742778c17f2a71be2c0/python-xlib-0.33.tar.gz", hash = "sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32", size = 269068, upload-time = "2022-12-25T18:53:00.824Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/b8/ff33610932e0ee81ae7f1269c890f697d56ff74b9f5b2ee5d9b7fa2c5355/python_xlib-0.33-py2.py3-none-any.whl", hash = "sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398", size = 182185, upload-time = "2022-12-25T18:52:58.662Z" }, -] - -[[package]] -name = "pytweening" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/79/0c/c16bc93ac2755bac0066a8ecbd2a2931a1735a6fffd99a2b9681c7e83e90/pytweening-1.2.0.tar.gz", hash = "sha256:243318b7736698066c5f362ec5c2b6434ecf4297c3c8e7caa8abfe6af4cac71b", size = 171241, upload-time = "2024-02-20T03:37:56.809Z" } - -[[package]] -name = "pywavelets" -version = "1.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5a/75/50581633d199812205ea8cdd0f6d52f12a624886b74bf1486335b67f01ff/pywavelets-1.9.0.tar.gz", hash = "sha256:148d12203377772bea452a59211d98649c8ee4a05eff019a9021853a36babdc8", size = 3938340, upload-time = "2025-08-04T16:20:04.978Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/8b/ca700d0c174c3a4eec1fbb603f04374d1fed84255c2a9f487cfaa749c865/pywavelets-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54662cce4d56f0d6beaa6ebd34b2960f3aa4a43c83c9098a24729e9dc20a4be2", size = 4323640, upload-time = "2025-08-04T16:18:51.683Z" }, - { url = "https://files.pythonhosted.org/packages/b5/f3/0fa57b6407ea9c4452b0bc182141256b9481b479ffbfc9d7fdb73afe193b/pywavelets-1.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0d8ed4b4d1eab9347e8fe0c5b45008ce5a67225ce5b05766b8b1fa923a5f8b34", size = 4294938, upload-time = "2025-08-04T16:18:53.818Z" }, - { url = "https://files.pythonhosted.org/packages/ea/95/a998313c8459a57e488ff2b18e24be9e836aedda3aa3a1673197deeaa59a/pywavelets-1.9.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:862be65481fdfecfd84c6b0ca132ba571c12697a082068921bca5b5e039f1371", size = 4472829, upload-time = "2025-08-04T16:18:55.508Z" }, - { url = "https://files.pythonhosted.org/packages/d8/8c/f316a153f7f89d2753df8a7371d15d0faab87e709fe02715dbc297c79385/pywavelets-1.9.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d76b7fa8fc500b09201d689b4f15bf5887e30ffbe2e1f338eb8470590eb4521a", size = 4524936, upload-time = "2025-08-04T16:18:57.146Z" }, - { url = "https://files.pythonhosted.org/packages/24/f7/89fdc1caef4b384a341a8e149253e23f36c1702bbb986a26123348624854/pywavelets-1.9.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:aa859d0b686a697c87a47e29319aebe44125f114a4f8c7e444832b921f52de5a", size = 4481475, upload-time = "2025-08-04T16:18:58.725Z" }, - { url = "https://files.pythonhosted.org/packages/82/53/b733fbfb71853e4a5c430da56e325a763562d65241dd785f0fadb67aed6a/pywavelets-1.9.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20e97b84a263003e2c7348bcf72beba96edda1a6169f072dc4e4d4ee3a6c7368", size = 4527994, upload-time = "2025-08-04T16:18:59.917Z" }, - { url = "https://files.pythonhosted.org/packages/ed/15/5f6a6e9fdad8341e42642ed622a5f3033da4ea9d426cc3e574ae418b4726/pywavelets-1.9.0-cp311-cp311-win32.whl", hash = "sha256:f8330cdbfa506000e63e79525716df888998a76414c5cd6ecd9a7e371191fb05", size = 4136109, upload-time = "2025-08-04T16:19:01.511Z" }, - { url = "https://files.pythonhosted.org/packages/fd/33/62dbb4aea86ec9d79b283127c42cc896f4d4ff265a9aeb1337a7836dd550/pywavelets-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:ed10959a17df294ef55948dcc76367d59ec7b6aad67e38dd4e313d2fe3ad47b2", size = 4228321, upload-time = "2025-08-04T16:19:03.164Z" }, - { url = "https://files.pythonhosted.org/packages/5c/37/3fda13fb2518fdd306528382d6b18c116ceafefff0a7dccd28f1034f4dd2/pywavelets-1.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30baa0788317d3c938560c83fe4fc43817342d06e6c9662a440f73ba3fb25c9b", size = 4320835, upload-time = "2025-08-04T16:19:04.855Z" }, - { url = "https://files.pythonhosted.org/packages/36/65/a5549325daafc3eae4b52de076798839eaf529a07218f8fb18cccefe76a1/pywavelets-1.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:df7436a728339696a7aa955c020ae65c85b0d9d2b5ff5b4cf4551f5d4c50f2c7", size = 4290469, upload-time = "2025-08-04T16:19:06.178Z" }, - { url = "https://files.pythonhosted.org/packages/05/85/901bb756d37dfa56baa26ef4a3577aecfe9c55f50f51366fede322f8c91d/pywavelets-1.9.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:07b26526db2476974581274c43a9c2447c917418c6bd03c8d305ad2a5cd9fac3", size = 4437717, upload-time = "2025-08-04T16:19:07.514Z" }, - { url = "https://files.pythonhosted.org/packages/0f/34/0f54dd9c288941294898877008bcb5c07012340cc9c5db9cff1bd185d449/pywavelets-1.9.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:573b650805d2f3c981a0e5ae95191c781a722022c37a0f6eba3fa7eae8e0ee17", size = 4483843, upload-time = "2025-08-04T16:19:08.857Z" }, - { url = "https://files.pythonhosted.org/packages/48/1f/cff6bb4ea64ff508d8cac3fe113c0aa95310a7446d9efa6829027cc2afdf/pywavelets-1.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3747ec804492436de6e99a7b6130480e53406d047e87dc7095ab40078a515a23", size = 4442236, upload-time = "2025-08-04T16:19:11.061Z" }, - { url = "https://files.pythonhosted.org/packages/ce/53/a3846eeefe0fb7ca63ae045f038457aa274989a15af793c1b824138caf98/pywavelets-1.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5163665686219c3f43fd5bbfef2391e87146813961dad0f86c62d4aed561f547", size = 4488077, upload-time = "2025-08-04T16:19:12.333Z" }, - { url = "https://files.pythonhosted.org/packages/f7/98/44852d2fe94455b72dece2db23562145179d63186a1c971125279a1c381f/pywavelets-1.9.0-cp312-cp312-win32.whl", hash = "sha256:80b8ab99f5326a3e724f71f23ba8b0a5b03e333fa79f66e965ea7bed21d42a2f", size = 4134094, upload-time = "2025-08-04T16:19:13.564Z" }, - { url = "https://files.pythonhosted.org/packages/2c/a7/0d9ee3fe454d606e0f5c8e3aebf99d2ecddbfb681826a29397729538c8f1/pywavelets-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:92bfb8a117b8c8d3b72f2757a85395346fcbf37f50598880879ae72bd8e1c4b9", size = 4213900, upload-time = "2025-08-04T16:19:14.939Z" }, - { url = "https://files.pythonhosted.org/packages/db/a7/dec4e450675d62946ad975f5b4d924437df42d2fae46e91dfddda2de0f5a/pywavelets-1.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:74f8455c143818e4b026fc67b27fd82f38e522701b94b8a6d1aaf3a45fcc1a25", size = 4316201, upload-time = "2025-08-04T16:19:16.259Z" }, - { url = "https://files.pythonhosted.org/packages/aa/0c/b54b86596c0df68027e48c09210e907e628435003e77048384a2dd6767e3/pywavelets-1.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c50320fe0a4a23ddd8835b3dc9b53b09ee05c7cc6c56b81d0916f04fc1649070", size = 4286838, upload-time = "2025-08-04T16:19:17.92Z" }, - { url = "https://files.pythonhosted.org/packages/5a/9c/333969c3baad8af2e7999e83addcb7bb1d1fd48e2d812fb27e2e89582cb1/pywavelets-1.9.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6e059265223ed659e5214ab52a84883c88ddf3decbf08d7ec6abb8e4c5ed7be", size = 4430753, upload-time = "2025-08-04T16:19:19.529Z" }, - { url = "https://files.pythonhosted.org/packages/e5/1b/a24c6ff03b026b826ad7b9267bd63cd34ce026795a0302f8a5403840b8e7/pywavelets-1.9.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ae10ed46c139c7ddb8b1249cfe0989f8ccb610d93f2899507b1b1573a0e424b5", size = 4491315, upload-time = "2025-08-04T16:19:20.717Z" }, - { url = "https://files.pythonhosted.org/packages/d7/c7/e3fbb502fca3469e51ced4f1e1326364c338be91edc5db5a8ddd26b303fa/pywavelets-1.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c8f8b1cc2df012401cb837ee6fa2f59607c7b4fe0ff409d9a4f6906daf40dc86", size = 4437654, upload-time = "2025-08-04T16:19:22.359Z" }, - { url = "https://files.pythonhosted.org/packages/92/44/c9b25084048d9324881a19b88e0969a4141bcfdc1d218f1b4b680b7af1c1/pywavelets-1.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:db43969c7a8fbb17693ecfd14f21616edc3b29f0e47a49b32fa4127c01312a67", size = 4496435, upload-time = "2025-08-04T16:19:23.842Z" }, - { url = "https://files.pythonhosted.org/packages/cd/b6/b27ec18c72b1dee3314e297af39c5f8136d43cc130dd93cb6c178ca820e5/pywavelets-1.9.0-cp313-cp313-win32.whl", hash = "sha256:9e7d60819d87dcd6c68a2d1bc1d37deb1f4d96607799ab6a25633ea484dcda41", size = 4132709, upload-time = "2025-08-04T16:19:25.415Z" }, - { url = "https://files.pythonhosted.org/packages/0a/87/78ef3f9fb36cdb16ee82371d22c3a7c89eeb79ec8c9daef6222060da6c79/pywavelets-1.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:0d70da9d7858c869e24dc254f16a61dc09d8a224cad85a10c393b2eccddeb126", size = 4213377, upload-time = "2025-08-04T16:19:26.875Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cd/ca0d9db0ff29e3843f6af60c2f5eb588794e05ca8eeb872a595867b1f3f5/pywavelets-1.9.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4dc85f44c38d76a184a1aa2cb038f802c3740428c9bb877525f4be83a223b134", size = 4354336, upload-time = "2025-08-04T16:19:28.745Z" }, - { url = "https://files.pythonhosted.org/packages/82/d6/70afefcc1139f37d02018a3b1dba3b8fc87601bb7707d9616b7f7a76e269/pywavelets-1.9.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7acf6f950c6deaecd210fbff44421f234a8ca81eb6f4da945228e498361afa9d", size = 4335721, upload-time = "2025-08-04T16:19:30.371Z" }, - { url = "https://files.pythonhosted.org/packages/cd/3a/713f731b9ed6df0c36269c8fb62be8bb28eb343b9e26b13d6abda37bce38/pywavelets-1.9.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:144d4fc15c98da56654d0dca2d391b812b8d04127b194a37ad4a497f8e887141", size = 4418702, upload-time = "2025-08-04T16:19:31.743Z" }, - { url = "https://files.pythonhosted.org/packages/44/e8/f801eb4b5f7a316ba20054948c5d6b27b879c77fab2674942e779974bd86/pywavelets-1.9.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1aa3729585408a979d655736f74b995b511c86b9be1544f95d4a3142f8f4b8b5", size = 4470023, upload-time = "2025-08-04T16:19:32.963Z" }, - { url = "https://files.pythonhosted.org/packages/e9/cc/44b002cb16f2a392f2082308dd470b3f033fa4925d3efa7c46f790ce895a/pywavelets-1.9.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e0e24ad6b8eb399c49606dd1fcdcbf9749ad7f6d638be3fe6f59c1f3098821e2", size = 4426498, upload-time = "2025-08-04T16:19:34.151Z" }, - { url = "https://files.pythonhosted.org/packages/91/fe/2b70276ede7878c5fe8356ca07574db5da63e222ce39a463e84bfad135e8/pywavelets-1.9.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3830e6657236b53a3aae20c735cccead942bb97c54bbca9e7d07bae01645fe9c", size = 4477528, upload-time = "2025-08-04T16:19:35.932Z" }, - { url = "https://files.pythonhosted.org/packages/e7/ed/d58b540c15e36508cfeded7b0d39493e811b0dce18d9d4e6787fb2e89685/pywavelets-1.9.0-cp313-cp313t-win32.whl", hash = "sha256:81bb65facfbd7b50dec50450516e72cdc51376ecfdd46f2e945bb89d39bfb783", size = 4186493, upload-time = "2025-08-04T16:19:37.198Z" }, - { url = "https://files.pythonhosted.org/packages/84/b2/12a849650d618a86bbe4d8876c7e20a7afe59a8cad6f49c57eca9af26dfa/pywavelets-1.9.0-cp313-cp313t-win_amd64.whl", hash = "sha256:47d52cf35e2afded8cfe1133663f6f67106a3220b77645476ae660ad34922cb4", size = 4274821, upload-time = "2025-08-04T16:19:38.436Z" }, - { url = "https://files.pythonhosted.org/packages/ba/1f/18c82122547c9eec2232d800b02ada1fbd30ce2136137b5738acca9d653e/pywavelets-1.9.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:53043d2f3f4e55a576f51ac594fe33181e1d096d958e01524db5070eb3825306", size = 4314440, upload-time = "2025-08-04T16:19:39.701Z" }, - { url = "https://files.pythonhosted.org/packages/eb/e1/1c92ac6b538ef5388caf1a74af61cf6af16ea6d14115bb53357469cb38d6/pywavelets-1.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:56bc36b42b1b125fd9cb56e7956b22f8d0f83c1093f49c77fc042135e588c799", size = 4290162, upload-time = "2025-08-04T16:19:41.322Z" }, - { url = "https://files.pythonhosted.org/packages/96/d3/d856a2cac8069c20144598fa30a43ca40b5df2e633230848a9a942faf04a/pywavelets-1.9.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08076eb9a182ddc6054ac86868fb71df6267c341635036dc63d20bdbacd9ad7e", size = 4437162, upload-time = "2025-08-04T16:19:42.556Z" }, - { url = "https://files.pythonhosted.org/packages/c9/54/777e0495acd4fb008791e84889be33d6e7fc8af095b441d939390b7d2491/pywavelets-1.9.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ee1ee7d80f88c64b8ec3b5021dd1e94545cc97f0cd479fb51aa7b10f6def08e", size = 4498169, upload-time = "2025-08-04T16:19:43.791Z" }, - { url = "https://files.pythonhosted.org/packages/76/68/81b97f4d18491a18fbe17e06e2eee80a591ce445942f7b6f522de07813c5/pywavelets-1.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3226b6f62838a6ccd7782cb7449ee5d8b9d61999506c1d9b03b2baf41b01b6fd", size = 4443318, upload-time = "2025-08-04T16:19:45.368Z" }, - { url = "https://files.pythonhosted.org/packages/92/74/5147f2f0436f7aa131cb1bc13dba32ef5f3862748ae1c7366b4cde380362/pywavelets-1.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fb7f4b11d18e2db6dd8deee7b3ce8343d45f195f3f278c2af6e3724b1b93a24", size = 4503294, upload-time = "2025-08-04T16:19:46.632Z" }, - { url = "https://files.pythonhosted.org/packages/3d/d4/af998cc71e869919e0ab45471bd43e91d055ac7bc3ce6f56cc792c9b6bc8/pywavelets-1.9.0-cp314-cp314-win32.whl", hash = "sha256:9902d9fc9812588ab2dce359a1307d8e7f002b53a835640e2c9388fe62a82fd4", size = 4144478, upload-time = "2025-08-04T16:19:47.974Z" }, - { url = "https://files.pythonhosted.org/packages/7d/66/1d071eae5cc3e3ad0e45334462f8ce526a79767ccb759eb851aa5b78a73a/pywavelets-1.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:7e57792bde40e331d6cc65458e5970fd814dba18cfc4e9add9d051e901a7b7c7", size = 4227186, upload-time = "2025-08-04T16:19:49.57Z" }, - { url = "https://files.pythonhosted.org/packages/bf/1f/da0c03ac99bd9d20409c0acf6417806d4cf333d70621da9f535dd0cf27fa/pywavelets-1.9.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b47c72fb4b76d665c4c598a5b621b505944e5b761bf03df9d169029aafcb652f", size = 4354391, upload-time = "2025-08-04T16:19:51.221Z" }, - { url = "https://files.pythonhosted.org/packages/95/b6/de9e225d8cc307fbb4fda88aefa79442775d5e27c58ee4d3c8a8580ceba6/pywavelets-1.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:969e369899e7eab546ea5d77074e4125082e6f9dad71966499bf5dee3758be55", size = 4335810, upload-time = "2025-08-04T16:19:52.813Z" }, - { url = "https://files.pythonhosted.org/packages/33/3b/336761359d07cd44a4233ca854704ff2a9e78d285879ccc82d254b9daa57/pywavelets-1.9.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8aeffd4f35036c1fade972a61454de5709a7a8fc9a7d177eefe3ac34d76962e5", size = 4422220, upload-time = "2025-08-04T16:19:54.068Z" }, - { url = "https://files.pythonhosted.org/packages/98/61/76ccc7ada127f14f65eda40e37407b344fd3713acfca7a94d7f0f67fe57d/pywavelets-1.9.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f63f400fcd4e7007529bd06a5886009760da35cd7e76bb6adb5a5fbee4ffeb8c", size = 4470156, upload-time = "2025-08-04T16:19:55.379Z" }, - { url = "https://files.pythonhosted.org/packages/e0/de/142ca27ee729cf64113c2560748fcf2bd45b899ff282d6f6f3c0e7f177bb/pywavelets-1.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a63bcb6b5759a7eb187aeb5e8cd316b7adab7de1f4b5a0446c9a6bcebdfc22fb", size = 4430167, upload-time = "2025-08-04T16:19:56.566Z" }, - { url = "https://files.pythonhosted.org/packages/ca/5e/90b39adff710d698c00ba9c3125e2bec99dad7c5f1a3ba37c73a78a6689f/pywavelets-1.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9950eb7c8b942e9bfa53d87c7e45a420dcddbd835c4c5f1aca045a3f775c6113", size = 4477378, upload-time = "2025-08-04T16:19:58.162Z" }, - { url = "https://files.pythonhosted.org/packages/f1/1a/89f5f4ebcb9d34d9b7b2ac0a868c8b6d8c78d699a36f54407a060cea0566/pywavelets-1.9.0-cp314-cp314t-win32.whl", hash = "sha256:097f157e07858a1eb370e0d9c1bd11185acdece5cca10756d6c3c7b35b52771a", size = 4209132, upload-time = "2025-08-04T16:20:00.371Z" }, - { url = "https://files.pythonhosted.org/packages/68/d2/a8065103f5e2e613b916489e6c85af6402a1ec64f346d1429e2d32cb8d03/pywavelets-1.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3b6ff6ba4f625d8c955f68c2c39b0a913776d406ab31ee4057f34ad4019fb33b", size = 4306793, upload-time = "2025-08-04T16:20:02.934Z" }, -] - [[package]] name = "pywin32" version = "312" @@ -2244,77 +2332,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" }, ] -[[package]] -name = "scipy" -version = "1.17.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, - { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, - { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, - { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, - { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, - { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, - { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, - { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, - { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, - { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, - { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, - { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, - { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, - { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, - { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, - { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, - { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, - { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, - { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, - { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, - { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, - { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, - { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, - { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, - { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, - { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, - { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, - { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, - { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, - { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, - { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, - { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, - { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, - { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, - { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, - { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, - { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, - { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, - { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, - { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, - { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, - { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, - { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, - { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, - { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, - { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, - { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, - { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, - { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, - { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, - { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, - { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, - { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, - { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, -] - [[package]] name = "secretstorage" version = "3.5.0" From d5dfb73f949ced853d2730bca55fb9b3d35a8dcc Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 23 Jul 2026 18:34:35 -0700 Subject: [PATCH 11/15] test: make frozen notice checks hermetic --- tests/test_frozen_notices.py | 58 ++++++++++++++++++++++++++++-------- 1 file changed, 45 insertions(+), 13 deletions(-) diff --git a/tests/test_frozen_notices.py b/tests/test_frozen_notices.py index 4d10725..f8d7463 100644 --- a/tests/test_frozen_notices.py +++ b/tests/test_frozen_notices.py @@ -19,7 +19,9 @@ def __init__( *, version: str = "1.0.0", requires: list[str] | None = None, - license_expression: str = "MIT", + license_expression: str | None = "MIT", + license: str | None = None, + classifiers: list[str] | None = None, notice_files: dict[str, str] | None = None, ) -> None: self.root = root @@ -27,7 +29,12 @@ def __init__( self.requires = requires or [] self.metadata = Message() self.metadata["Name"] = name - self.metadata["License-Expression"] = license_expression + if license_expression is not None: + self.metadata["License-Expression"] = license_expression + if license is not None: + self.metadata["License"] = license + for classifier in classifiers or []: + self.metadata["Classifier"] = classifier self.files = [] for member, text in (notice_files or {}).items(): path = root / member @@ -335,8 +342,29 @@ def test_reviewed_dual_license_rejects_any_metadata_drift() -> None: ) -def test_flatbuffers_uses_exact_reviewed_upstream_notice() -> None: - flatbuffers = distribution("flatbuffers") +def _flatbuffers_distribution(tmp_path: Path) -> FakeDistribution: + return FakeDistribution( + tmp_path / "flatbuffers", + "flatbuffers", + version="25.12.19", + license_expression=None, + license="Apache 2.0", + classifiers=["License :: OSI Approved :: Apache Software License"], + ) + + +def _rapidocr_distribution(tmp_path: Path) -> FakeDistribution: + return FakeDistribution( + tmp_path / "rapidocr", + "rapidocr-onnxruntime", + version="1.4.4", + license_expression=None, + license="Apache-2.0", + ) + + +def test_flatbuffers_uses_exact_reviewed_upstream_notice(tmp_path: Path) -> None: + flatbuffers = _flatbuffers_distribution(tmp_path) sources = notices._reviewed_external_notice_sources( # noqa: SLF001 "flatbuffers", @@ -345,16 +373,19 @@ def test_flatbuffers_uses_exact_reviewed_upstream_notice() -> None: assert len(sources) == 1 source_member, source = sources[0] - assert notices.REVIEWED_EXTERNAL_NOTICE_FILES[ - ("flatbuffers", str(flatbuffers.version)) - ]["source_commit"] in source_member + assert ( + notices.REVIEWED_EXTERNAL_NOTICE_FILES[("flatbuffers", str(flatbuffers.version))][ + "source_commit" + ] + in source_member + ) assert hashlib.sha256(source.read_bytes()).hexdigest() == ( "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30" ) def test_reviewed_external_notice_rejects_byte_drift(tmp_path: Path) -> None: - flatbuffers = distribution("flatbuffers") + flatbuffers = _flatbuffers_distribution(tmp_path) notice = tmp_path / "notices" / "flatbuffers" / "LICENSE" notice.parent.mkdir(parents=True) notice.write_text("changed terms\n") @@ -395,16 +426,17 @@ def test_loguru_uses_exact_reviewed_upstream_notice() -> None: assert len(sources) == 1 source_member, source = sources[0] - assert notices.REVIEWED_EXTERNAL_NOTICE_FILES[("loguru", str(loguru.version))][ - "source_commit" - ] in source_member + assert ( + notices.REVIEWED_EXTERNAL_NOTICE_FILES[("loguru", str(loguru.version))]["source_commit"] + in source_member + ) assert hashlib.sha256(source.read_bytes()).hexdigest() == ( "b35d026cc7aca9d5859a02eb87ddf7a386a24c986838651bd1f283f94e003327" ) -def test_rapidocr_uses_exact_reviewed_upstream_notice() -> None: - rapidocr = distribution("rapidocr-onnxruntime") +def test_rapidocr_uses_exact_reviewed_upstream_notice(tmp_path: Path) -> None: + rapidocr = _rapidocr_distribution(tmp_path) sources = notices._reviewed_external_notice_sources( # noqa: SLF001 "rapidocr-onnxruntime", From 11b218668e91630337b26fee647180066992e1fc Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 23 Jul 2026 18:52:54 -0700 Subject: [PATCH 12/15] fix: secure managed runtime boundaries --- .gitattributes | 5 + engine/controller.py | 5 + engine/managed_vision.py | 178 ++++++++++++++++++++++----- engine/vision-runtime-manifest.json | 52 +++++++- scripts/build_frozen_engine.py | 8 +- scripts/frozen_notices.py | 22 +++- scripts/smoke_test_frozen_flow.py | 5 + scripts/verify_build_artifact.py | 2 +- tests/test_build_frozen_engine.py | 5 +- tests/test_engine/test_controller.py | 26 ++++ tests/test_frozen_notices.py | 69 ++++++++++- tests/test_managed_vision.py | 80 +++++++++++- 12 files changed, 410 insertions(+), 47 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..138c953 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +third_party/flatbuffers/LICENSE text eol=lf +third_party/loguru/LICENSE text eol=lf +third_party/pyobjc/LICENSE.txt text eol=lf +third_party/rapidocr/LICENSE text eol=lf +third_party/rapidocr/NOTICE text eol=lf diff --git a/engine/controller.py b/engine/controller.py index cd52a52..3d07433 100644 --- a/engine/controller.py +++ b/engine/controller.py @@ -25,6 +25,7 @@ import enum import json +import sys import uuid from datetime import datetime, timezone from pathlib import Path @@ -41,6 +42,10 @@ def _load_capture_recorder(): """Load the native recorder or fail before claiming a recording started.""" + if getattr(sys, "frozen", False): + from engine.managed_vision import ensure_managed_vision_runtime + + ensure_managed_vision_runtime() try: from openadapt_capture import Recorder except ImportError as exc: diff --git a/engine/managed_vision.py b/engine/managed_vision.py index de77b2d..7148a6e 100644 --- a/engine/managed_vision.py +++ b/engine/managed_vision.py @@ -2,8 +2,9 @@ OpenCV's upstream wheels include separately licensed codec libraries. They are therefore not copied into OpenAdapt's MIT wheel, frozen sidecar, or installer. -Desktop downloads the exact reviewed OpenCV and RapidOCR wheels only when a -Flow command first needs the vision stack, verifies their published hashes, +Desktop downloads the exact reviewed NumPy, OpenCV, and RapidOCR wheels only +when a Flow or native-recording command first needs the vision stack, verifies +their published hashes, extracts them into a versioned user-data cache, and loads them as an optional runtime component. Re-running the command retries a failed download; a partially prepared directory is never treated as ready. @@ -11,8 +12,11 @@ from __future__ import annotations +import base64 +import csv import hashlib import importlib +import io import json import os import platform @@ -37,6 +41,16 @@ ("LICENSE", "LICENSE"), ("NOTICE", "NOTICE"), ) +RAPIDOCR_NOTICE_CONTRACT = { + "rapidocr_onnxruntime-1.4.4.dist-info/licenses/LICENSE": ( + "3e0af25fdd06aa9586ae97adb00ea927ebe5a3805ac77d2d3a81ce5f55693333", + 11422, + ), + "rapidocr_onnxruntime-1.4.4.dist-info/licenses/NOTICE": ( + "c14087f8546efee022e49c5a1629f74f7fe5cf219c54ffa690f4dcc83dd10dfb", + 554, + ), +} SUPPORTED_TARGETS = frozenset( { "aarch64-apple-darwin", @@ -58,6 +72,8 @@ class Wheel: url: str sha256: str bytes: int + record_member: str + record_sha256: str license_expression: str @@ -109,6 +125,8 @@ def _wheel(record: object) -> Wheel: url=str(record["url"]), sha256=str(record["sha256"]), bytes=int(record["bytes"]), + record_member=str(record["record_member"]), + record_sha256=str(record["record_sha256"]), license_expression=str(record["license_expression"]), ) except (KeyError, TypeError, ValueError) as exc: @@ -132,12 +150,15 @@ def _wheel(record: object) -> Wheel: or not wheel.license_expression or len(wheel.sha256) != 64 or any(character not in "0123456789abcdef" for character in wheel.sha256) + or len(wheel.record_sha256) != 64 + or any(character not in "0123456789abcdef" for character in wheel.record_sha256) or wheel.bytes <= 0 or wheel.bytes > 150 * 1024 * 1024 ): raise ManagedVisionRuntimeError( f"vision runtime wheel metadata is invalid for {wheel.distribution or 'unknown'}" ) + _safe_member(wheel.record_member) return wheel @@ -189,9 +210,9 @@ def load_contract( ) wheels = tuple(_wheel(record) for record in [*shared, *selected["wheels"]]) names = [wheel.distribution for wheel in wheels] - if sorted(names) != ["opencv-python", "rapidocr-onnxruntime"]: + if sorted(names) != ["numpy", "opencv-python", "rapidocr-onnxruntime"]: raise ManagedVisionRuntimeError( - f"vision runtime must contain exact OpenCV and RapidOCR wheels, found {names}" + f"vision runtime must contain exact NumPy, OpenCV, and RapidOCR wheels, found {names}" ) return RuntimeContract( runtime_version=runtime_version, @@ -349,6 +370,11 @@ def _install_runtime_notices( destination.parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(source, destination) digest, size = _hash_file(destination) + expected = RAPIDOCR_NOTICE_CONTRACT[destination.relative_to(staging).as_posix()] + if (digest, size) != expected: + raise ManagedVisionRuntimeError( + f"reviewed RapidOCR {source_name} bytes require a new release review" + ) records.append( { "member": destination.relative_to(staging).as_posix(), @@ -372,6 +398,8 @@ def _marker(contract: RuntimeContract, files: list[dict[str, object]]) -> dict[s "url": wheel.url, "sha256": wheel.sha256, "bytes": wheel.bytes, + "record_member": wheel.record_member, + "record_sha256": wheel.record_sha256, "license_expression": wheel.license_expression, } for wheel in contract.wheels @@ -380,6 +408,94 @@ def _marker(contract: RuntimeContract, files: list[dict[str, object]]) -> dict[s } +def _record_inventory(path: Path, contract: RuntimeContract) -> dict[str, tuple[str, int]]: + """Derive the admissible cache from signed-manifest-bound wheel RECORDs.""" + + expected: dict[str, tuple[str, int]] = {} + for wheel in contract.wheels: + record_member = _safe_member(wheel.record_member) + record_path = path.joinpath(*record_member.parts) + if _is_link_like(record_path) or not record_path.is_file(): + raise ManagedVisionRuntimeError( + f"managed runtime omitted trusted RECORD for {wheel.distribution}" + ) + digest, record_size = _hash_file(record_path) + if digest != wheel.record_sha256: + raise ManagedVisionRuntimeError( + f"managed runtime RECORD drifted for {wheel.distribution}" + ) + try: + rows = csv.reader(io.StringIO(record_path.read_text(encoding="utf-8"))) + saw_record = False + for row in rows: + if len(row) != 3: + raise ManagedVisionRuntimeError( + f"malformed trusted RECORD for {wheel.distribution}" + ) + member = _safe_member(row[0]) + member_name = member.as_posix() + if member_name in expected: + raise ManagedVisionRuntimeError( + f"duplicate managed runtime member in trusted RECORDs: {member_name}" + ) + hash_field, size_field = row[1:] + if member_name == wheel.record_member: + if hash_field or size_field: + raise ManagedVisionRuntimeError( + f"trusted RECORD self-entry drifted for {wheel.distribution}" + ) + expected[member_name] = (wheel.record_sha256, record_size) + saw_record = True + continue + if not hash_field.startswith("sha256=") or not size_field.isdigit(): + raise ManagedVisionRuntimeError( + f"trusted RECORD omitted a file digest for {wheel.distribution}" + ) + encoded = hash_field.removeprefix("sha256=") + decoded = base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4)) + if len(decoded) != hashlib.sha256().digest_size: + raise ManagedVisionRuntimeError( + f"trusted RECORD has an invalid digest for {wheel.distribution}" + ) + expected[member_name] = (decoded.hex(), int(size_field)) + except (UnicodeDecodeError, csv.Error, ValueError) as exc: + raise ManagedVisionRuntimeError( + f"could not parse trusted RECORD for {wheel.distribution}" + ) from exc + if not saw_record: + raise ManagedVisionRuntimeError( + f"trusted RECORD omitted itself for {wheel.distribution}" + ) + for member, record in RAPIDOCR_NOTICE_CONTRACT.items(): + if member in expected: + raise ManagedVisionRuntimeError( + f"reviewed supplemental notice collides with wheel inventory: {member}" + ) + expected[member] = record + return expected + + +def _is_link_like(path: Path) -> bool: + """Reject symlinks and Windows junction/reparse directory links.""" + + if path.is_symlink(): + return True + is_junction = getattr(path, "is_junction", None) + return bool(is_junction is not None and is_junction()) + + +def _actual_cache_files(path: Path) -> set[str]: + actual: set[str] = set() + for candidate in path.rglob("*"): + if _is_link_like(candidate): + raise ManagedVisionRuntimeError( + f"managed runtime cache contains a symlink: {candidate}" + ) + if candidate.is_file(): + actual.add(candidate.relative_to(path).as_posix()) + return actual + + def _cache_is_valid(path: Path, contract: RuntimeContract) -> bool: try: marker = json.loads((path / MARKER_NAME).read_text(encoding="utf-8")) @@ -397,26 +513,20 @@ def _cache_is_valid(path: Path, contract: RuntimeContract) -> bool: ): if marker.get(key) != expected[key]: return False - files = marker.get("files") - if not isinstance(files, list) or not files: + if not isinstance(marker.get("files"), list): return False - for record in files: - if ( - not isinstance(record, dict) - or not isinstance(record.get("member"), str) - or not isinstance(record.get("sha256"), str) - or not isinstance(record.get("bytes"), int) - ): - return False - try: - member = _safe_member(record["member"]) - except ManagedVisionRuntimeError: - return False + try: + expected_files = _record_inventory(path, contract) + actual_files = _actual_cache_files(path) + except (ManagedVisionRuntimeError, OSError): + return False + if actual_files != {*expected_files, MARKER_NAME}: + return False + for member_name, (expected_digest, expected_size) in expected_files.items(): + member = _safe_member(member_name) candidate = path.joinpath(*member.parts) - if candidate.is_symlink() or not candidate.is_file(): - return False digest, size = _hash_file(candidate) - if size != record["bytes"] or digest != record["sha256"]: + if size != expected_size or digest != expected_digest: return False return True @@ -427,25 +537,35 @@ def _activate(path: Path, contract: RuntimeContract) -> None: sys.path.insert(0, resolved) importlib.invalidate_caches() + previous_dont_write_bytecode = sys.dont_write_bytecode + sys.dont_write_bytecode = True try: - import cv2 - import rapidocr_onnxruntime - except ImportError as exc: - raise ManagedVisionRuntimeError( - "managed vision runtime imports failed after verified extraction" - ) from exc + try: + import cv2 + import numpy + import rapidocr_onnxruntime + except ImportError as exc: + raise ManagedVisionRuntimeError( + "managed vision runtime imports failed after verified extraction" + ) from exc + finally: + sys.dont_write_bytecode = previous_dont_write_bytecode expected = {wheel.distribution: wheel.version for wheel in contract.wheels} # Keep names data-driven from the signed manifest. Besides avoiding a # second source of truth, this prevents PyInstaller's metadata scanner from # copying these externally provisioned distributions into the sidecar. versions = {name: distribution(name).version for name in expected} expected_cv2 = ".".join(expected["opencv-python"].split(".")[:3]) - if versions != expected or cv2.__version__ != expected_cv2: + if ( + versions != expected + or numpy.__version__ != expected["numpy"] + or cv2.__version__ != expected_cv2 + ): raise ManagedVisionRuntimeError( f"managed vision runtime version drift: expected {expected}, found {versions}" ) runtime_path = path.resolve() - for module in (cv2, rapidocr_onnxruntime): + for module in (numpy, cv2, rapidocr_onnxruntime): module_path = Path(module.__file__ or "").resolve() if runtime_path not in module_path.parents: raise ManagedVisionRuntimeError( diff --git a/engine/vision-runtime-manifest.json b/engine/vision-runtime-manifest.json index 0fdd5b7..6d16122 100644 --- a/engine/vision-runtime-manifest.json +++ b/engine/vision-runtime-manifest.json @@ -1,7 +1,7 @@ { "schema_version": 1, "runtime": "openadapt-managed-vision", - "runtime_version": "rapidocr-1.4.4-opencv-5.0.0.93-r2", + "runtime_version": "rapidocr-1.4.4-opencv-5.0.0.93-numpy-2.4.2-r3", "shared_wheels": [ { "distribution": "rapidocr-onnxruntime", @@ -9,6 +9,8 @@ "url": "https://files.pythonhosted.org/packages/ba/12/1e5497183bdbe782dbb91bad1d0d2297dba4d2831b2652657f7517bfc6df/rapidocr_onnxruntime-1.4.4-py3-none-any.whl", "sha256": "971d7d5f223a7a808662229df1ef69893809d8457d834e6373d3854bc1782cbf", "bytes": 14915192, + "record_member": "rapidocr_onnxruntime-1.4.4.dist-info/RECORD", + "record_sha256": "0983f442ad98581c9eeff2075feb496bae2052c35265e2894e329b96e12acfea", "license_expression": "Apache-2.0" } ], @@ -16,12 +18,24 @@ { "target": "aarch64-apple-darwin", "wheels": [ + { + "distribution": "numpy", + "version": "2.4.2", + "url": "https://files.pythonhosted.org/packages/1f/f8/55483431f2b2fd015ae6ed4fe62288823ce908437ed49db5a03d15151678/numpy-2.4.2-cp312-cp312-macosx_11_0_arm64.whl", + "sha256": "40397bda92382fcec844066efb11f13e1c9a3e2a8e8f318fb72ed8b6db9f60f1", + "bytes": 14693571, + "record_member": "numpy-2.4.2.dist-info/RECORD", + "record_sha256": "dbfd6fc416f6ed0ec855b954592ff2954f161a9748d96a48952bc51f7d4b8db5", + "license_expression": "BSD-3-Clause with bundled third-party notices" + }, { "distribution": "opencv-python", "version": "5.0.0.93", "url": "https://files.pythonhosted.org/packages/9c/75/76f6ade78f6102c61034f828e2a22616708df2c9504bc8d6af9dd8f73dc5/opencv_python-5.0.0.93-cp37-abi3-macosx_13_0_arm64.whl", "sha256": "198a75138241810206a17c829dbcc40a7cb1841cda538ca86cbbfc6c7d95f898", "bytes": 48322443, + "record_member": "opencv_python-5.0.0.93.dist-info/RECORD", + "record_sha256": "8c948e411e4ce7eee7c03a2bd38ad49326a176e564e0b2f5472cf64251110631", "license_expression": "Apache-2.0 with bundled third-party notices" } ] @@ -29,12 +43,24 @@ { "target": "x86_64-apple-darwin", "wheels": [ + { + "distribution": "numpy", + "version": "2.4.2", + "url": "https://files.pythonhosted.org/packages/51/6e/6f394c9c77668153e14d4da83bcc247beb5952f6ead7699a1a2992613bea/numpy-2.4.2-cp312-cp312-macosx_10_13_x86_64.whl", + "sha256": "21982668592194c609de53ba4933a7471880ccbaadcc52352694a59ecc860b3a", + "bytes": 16667963, + "record_member": "numpy-2.4.2.dist-info/RECORD", + "record_sha256": "99ab196ecd99b700dbe5d2c18eba4419626f95d6537dbdb48989b6e10414877b", + "license_expression": "BSD-3-Clause with bundled third-party notices" + }, { "distribution": "opencv-python", "version": "5.0.0.93", "url": "https://files.pythonhosted.org/packages/15/8c/bc1bda6aae69a32e9d84fc34153ba104cd25226861eb4aea33b2cea4860d/opencv_python-5.0.0.93-cp37-abi3-macosx_14_0_x86_64.whl", "sha256": "6bbc32f59e1b1a7db7b39c81f63d00625f041d333037fd8702f6da52cc39108b", "bytes": 34782755, + "record_member": "opencv_python-5.0.0.93.dist-info/RECORD", + "record_sha256": "3957fb0e9e40a18ba80813b937146a627896d3ce10b5c94bdc0d735f068e92e9", "license_expression": "Apache-2.0 with bundled third-party notices" } ] @@ -42,12 +68,24 @@ { "target": "x86_64-pc-windows-msvc", "wheels": [ + { + "distribution": "numpy", + "version": "2.4.2", + "url": "https://files.pythonhosted.org/packages/92/0f/7ceaaeaacb40567071e94dbf2c9480c0ae453d5bb4f52bea3892c39dc83c/numpy-2.4.2-cp312-cp312-win_amd64.whl", + "sha256": "209fae046e62d0ce6435fcfe3b1a10537e858249b3d9b05829e2a05218296a85", + "bytes": 12314210, + "record_member": "numpy-2.4.2.dist-info/RECORD", + "record_sha256": "98c2c3dded7f355e97a56d0ff1afda22b4fa8b7511027db25000abc95cf4a2f6", + "license_expression": "BSD-3-Clause with bundled third-party notices" + }, { "distribution": "opencv-python", "version": "5.0.0.93", "url": "https://files.pythonhosted.org/packages/21/f0/9fa6e85cb10c8eb36a0222d27e50fe381b86ce49a55446bf39f491727564/opencv_python-5.0.0.93-cp37-abi3-win_amd64.whl", "sha256": "f90ba04b8f73bc5c3814037699739f0156f597338a98f05956c684e7c3ca10d2", "bytes": 44000345, + "record_member": "opencv_python-5.0.0.93.dist-info/RECORD", + "record_sha256": "432e564ead56d831a8edec118434f9ce9682f596671edd4f23e2c7871e6e6fa6", "license_expression": "Apache-2.0 with bundled third-party notices" } ] @@ -55,12 +93,24 @@ { "target": "x86_64-unknown-linux-gnu", "wheels": [ + { + "distribution": "numpy", + "version": "2.4.2", + "url": "https://files.pythonhosted.org/packages/f5/c6/a18e59f3f0b8071cc85cbc8d80cd02d68aa9710170b2553a117203d46936/numpy-2.4.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", + "sha256": "9e35d3e0144137d9fdae62912e869136164534d64a169f86438bc9561b6ad49f", + "bytes": 16619782, + "record_member": "numpy-2.4.2.dist-info/RECORD", + "record_sha256": "559816af7f01b5dcc5d00002934857303050f50ec9e5d221f4644b4a4dfb8412", + "license_expression": "BSD-3-Clause with bundled third-party notices" + }, { "distribution": "opencv-python", "version": "5.0.0.93", "url": "https://files.pythonhosted.org/packages/95/54/eb47866b94f2b5b42dde17644b78055ef1ee05aae59962c7290e55270803/opencv_python-5.0.0.93-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", "sha256": "f8b6d0a212253dd26ad338c812f1f23ca118fdf05a9c8c6b9444f161aa8c5881", "bytes": 71064711, + "record_member": "opencv_python-5.0.0.93.dist-info/RECORD", + "record_sha256": "b18d60a59f982cfa0d9b71cb85f2d9dd4cadd72f77b849ad868a5cd514dc28bb", "license_expression": "Apache-2.0 with bundled third-party notices" } ] diff --git a/scripts/build_frozen_engine.py b/scripts/build_frozen_engine.py index 9f674f2..29d4a29 100644 --- a/scripts/build_frozen_engine.py +++ b/scripts/build_frozen_engine.py @@ -27,9 +27,10 @@ # Defense in depth. Static analysis of the product CLI should not pull these # modules in, and the artifact audit independently refuses them if it ever does. EXCLUDED_MODULES = ( - # OpenCV's upstream wheels and RapidOCR are exact-hash, first-use runtime + # NumPy, OpenCV, and RapidOCR are exact-hash, first-use runtime # components. They remain outside the MIT sidecar/installer and are loaded # from the verified user-data cache by engine.managed_vision. + "numpy", "cv2", "rapidocr_onnxruntime", # Build tooling is neither required at runtime nor permitted in the frozen @@ -106,11 +107,6 @@ def build_command( "onnxruntime", "--hidden-import", "shapely", - # OpenCV's separately provisioned loader imports this legacy NumPy - # compatibility alias; NumPy 2.x no longer exposes it to PyInstaller's - # static graph unless named explicitly. - "--hidden-import", - "numpy.core.multiarray", "--hidden-import", "pyclipper", "--hidden-import", diff --git a/scripts/frozen_notices.py b/scripts/frozen_notices.py index 06a717a..ac8e446 100644 --- a/scripts/frozen_notices.py +++ b/scripts/frozen_notices.py @@ -54,6 +54,7 @@ ) EXTERNALLY_PROVISIONED_DISTRIBUTIONS = frozenset( { + "numpy", "opencv-python", "opencv-python-headless", "rapidocr-onnxruntime", @@ -346,6 +347,19 @@ def _declared_name(dist) -> str: return _canonicalize_name(str(name)) +def _canonical_notice_bytes(source: Path) -> bytes: + """Return reviewed notice text with platform-independent LF endings.""" + + payload = source.read_bytes() + if b"\x00" in payload: + raise RuntimeError(f"notice candidate contains a NUL byte: {source}") + try: + text = payload.decode("utf-8") + except UnicodeDecodeError as exc: + raise RuntimeError(f"notice candidate is not UTF-8: {source}") from exc + return text.replace("\r\n", "\n").replace("\r", "\n").encode("utf-8") + + def _reviewed_external_notice_sources( canonical_name: str, dist, @@ -371,7 +385,7 @@ def _reviewed_external_notice_sources( raise RuntimeError( f"reviewed external notice is missing for {canonical_name}: {source}" ) - payload = source.read_bytes() + payload = _canonical_notice_bytes(source) digest = hashlib.sha256(payload).hexdigest() if digest != record["sha256"]: raise RuntimeError( @@ -506,7 +520,7 @@ def _stage_pyinstaller_bootloader_notice( f"found {[member for member, _ in candidates]}" ) source_member, source = candidates[0] - payload = source.read_bytes() + payload = _canonical_notice_bytes(source) digest = hashlib.sha256(payload).hexdigest() if digest != PYINSTALLER_NOTICE_SHA256: raise RuntimeError( @@ -590,8 +604,8 @@ def prepare_notice_bundle( safe_name = re.sub(r"[^A-Za-z0-9._-]+", "-", source.name).strip("-") destination = package_dir / f"{index:03d}-{safe_name or 'NOTICE'}" destination.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(source, destination) - payload = destination.read_bytes() + payload = _canonical_notice_bytes(source) + destination.write_bytes(payload) notices.append( { "source_member": source_member, diff --git a/scripts/smoke_test_frozen_flow.py b/scripts/smoke_test_frozen_flow.py index 3b92e8b..eacf886 100644 --- a/scripts/smoke_test_frozen_flow.py +++ b/scripts/smoke_test_frozen_flow.py @@ -50,6 +50,7 @@ def main() -> int: # first-use provision. No system Python or openadapt-flow command is # used by any lifecycle command below. env["PLAYWRIGHT_BROWSERS_PATH"] = str(root / "browser-runtime") + env["OPENADAPT_VISION_RUNTIME_ROOT"] = str(root / "vision-runtime") env.pop("OPENADAPT_FLOW_NO_AUTO_INSTALL", None) flow = [str(executable), "__openadapt_flow__"] @@ -57,6 +58,10 @@ def main() -> int: [*flow, "demo-record", "--out", str(root / "recording")], env=env, ) + if "local vision runtime is ready" not in first_output: + raise RuntimeError("clean-user run did not exercise first-use vision provision") + if not any((root / "vision-runtime").glob("*/*/.complete.json")): + raise RuntimeError("vision runtime was not persisted outside the one-file extraction") if "Downloading the Chromium browser" not in first_output: raise RuntimeError("clean-user run did not exercise first-use browser provision") if not any((root / "browser-runtime").glob("chromium*")): diff --git a/scripts/verify_build_artifact.py b/scripts/verify_build_artifact.py index bd01f57..a1a93ea 100644 --- a/scripts/verify_build_artifact.py +++ b/scripts/verify_build_artifact.py @@ -56,7 +56,7 @@ re.IGNORECASE, ) FORBIDDEN_EMBEDDED_VISION_MEMBERS = re.compile( - r"(?:^|[/.'\" _-])(?:cv2|opencv[_-]python(?:[_-]headless)?|" + r"(?:^|[/.'\" _-])(?:numpy|cv2|opencv[_-]python(?:[_-]headless)?|" r"rapidocr[_-]onnxruntime)(?:[/.'\" _-]|$)", re.IGNORECASE, ) diff --git a/tests/test_build_frozen_engine.py b/tests/test_build_frozen_engine.py index 0e25a2d..76682e7 100644 --- a/tests/test_build_frozen_engine.py +++ b/tests/test_build_frozen_engine.py @@ -66,8 +66,9 @@ def test_frozen_runtime_bundles_required_third_party_notices(tmp_path: Path) -> assert ["--hidden-import", "shapely"] == command[ command.index("shapely") - 1 : command.index("shapely") + 1 ] - assert ["--hidden-import", "numpy.core.multiarray"] == command[ - command.index("numpy.core.multiarray") - 1 : command.index("numpy.core.multiarray") + 1 + assert "numpy.core.multiarray" not in command + assert ["--exclude-module", "numpy"] == command[ + command.index("numpy") - 1 : command.index("numpy") + 1 ] diff --git a/tests/test_engine/test_controller.py b/tests/test_engine/test_controller.py index 47dcbf9..877d33f 100644 --- a/tests/test_engine/test_controller.py +++ b/tests/test_engine/test_controller.py @@ -3,12 +3,38 @@ from __future__ import annotations import json +import sys from pathlib import Path +from types import ModuleType import pytest +from engine import controller as controller_module from engine.controller import RecordingController, RecordingState +REAL_LOAD_CAPTURE_RECORDER = controller_module._load_capture_recorder # noqa: SLF001 + + +def test_frozen_capture_provisions_vision_before_recorder_import(monkeypatch) -> None: + calls: list[str] = [] + monkeypatch.setattr(controller_module.sys, "frozen", True, raising=False) + managed = ModuleType("engine.managed_vision") + capture = ModuleType("openadapt_capture") + + class Recorder: + pass + + def provision() -> None: + calls.append("provision") + + managed.ensure_managed_vision_runtime = provision # type: ignore[attr-defined] + capture.Recorder = Recorder # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "engine.managed_vision", managed) + monkeypatch.setitem(sys.modules, "openadapt_capture", capture) + + assert REAL_LOAD_CAPTURE_RECORDER() is Recorder + assert calls == ["provision"] + class TestRecordingController: """Tests for RecordingController lifecycle management.""" diff --git a/tests/test_frozen_notices.py b/tests/test_frozen_notices.py index f8d7463..6e2eb41 100644 --- a/tests/test_frozen_notices.py +++ b/tests/test_frozen_notices.py @@ -120,8 +120,9 @@ def test_frozen_runtime_closure_excludes_managed_vision_wheels( "openadapt-flow": FakeDistribution( tmp_path / "flow", "openadapt-flow", - requires=["opencv-python-headless", "rapidocr-onnxruntime"], + requires=["numpy", "opencv-python-headless", "rapidocr-onnxruntime"], ), + "numpy": FakeDistribution(tmp_path / "numpy", "numpy"), "opencv-python-headless": FakeDistribution( tmp_path / "opencv-headless", "opencv-python-headless", @@ -269,6 +270,40 @@ def test_bootloader_notice_rejects_unreviewed_bytes(tmp_path: Path) -> None: ) +def test_bootloader_notice_stages_canonical_lf_bytes( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + notice_text = "\n".join(notices.PYINSTALLER_EXCEPTION_MARKERS) + "\n" + pyinstaller = FakeDistribution( + tmp_path / "pyinstaller", + "pyinstaller", + version=notices.PYINSTALLER_VERSION, + license_expression="GPL-2.0-or-later WITH Bootloader-exception", + notice_files={ + ( + f"pyinstaller-{notices.PYINSTALLER_VERSION}.dist-info/licenses/COPYING.txt" + ): notice_text, + }, + ) + source = pyinstaller.locate_file(pyinstaller.files[0]) + source.write_bytes(notice_text.replace("\n", "\r\n").encode()) + monkeypatch.setattr( + notices, + "PYINSTALLER_NOTICE_SHA256", + hashlib.sha256(notice_text.encode()).hexdigest(), + ) + + record = notices._stage_pyinstaller_bootloader_notice( # noqa: SLF001 + tmp_path / "bundle", + pyinstaller_dist=pyinstaller, + ) + + staged = tmp_path / "bundle" / "build-components" / "pyinstaller" / "COPYING.txt" + assert staged.read_bytes() == notice_text.encode() + assert record["sha256"] == hashlib.sha256(notice_text.encode()).hexdigest() + + def test_bootloader_notice_rejects_unreviewed_version(tmp_path: Path) -> None: pyinstaller = FakeDistribution( tmp_path / "pyinstaller", @@ -384,6 +419,38 @@ def test_flatbuffers_uses_exact_reviewed_upstream_notice(tmp_path: Path) -> None ) +def test_reviewed_notice_hash_is_stable_across_windows_line_endings( + tmp_path: Path, +) -> None: + flatbuffers = _flatbuffers_distribution(tmp_path) + notice = tmp_path / "notices" / "flatbuffers" / "LICENSE" + notice.parent.mkdir(parents=True) + canonical = (notices.REVIEWED_NOTICE_ROOT / "flatbuffers" / "LICENSE").read_bytes() + notice.write_bytes(canonical.replace(b"\n", b"\r\n")) + + sources = notices._reviewed_external_notice_sources( # noqa: SLF001 + "flatbuffers", + flatbuffers, + notice_root=tmp_path / "notices", + ) + + assert sources == [ + ( + ( + "reviewed-upstream:" + + notices.REVIEWED_EXTERNAL_NOTICE_FILES[ + ("flatbuffers", str(flatbuffers.version)) + ]["source_url"] + + "#commit=" + + notices.REVIEWED_EXTERNAL_NOTICE_FILES[ + ("flatbuffers", str(flatbuffers.version)) + ]["source_commit"] + ), + notice, + ) + ] + + def test_reviewed_external_notice_rejects_byte_drift(tmp_path: Path) -> None: flatbuffers = _flatbuffers_distribution(tmp_path) notice = tmp_path / "notices" / "flatbuffers" / "LICENSE" diff --git a/tests/test_managed_vision.py b/tests/test_managed_vision.py index d84dd4d..9f39402 100644 --- a/tests/test_managed_vision.py +++ b/tests/test_managed_vision.py @@ -1,5 +1,6 @@ from __future__ import annotations +import base64 import hashlib import io import json @@ -13,10 +14,18 @@ def _wheel(tmp_path: Path, name: str, members: dict[str, bytes]) -> tuple[vision.Wheel, Path]: archive = tmp_path / f"{name}.whl" + record_member = f"{name.replace('-', '_')}-1.0.0.dist-info/RECORD" + record_lines = [] + for member, payload in members.items(): + digest = base64.urlsafe_b64encode(hashlib.sha256(payload).digest()).decode().rstrip("=") + record_lines.append(f"{member},sha256={digest},{len(payload)}") + record_lines.append(f"{record_member},,") + members = {**members, record_member: ("\n".join(record_lines) + "\n").encode()} with zipfile.ZipFile(archive, "w") as package: for member, payload in members.items(): package.writestr(member, payload) payload = archive.read_bytes() + record_payload = members[record_member] return ( vision.Wheel( distribution=name, @@ -24,6 +33,8 @@ def _wheel(tmp_path: Path, name: str, members: dict[str, bytes]) -> tuple[vision url=f"https://files.pythonhosted.org/packages/test/{archive.name}", sha256=hashlib.sha256(payload).hexdigest(), bytes=len(payload), + record_member=record_member, + record_sha256=hashlib.sha256(record_payload).hexdigest(), license_expression="MIT", ), archive, @@ -34,12 +45,16 @@ def test_embedded_manifest_covers_exact_supported_targets() -> None: payload = json.loads(vision.MANIFEST_PATH.read_text()) assert payload["schema_version"] == 1 - assert payload["runtime_version"] == "rapidocr-1.4.4-opencv-5.0.0.93-r2" + assert ( + payload["runtime_version"] + == "rapidocr-1.4.4-opencv-5.0.0.93-numpy-2.4.2-r3" + ) assert {artifact["target"] for artifact in payload["artifacts"]} == (vision.SUPPORTED_TARGETS) for target in vision.SUPPORTED_TARGETS: contract = vision.load_contract(target=target) assert [wheel.distribution for wheel in contract.wheels] == [ "rapidocr-onnxruntime", + "numpy", "opencv-python", ] assert all( @@ -89,8 +104,10 @@ def test_extract_wheels_hashes_members(tmp_path: Path) -> None: assert {record["member"] for record in files} == { "cv2/__init__.py", "opencv_python-1.0.0.dist-info/LICENSE.txt", + "opencv_python-1.0.0.dist-info/RECORD", "rapidocr_onnxruntime/__init__.py", "rapidocr_onnxruntime-1.0.0.dist-info/LICENSE", + "rapidocr_onnxruntime-1.0.0.dist-info/RECORD", } for record in files: path = staging / str(record["member"]) @@ -99,6 +116,7 @@ def test_extract_wheels_hashes_members(tmp_path: Path) -> None: def test_install_runtime_notices_copies_exact_bytes_and_inventory( tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: notice_root = tmp_path / "notices" notice_root.mkdir() @@ -106,6 +124,20 @@ def test_install_runtime_notices_copies_exact_bytes_and_inventory( (notice_root / "NOTICE").write_bytes(b"model attribution\n") staging = tmp_path / "runtime" staging.mkdir() + monkeypatch.setattr( + vision, + "RAPIDOCR_NOTICE_CONTRACT", + { + "rapidocr_onnxruntime-1.4.4.dist-info/licenses/LICENSE": ( + hashlib.sha256(b"Apache-2.0 terms\n").hexdigest(), + len(b"Apache-2.0 terms\n"), + ), + "rapidocr_onnxruntime-1.4.4.dist-info/licenses/NOTICE": ( + hashlib.sha256(b"model attribution\n").hexdigest(), + len(b"model attribution\n"), + ), + }, + ) records = vision._install_runtime_notices( # noqa: SLF001 staging, @@ -134,6 +166,16 @@ def test_extract_wheels_rejects_traversal(tmp_path: Path) -> None: vision._extract_wheels((malicious,), staging) # noqa: SLF001 +def test_link_like_rejects_windows_junctions( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(Path, "is_symlink", lambda _path: False) + monkeypatch.setattr(Path, "is_junction", lambda _path: True, raising=False) + + assert vision._is_link_like(tmp_path) is True # noqa: SLF001 + + def test_download_rejects_hash_drift(tmp_path: Path) -> None: payload = b"wrong bytes" wheel = vision.Wheel( @@ -142,6 +184,8 @@ def test_download_rejects_hash_drift(tmp_path: Path) -> None: url="https://files.pythonhosted.org/packages/test/opencv.whl", sha256="0" * 64, bytes=len(payload), + record_member="opencv_python-1.0.0.dist-info/RECORD", + record_sha256="0" * 64, license_expression="MIT", ) @@ -170,6 +214,11 @@ def test_ensure_reuses_only_hash_valid_cache( source_dir = tmp_path / "source" source_dir.mkdir() wheels = ( + _wheel( + source_dir, + "numpy", + {"numpy/__init__.py": b"__version__ = '1.0.0'\n"}, + ), _wheel( source_dir, "rapidocr-onnxruntime", @@ -205,13 +254,38 @@ def fake_download(wheel: vision.Wheel, destination: Path) -> None: second = vision.ensure_managed_vision_runtime(status=lambda _message: None) assert first == second - assert downloads == 2 + assert downloads == 3 assert activations == [first, first] (first / "cv2" / "__init__.py").write_text("tampered\n") + marker_path = first / vision.MARKER_NAME + marker = json.loads(marker_path.read_text()) + cv2_record = next( + record for record in marker["files"] if record["member"] == "cv2/__init__.py" + ) + cv2_record["sha256"] = hashlib.sha256(b"tampered\n").hexdigest() + cv2_record["bytes"] = len(b"tampered\n") + marker_path.write_text(json.dumps(marker)) repaired = vision.ensure_managed_vision_runtime(status=lambda _message: None) assert repaired == first - assert downloads == 4 + assert downloads == 6 assert (repaired / "cv2" / "__init__.py").read_text() == ("__version__ = '1.0.0'\n") assert list(first.parent.glob(f"{first.name}.invalid-*")) + + (repaired / "cv2.py").write_text("unexpected cache shadow\n") + marker = json.loads(marker_path.read_text()) + marker["files"].append( + { + "member": "cv2.py", + "sha256": hashlib.sha256(b"unexpected cache shadow\n").hexdigest(), + "bytes": len(b"unexpected cache shadow\n"), + } + ) + marker_path.write_text(json.dumps(marker)) + + repaired_again = vision.ensure_managed_vision_runtime(status=lambda _message: None) + + assert repaired_again == first + assert downloads == 9 + assert not (repaired_again / "cv2.py").exists() From d80bec89dbf2acd4d254cf59e6b0cc5462634e92 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 23 Jul 2026 18:55:53 -0700 Subject: [PATCH 13/15] fix: serialize managed runtime activation --- engine/managed_vision.py | 164 ++++++++++++++++++++++-------- scripts/smoke_test_frozen_flow.py | 10 ++ tests/test_managed_vision.py | 52 ++++++++++ 3 files changed, 181 insertions(+), 45 deletions(-) diff --git a/engine/managed_vision.py b/engine/managed_vision.py index 7148a6e..27f9840 100644 --- a/engine/managed_vision.py +++ b/engine/managed_vision.py @@ -24,9 +24,11 @@ import shutil import sys import tempfile +import time import urllib.parse import urllib.request import zipfile +from contextlib import contextmanager from dataclasses import dataclass from importlib.metadata import distribution from pathlib import Path, PurePosixPath @@ -36,6 +38,8 @@ MARKER_NAME = ".complete.json" MAX_EXTRACTED_BYTES = 700 * 1024 * 1024 DOWNLOAD_CHUNK_BYTES = 1024 * 1024 +LOCK_TIMEOUT_SECONDS = 300.0 +LOCK_RETRY_SECONDS = 0.1 ALLOWED_DOWNLOAD_HOST = "files.pythonhosted.org" RAPIDOCR_NOTICE_MEMBERS = ( ("LICENSE", "LICENSE"), @@ -497,6 +501,8 @@ def _actual_cache_files(path: Path) -> set[str]: def _cache_is_valid(path: Path, contract: RuntimeContract) -> bool: + if _is_link_like(path): + return False try: marker = json.loads((path / MARKER_NAME).read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): @@ -531,6 +537,69 @@ def _cache_is_valid(path: Path, contract: RuntimeContract) -> bool: return True +def _try_lock(stream: BinaryIO) -> None: + stream.seek(0) + if os.name == "nt": + import msvcrt + + msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1) + else: + import fcntl + + fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + + +def _unlock(stream: BinaryIO) -> None: + stream.seek(0) + if os.name == "nt": + import msvcrt + + msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + + fcntl.flock(stream.fileno(), fcntl.LOCK_UN) + + +@contextmanager +def _runtime_lock( + lock_path: Path, + *, + status: Callable[[str], None], + timeout: float = LOCK_TIMEOUT_SECONDS, +): + """Serialize admission through activation; OS locks release on process death.""" + + lock_path.parent.mkdir(parents=True, exist_ok=True) + with lock_path.open("a+b") as stream: + if stream.seek(0, os.SEEK_END) == 0: + stream.write(b"\0") + stream.flush() + deadline = time.monotonic() + timeout + announced = False + while True: + try: + _try_lock(stream) + break + except OSError as exc: + if time.monotonic() >= deadline: + raise ManagedVisionRuntimeError( + "timed out waiting for another OpenAdapt process to finish " + "preparing the local vision runtime" + ) from exc + if not announced: + status( + "OpenAdapt: waiting for another process to finish preparing " + "the local vision runtime..." + ) + announced = True + time.sleep(LOCK_RETRY_SECONDS) + try: + yield + finally: + _unlock(stream) + + def _activate(path: Path, contract: RuntimeContract) -> None: resolved = str(path.resolve()) if resolved not in sys.path: @@ -583,52 +652,57 @@ def ensure_managed_vision_runtime( contract = load_contract() root = runtime_root() final = root / contract.build_id / contract.target - if _cache_is_valid(final, contract): - _activate(final, contract) - return final - - status("OpenAdapt: preparing the separately licensed local vision runtime (one-time download).") root.mkdir(parents=True, exist_ok=True) - staging = Path(tempfile.mkdtemp(prefix=".vision-", dir=root)) - archives: list[tuple[Wheel, Path]] = [] - try: - for wheel in contract.wheels: - status(f"OpenAdapt: downloading {wheel.distribution} {wheel.version}...") - archive = staging / f"{wheel.distribution}-{wheel.version}.whl" - _download(wheel, archive) - archives.append((wheel, archive)) - payload = staging / "payload" - payload.mkdir() - status("OpenAdapt: verifying and installing the local vision runtime...") - files = _extract_wheels(tuple(archives), payload) - files.extend(_install_runtime_notices(payload)) - files.sort(key=lambda record: str(record["member"])) - (payload / MARKER_NAME).write_text( - json.dumps(_marker(contract, files), indent=2, sort_keys=True) + "\n", - encoding="utf-8", + lock_path = root / f".{contract.build_id}-{contract.target}.lock" + with _runtime_lock(lock_path, status=status): + if _cache_is_valid(final, contract): + _activate(final, contract) + return final + + status( + "OpenAdapt: preparing the separately licensed local vision runtime " + "(one-time download)." ) - final.parent.mkdir(parents=True, exist_ok=True) - if final.exists(): - quarantine = final.with_name( - f"{final.name}.invalid-{os.getpid()}-{secrets.token_hex(4)}" + staging = Path(tempfile.mkdtemp(prefix=".vision-", dir=root)) + archives: list[tuple[Wheel, Path]] = [] + try: + for wheel in contract.wheels: + status(f"OpenAdapt: downloading {wheel.distribution} {wheel.version}...") + archive = staging / f"{wheel.distribution}-{wheel.version}.whl" + _download(wheel, archive) + archives.append((wheel, archive)) + payload = staging / "payload" + payload.mkdir() + status("OpenAdapt: verifying and installing the local vision runtime...") + files = _extract_wheels(tuple(archives), payload) + files.extend(_install_runtime_notices(payload)) + files.sort(key=lambda record: str(record["member"])) + (payload / MARKER_NAME).write_text( + json.dumps(_marker(contract, files), indent=2, sort_keys=True) + "\n", + encoding="utf-8", ) - os.replace(final, quarantine) - os.replace(payload, final) - except Exception as exc: - if isinstance(exc, ManagedVisionRuntimeError): - detail = str(exc) - else: - detail = f"unexpected provisioning failure: {exc}" - raise ManagedVisionRuntimeError( - f"{detail}. Run the command again to retry; partial downloads were not activated." - ) from exc - finally: - shutil.rmtree(staging, ignore_errors=True) + final.parent.mkdir(parents=True, exist_ok=True) + if final.exists(): + quarantine = final.with_name( + f"{final.name}.invalid-{os.getpid()}-{secrets.token_hex(4)}" + ) + os.replace(final, quarantine) + os.replace(payload, final) + except Exception as exc: + if isinstance(exc, ManagedVisionRuntimeError): + detail = str(exc) + else: + detail = f"unexpected provisioning failure: {exc}" + raise ManagedVisionRuntimeError( + f"{detail}. Run the command again to retry; partial downloads were not activated." + ) from exc + finally: + shutil.rmtree(staging, ignore_errors=True) - if not _cache_is_valid(final, contract): - raise ManagedVisionRuntimeError( - "managed vision runtime failed its post-install integrity check" - ) - _activate(final, contract) - status("OpenAdapt: local vision runtime is ready.") - return final + if not _cache_is_valid(final, contract): + raise ManagedVisionRuntimeError( + "managed vision runtime failed its post-install integrity check" + ) + _activate(final, contract) + status("OpenAdapt: local vision runtime is ready.") + return final diff --git a/scripts/smoke_test_frozen_flow.py b/scripts/smoke_test_frozen_flow.py index eacf886..8daa37e 100644 --- a/scripts/smoke_test_frozen_flow.py +++ b/scripts/smoke_test_frozen_flow.py @@ -104,6 +104,16 @@ def main() -> int: ) if "Downloading the Chromium browser" in second_output: raise RuntimeError("warm run attempted to download the browser again") + if any( + marker in second_output + for marker in ( + "preparing the separately licensed local vision runtime", + "downloading rapidocr-onnxruntime", + "downloading numpy", + "downloading opencv-python", + ) + ): + raise RuntimeError("warm run attempted to download the vision runtime again") print( json.dumps( diff --git a/tests/test_managed_vision.py b/tests/test_managed_vision.py index 9f39402..d88889b 100644 --- a/tests/test_managed_vision.py +++ b/tests/test_managed_vision.py @@ -4,6 +4,8 @@ import hashlib import io import json +import threading +import time import zipfile from pathlib import Path @@ -176,6 +178,56 @@ def test_link_like_rejects_windows_junctions( assert vision._is_link_like(tmp_path) is True # noqa: SLF001 +def test_cache_admission_rejects_link_like_root( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + contract = vision.load_contract(target="aarch64-apple-darwin") + monkeypatch.setattr(vision, "_is_link_like", lambda path: path == tmp_path) + + assert vision._cache_is_valid(tmp_path, contract) is False # noqa: SLF001 + + +def test_runtime_lock_serializes_concurrent_first_use(tmp_path: Path) -> None: + lock_path = tmp_path / "vision.lock" + first_entered = threading.Event() + release_first = threading.Event() + second_entered = threading.Event() + + def first() -> None: + with vision._runtime_lock( # noqa: SLF001 + lock_path, + status=lambda _message: None, + timeout=2, + ): + first_entered.set() + assert release_first.wait(2) + + def second() -> None: + assert first_entered.wait(2) + with vision._runtime_lock( # noqa: SLF001 + lock_path, + status=lambda _message: None, + timeout=2, + ): + second_entered.set() + + first_thread = threading.Thread(target=first) + second_thread = threading.Thread(target=second) + first_thread.start() + second_thread.start() + assert first_entered.wait(2) + time.sleep(0.05) + assert not second_entered.is_set() + release_first.set() + first_thread.join(2) + second_thread.join(2) + + assert not first_thread.is_alive() + assert not second_thread.is_alive() + assert second_entered.is_set() + + def test_download_rejects_hash_drift(tmp_path: Path) -> None: payload = b"wrong bytes" wheel = vision.Wheel( From e2b40bdc79c2fa47ce4a46ab8936bfa009cedae5 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 23 Jul 2026 19:06:34 -0700 Subject: [PATCH 14/15] fix: exclude unpinned Linux libgcc runtime --- scripts/build_frozen_engine.py | 14 ++++++++++++++ tests/test_build_frozen_engine.py | 18 ++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/scripts/build_frozen_engine.py b/scripts/build_frozen_engine.py index 29d4a29..ac37977 100644 --- a/scripts/build_frozen_engine.py +++ b/scripts/build_frozen_engine.py @@ -45,6 +45,19 @@ ) RAPIDOCR_NOTICE_DIR = ROOT / "third_party" / "rapidocr" +LINUX_RUNNER_RUNTIME_EXCLUDE = r"libgcc_s\.so(\..*)?" + + +def configure_system_runtime_boundary(*, platform: str = sys.platform) -> bool: + """Keep the Linux runner's unpinned libgcc outside the frozen artifact.""" + + if not platform.startswith("linux"): + return False + from PyInstaller.depend import dylib + + dylib._excludes.add(LINUX_RUNNER_RUNTIME_EXCLUDE) # noqa: SLF001 + dylib.exclude_list = dylib.MatchList(dylib._excludes) # noqa: SLF001 + return True def notice_data( @@ -157,6 +170,7 @@ def main() -> int: root_license=ROOT / "LICENSE", ) try: + configure_system_runtime_boundary() command = build_command( distpath=args.distpath, workpath=args.workpath, diff --git a/tests/test_build_frozen_engine.py b/tests/test_build_frozen_engine.py index 76682e7..1ee0b9d 100644 --- a/tests/test_build_frozen_engine.py +++ b/tests/test_build_frozen_engine.py @@ -46,6 +46,24 @@ def test_adhoc_build_does_not_enable_hardened_runtime_inside_onefile(tmp_path: P ] +def test_linux_runner_libgcc_is_excluded_at_pyinstaller_resolution( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from PyInstaller.depend import dylib + + monkeypatch.setattr(dylib, "_excludes", set(dylib._excludes)) + monkeypatch.setattr(dylib, "exclude_list", dylib.MatchList(dylib._excludes)) + + assert build.configure_system_runtime_boundary(platform="linux") is True + assert dylib.include_library("/lib/x86_64-linux-gnu/libgcc_s.so.1") is False + assert dylib.include_library("/lib/x86_64-linux-gnu/libstdc++.so.6") is True + + +@pytest.mark.parametrize("platform", ["darwin", "win32"]) +def test_non_linux_system_runtime_boundary_is_a_noop(platform: str) -> None: + assert build.configure_system_runtime_boundary(platform=platform) is False + + def test_frozen_runtime_bundles_required_third_party_notices(tmp_path: Path) -> None: command = _build_command("", tmp_path) From 94f65373b6d1053603c92037ffbffa0132bc622b Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 23 Jul 2026 19:09:03 -0700 Subject: [PATCH 15/15] test: keep Linux boundary check hermetic --- scripts/build_frozen_engine.py | 13 +++++++++---- tests/test_build_frozen_engine.py | 25 ++++++++++++++++++------- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/scripts/build_frozen_engine.py b/scripts/build_frozen_engine.py index ac37977..c69e880 100644 --- a/scripts/build_frozen_engine.py +++ b/scripts/build_frozen_engine.py @@ -48,15 +48,20 @@ LINUX_RUNNER_RUNTIME_EXCLUDE = r"libgcc_s\.so(\..*)?" -def configure_system_runtime_boundary(*, platform: str = sys.platform) -> bool: +def configure_system_runtime_boundary( + *, + platform: str = sys.platform, + dylib_module=None, +) -> bool: """Keep the Linux runner's unpinned libgcc outside the frozen artifact.""" if not platform.startswith("linux"): return False - from PyInstaller.depend import dylib + if dylib_module is None: + from PyInstaller.depend import dylib as dylib_module - dylib._excludes.add(LINUX_RUNNER_RUNTIME_EXCLUDE) # noqa: SLF001 - dylib.exclude_list = dylib.MatchList(dylib._excludes) # noqa: SLF001 + dylib_module._excludes.add(LINUX_RUNNER_RUNTIME_EXCLUDE) + dylib_module.exclude_list = dylib_module.MatchList(dylib_module._excludes) return True diff --git a/tests/test_build_frozen_engine.py b/tests/test_build_frozen_engine.py index 1ee0b9d..9f8282c 100644 --- a/tests/test_build_frozen_engine.py +++ b/tests/test_build_frozen_engine.py @@ -1,11 +1,13 @@ from __future__ import annotations import json +import re import subprocess import sys import tarfile import zipfile from pathlib import Path +from types import SimpleNamespace import pytest @@ -46,15 +48,24 @@ def test_adhoc_build_does_not_enable_hardened_runtime_inside_onefile(tmp_path: P ] -def test_linux_runner_libgcc_is_excluded_at_pyinstaller_resolution( - monkeypatch: pytest.MonkeyPatch, -) -> None: - from PyInstaller.depend import dylib +def test_linux_runner_libgcc_is_excluded_at_pyinstaller_resolution() -> None: + class MatchList: + def __init__(self, patterns) -> None: + self.patterns = tuple(re.compile(pattern) for pattern in patterns) + + def check_library(self, libname: str) -> bool: + return any(pattern.fullmatch(Path(libname).name) for pattern in self.patterns) - monkeypatch.setattr(dylib, "_excludes", set(dylib._excludes)) - monkeypatch.setattr(dylib, "exclude_list", dylib.MatchList(dylib._excludes)) + dylib = SimpleNamespace(_excludes=set(), MatchList=MatchList, exclude_list=MatchList(set())) + dylib.include_library = lambda libname: not dylib.exclude_list.check_library(libname) - assert build.configure_system_runtime_boundary(platform="linux") is True + assert ( + build.configure_system_runtime_boundary( + platform="linux", + dylib_module=dylib, + ) + is True + ) assert dylib.include_library("/lib/x86_64-linux-gnu/libgcc_s.so.1") is False assert dylib.include_library("/lib/x86_64-linux-gnu/libstdc++.so.6") is True