From 2e8af26cdfa3d756695df04574def93767d46f28 Mon Sep 17 00:00:00 2001 From: nan Date: Sun, 9 Aug 2026 11:44:24 +0800 Subject: [PATCH] feat: Add GitHub Actions workflow for building and testing the Depth Anything 3 extension - Create a build workflow in `.github/workflows/build.yml` to automate the build process. - Configure Python environment and install dependencies. - Add steps for running tests and packaging the extension. chore: Initialize project metadata and dependencies for Depth Anything 3 - Add `pyproject.toml` for project configuration and dependencies. - Include optional dependencies for app and gs functionalities. - Define project scripts and metadata. chore: Add requirements file for Depth Anything 3 dependencies - Create `requirements.txt` listing all necessary packages for the project. feat: Implement export utility functions for Depth Anything 3 - Create `src/depth_anything_3/utils/export/__init__.py` to handle various export formats. - Implement functions for exporting predictions in different formats. feat: Add ray utility functions for homography estimation - Create `src/depth_anything_3/utils/ray_utils.py` with functions for computing optimal rotation and homography using RANSAC. feat: Implement vendor preparation script - Add `prepare_vendor.py` to manage third-party dependencies and artifacts. - Include functions for downloading and verifying artifacts based on a lock file. test: Add unit tests for vendor preparation script - Create `tests/test_prepare_vendor.py` to test artifact downloading and verification. - Implement tests for handling hash mismatches and unsupported platforms. chore: Add vendor lock file for dependency management - Create `vendor.lock.toml` to define artifacts and platforms for the project. --- .github/workflows/build.yml | 41 ++ .gitignore | 2 +- docs/technical.md | 20 +- pack.py | 106 +++- patches/depth-anything-3/pyproject.toml | 93 +++ patches/depth-anything-3/requirements.txt | 23 + .../depth_anything_3/utils/export/__init__.py | 84 +++ .../src/depth_anything_3/utils/ray_utils.py | 531 ++++++++++++++++++ prepare_vendor.py | 111 ++++ pyproject.toml | 2 +- src/snapform/blender_manifest.toml | 2 +- src/snapform/bootstrap_runtime.py | 38 +- tests/test_bootstrap_runtime.py | 97 ++-- tests/test_pack.py | 53 +- tests/test_prepare_vendor.py | 67 +++ uv.lock | 147 ++++- vendor.lock.toml | 32 ++ 17 files changed, 1341 insertions(+), 108 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 patches/depth-anything-3/pyproject.toml create mode 100644 patches/depth-anything-3/requirements.txt create mode 100644 patches/depth-anything-3/src/depth_anything_3/utils/export/__init__.py create mode 100644 patches/depth-anything-3/src/depth_anything_3/utils/ray_utils.py create mode 100644 prepare_vendor.py create mode 100644 tests/test_prepare_vendor.py create mode 100644 vendor.lock.toml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..d293dc2 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,41 @@ +name: 构建扩展 + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: 检出代码 + uses: actions/checkout@v6 + + - name: 配置 Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + cache: pip + + - name: 安装测试依赖 + run: python -m pip install numpy pytest + + - name: 准备 Vendor + run: python prepare_vendor.py + + - name: 运行测试 + run: python -m pytest + + - name: 构建扩展 + run: python pack.py + + - name: 上传扩展包 + uses: actions/upload-artifact@v7 + with: + name: SnapForm + path: dist/*.zip + if-no-files-found: error diff --git a/.gitignore b/.gitignore index e6be4e7..efbc756 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,4 @@ build/ *.zip .pytest_cache/ -sources/ +/vendor/ diff --git a/docs/technical.md b/docs/technical.md index f7a0b3b..f41be33 100644 --- a/docs/technical.md +++ b/docs/technical.md @@ -46,15 +46,16 @@ ## 使用 -1. 使用 `uv run python pack.py` 分别构建 `windows-x64`、`macos-x64`、`macos-arm64`、 - `linux-x64` 四个平台的 Extension ZIP(如 `SnapForm.v0.10.10.macos-arm64.zip`)。 +1. 先运行 `uv run python prepare_vendor.py`,按 `vendor.lock.toml` 下载并校验固定版本的 + 第三方构建输入;再运行 `uv run python pack.py`,分别构建 `windows-x64`、`macos-arm64`、 + `linux-x64` 三个平台的 Extension ZIP(如 `SnapForm.v0.10.11.macos-arm64.zip`)。 每个包只含对应平台的 uv 二进制,并内置 DA3、BEN2 源码包。 2. 在 Blender 的 `Preferences > Extensions` 中选择 `Install from Disk`。 3. 在 Extension Preferences 中确认 Storage Root 与 Device。Device 是正式功能 (Image Empty 右键菜单)使用的推理设备,默认取本机最快的 CUDA/MPS,否则 CPU,且只列出 本机支持的设备。 4. 在 `Host` 点击 `Install Runtime`。首次安装会下载 Python、PyTorch、FastAPI、Uvicorn; - DA3、BEN2 源码包与 uv 已内置在扩展中,安装时从 `~/.snapform/sources/` 解包使用, + DA3、BEN2 源码包与 uv 已内置在扩展中,安装时从 `~/.snapform/vendor/` 解包使用, 并在 Runtime 就绪后自动下载 `DA3 Small` 和 `BEN2 Base` 推荐模型。Runtime 安装 固定显示 `Environment`、`PyTorch`、`AI components` 三个用户阶段;xFormers(macOS 跳过)、 FastAPI 等内部安装步骤合并在 `AI components` 中。耗时最长的 PyTorch 与 AI 组件占主要进度 @@ -301,12 +302,13 @@ Windows 上代理若只在系统设置里而未写入环境变量,安装程序 - 固定到已验证 commit 的 Depth Anything 3 - 固定到已验证 commit 的 BEN2 - FastAPI 0.139.2 与 Uvicorn 0.51.0 -- DA3 与 BEN2 源码包随扩展打包,安装时复制到 `~/.snapform/sources/` 后由 uv 本地安装 +- DA3 与 BEN2 源码包随扩展打包,安装时复制到 `~/.snapform/vendor/` 后由 uv 本地安装 (无需访问 GitHub);SnapForm 直接调用 DA3 底层 export 接口,`gs_ply` 导出不会触发 `gs_video` 渲染,因此不需要 gsplat 与 C++ 编译工具链 -- `sources/` 下的 DA3/BEN2 源码包保持官方原版(固定 commit);`sources/patches/<包名>/` - 存放 SnapForm 对源码的覆盖文件(移除 xformers 硬依赖、export 入口改懒加载、 +- `vendor/` 是不进入 Git 的本地缓存;`vendor.lock.toml` 固定下载 URL、commit/version 与 + SHA-256,`patches/<包名>/` 进入 Git 并存放 SnapForm 对源码的覆盖文件(移除 xformers + 硬依赖、export 入口改懒加载、 射线位姿的 3×3 QR 分解在 MPS 上改走 CPU), `pack.py` 打包时自动套入官方包,扩展 zip 内才是修补后的版本;更新上游只需替换官方包 并核对覆盖文件是否仍适用 @@ -372,6 +374,7 @@ Preferences 的 `Storage Root` 中修改共享位置。不要把模型写入 Ext ## 开发 ```bash +uv run python prepare_vendor.py uv run pytest tests -v uv run python pack.py ``` @@ -380,8 +383,11 @@ uv run python pack.py ```text . +├── vendor.lock.toml 第三方构建输入的版本、URL 与 SHA-256 +├── prepare_vendor.py 下载并校验第三方构建输入 ├── pack.py -├── sources/ 官方 DA3/BEN2 源码包、patches 覆盖文件与各平台 uv 二进制 +├── patches/ 进入 Git 的 DA3 源码覆盖文件 +├── vendor/ 不进入 Git 的下载缓存;本地调试也直接读取这里 ├── src/ │ └── snapform/ │ ├── blender_manifest.toml diff --git a/pack.py b/pack.py index ca4be0c..588f608 100644 --- a/pack.py +++ b/pack.py @@ -1,3 +1,4 @@ +import hashlib import io import tarfile import tomllib @@ -7,28 +8,64 @@ from zipfile import ZIP_DEFLATED, ZipFile PROJECT_ROOT = Path(__file__).resolve().parent SOURCE_DIR = PROJECT_ROOT / "src" / "snapform" -SOURCES_DIR = PROJECT_ROOT / "sources" +VENDOR_DIR = PROJECT_ROOT / "vendor" DIST_DIR = PROJECT_ROOT / "dist" -PATCHES_DIR = SOURCES_DIR / "patches" +PATCHES_DIR = PROJECT_ROOT / "patches" +LOCK_PATH = PROJECT_ROOT / "vendor.lock.toml" + +def load_vendor_lock(): + """Return the locked third-party artifact metadata.""" + with LOCK_PATH.open("rb") as stream: + lock = tomllib.load(stream) + if lock.get("schema_version") != 1: + raise RuntimeError("Unsupported vendor.lock.toml schema") + return lock + + +VENDOR_LOCK = load_vendor_lock() PLATFORMS = { - "windows-x64": "uv-x86_64-pc-windows-msvc.zip", - "macos-x64": "uv-x86_64-apple-darwin.tar.gz", - "macos-arm64": "uv-aarch64-apple-darwin.tar.gz", - "linux-x64": "uv-x86_64-unknown-linux-gnu.tar.gz", + platform: artifact["filename"] + for platform, artifact in VENDOR_LOCK["platforms"].items() } BUNDLED_ARCHIVES = { - "sources/depth-anything-3.tar.gz": "depth-anything-3.tar.gz", - "sources/ben2.tar.gz": "ben2.tar.gz", + f"vendor/{artifact['filename']}": artifact["filename"] + for artifact in VENDOR_LOCK["artifacts"].values() } OVERLAYS = { - "depth-anything-3.tar.gz": "depth-anything-3", + artifact["filename"]: artifact["patches"] + for artifact in VENDOR_LOCK["artifacts"].values() + if "patches" in artifact } -def bundled_source_bytes(filename): - """返回内置源码包内容;patches// 目录中的文件会覆盖官方包对应路径。""" - archive_path = SOURCES_DIR / filename +def file_sha256(path): + """Return the SHA-256 digest for a file.""" + digest = hashlib.sha256() + with Path(path).open("rb") as stream: + while chunk := stream.read(1024 * 1024): + digest.update(chunk) + return digest.hexdigest() + + +def validate_artifact(path, expected_sha256): + """Require an artifact to exist and match its locked digest.""" + if not path.is_file(): + raise RuntimeError( + f"Missing vendor artifact: {path}. " + "Run: uv run python prepare_vendor.py" + ) + actual = file_sha256(path) + if actual != expected_sha256: + raise RuntimeError( + f"Vendor artifact hash mismatch: {path}. " + "Run: uv run python prepare_vendor.py" + ) + + +def bundled_artifact_bytes(filename): + """Return a bundled artifact with its configured overlay applied.""" + archive_path = VENDOR_DIR / filename overlay_dir = PATCHES_DIR / OVERLAYS.get(filename, filename) if not overlay_dir.is_dir(): return archive_path.read_bytes() @@ -71,10 +108,8 @@ def manifest_for_platform(platform): head = lines[:table_start] tail = lines[table_start:] head = [line for line in head if not line.strip().startswith("platforms =")] - # Insert into the top-level section (before any `[table]` header) so the - # field is part of the extension metadata, not a sub-key of a table. - # Place it after the `blender_version_min` line, alongside the other - # top-level keys (before `license`), keeping the file readable. + # 插入顶层区域,避免 platforms 被解析为后续 table 的子键。 + # 放在 blender_version_min 后面,使扩展元数据保持易读。 insert_at = next( (i + 1 for i, line in enumerate(head) if line.strip().startswith("blender_version_min")), len(head), @@ -84,17 +119,31 @@ def manifest_for_platform(platform): return "\n".join(lines) + "\n" -def bundled_source_files(platform): +def bundled_vendor_files(platform): entries = { - name: SOURCES_DIR / filename + name: VENDOR_DIR / filename for name, filename in BUNDLED_ARCHIVES.items() } - entries[f"sources/{PLATFORMS[platform]}"] = ( - SOURCES_DIR / PLATFORMS[platform] + entries[f"vendor/{PLATFORMS[platform]}"] = ( + VENDOR_DIR / PLATFORMS[platform] ) return entries +def validate_vendor_files(platform): + """Validate common archives and the selected uv archive.""" + for artifact in VENDOR_LOCK["artifacts"].values(): + validate_artifact( + VENDOR_DIR / artifact["filename"], + artifact["sha256"], + ) + uv_artifact = VENDOR_LOCK["platforms"][platform] + validate_artifact( + VENDOR_DIR / uv_artifact["filename"], + uv_artifact["sha256"], + ) + + def build_extension(platform): if platform not in PLATFORMS: raise ValueError(f"Unsupported platform: {platform}") @@ -103,29 +152,28 @@ def build_extension(platform): f"{manifest['name']}.v{manifest['version']}.{platform}.zip" ) DIST_DIR.mkdir(exist_ok=True) - sources = bundled_source_files(platform) - missing = [str(path) for path in sources.values() if not path.is_file()] - if missing: - raise RuntimeError( - "Missing bundled source files: " + ", ".join(missing) - ) + vendor_files = bundled_vendor_files(platform) + validate_vendor_files(platform) with ZipFile(output_path, "w", ZIP_DEFLATED) as archive: for source_path in sorted(SOURCE_DIR.rglob("*")): if not source_path.is_file(): continue if "__pycache__" in source_path.parts: continue + relative_path = source_path.relative_to(SOURCE_DIR) + if relative_path.parts[0] == "vendor": + continue if source_path.name == "blender_manifest.toml": continue - archive.write(source_path, source_path.relative_to(SOURCE_DIR)) + archive.write(source_path, relative_path) archive.writestr( "blender_manifest.toml", manifest_for_platform(platform), ) - for archive_name, source_path in sources.items(): + for archive_name, source_path in vendor_files.items(): archive.writestr( archive_name, - bundled_source_bytes(source_path.name), + bundled_artifact_bytes(source_path.name), ) return output_path diff --git a/patches/depth-anything-3/pyproject.toml b/patches/depth-anything-3/pyproject.toml new file mode 100644 index 0000000..e0b6dc6 --- /dev/null +++ b/patches/depth-anything-3/pyproject.toml @@ -0,0 +1,93 @@ +[build-system] +requires = ["hatchling>=1.25", "hatch-vcs>=0.4"] +build-backend = "hatchling.build" + +[project] +name = "depth-anything-3" +version = "0.0.0" +description = "Depth Anything 3" +readme = "README.md" +requires-python = ">=3.9, <=3.13" +license = { text = "Apache-2.0" } +authors = [{ name = "Your Name" }] + +dependencies = [ + "pre-commit", + "trimesh", + "torch>=2", + "torchvision", + "einops", + "huggingface_hub", + "imageio", + "numpy<2", + "opencv-python", + "open3d", + "fastapi", + "uvicorn", + "requests", + "typer", + "pillow", + "omegaconf", + "evo", + "e3nn", + "moviepy", + "plyfile", + "pillow_heif", + "safetensors", + "uvicorn", + "moviepy==1.0.3", + "typer>=0.9.0", + "pycolmap", +] + +[project.optional-dependencies] +app = ["gradio>=5", "pillow>=9.0"] # requires that python3>=3.10 +gs = ["gsplat @ git+https://github.com/nerfstudio-project/gsplat.git@0b4dddf04cb687367602c01196913cde6a743d70"] +all = ["depth-anything-3[app,gs]"] + + +[project.scripts] +da3 = "depth_anything_3.cli:app" + +[project.urls] +Homepage = "https://github.com/ByteDance-Seed/Depth-Anything-3" + +[tool.hatch.version] +source = "vcs" + +[tool.hatch.build.targets.wheel] +packages = ["src/depth_anything_3"] + +[tool.hatch.build.targets.sdist] +include = [ + "/README.md", + "/pyproject.toml", + "/src/depth_anything_3", +] + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.mypy] +plugins = ["jaxtyping.mypy_plugin"] + +[tool.black] +line-length = 99 +target-version = ['py37', 'py38', 'py39', 'py310', 'py311'] +include = '\.pyi?$' +exclude = ''' +/( + | \.git +)/ +''' + +[tool.isort] +profile = "black" +multi_line_output = 3 +include_trailing_comma = true +known_third_party = ["bson","cruise","cv2","dataloader","diffusers","omegaconf","tensorflow","torch","torchvision","transformers","gsplat"] +known_first_party = ["common", "data", "models", "projects"] +sections = ["FUTURE","STDLIB","THIRDPARTY","FIRSTPARTY","LOCALFOLDER"] +skip_gitignore = true +line_length = 99 +no_lines_before="THIRDPARTY" diff --git a/patches/depth-anything-3/requirements.txt b/patches/depth-anything-3/requirements.txt new file mode 100644 index 0000000..a10d6b6 --- /dev/null +++ b/patches/depth-anything-3/requirements.txt @@ -0,0 +1,23 @@ +pre-commit +trimesh +torch>=2 +torchvision +einops +huggingface_hub +imageio +numpy<2 +opencv-python +open3d +fastapi +uvicorn +requests +typer +pillow +omegaconf +evo +e3nn +moviepy +plyfile +pillow_heif +safetensors +pycolmap diff --git a/patches/depth-anything-3/src/depth_anything_3/utils/export/__init__.py b/patches/depth-anything-3/src/depth_anything_3/utils/export/__init__.py new file mode 100644 index 0000000..9f774c1 --- /dev/null +++ b/patches/depth-anything-3/src/depth_anything_3/utils/export/__init__.py @@ -0,0 +1,84 @@ +# Copyright (c) 2025 ByteDance Ltd. and/or its affiliates +# +# 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. + +from depth_anything_3.specs import Prediction + +# Canonical set of export formats this dispatcher understands. Treat this as the +# server-trusted enum for any request-facing validation (e.g. the HTTP backend) instead +# of letting a client-supplied export_format string reach the dispatcher unchecked. +SUPPORTED_EXPORT_FORMATS = frozenset( + { + "glb", + "mini_npz", + "npz", + "feat_vis", + "depth_vis", + "gs_ply", + "gs_video", + "colmap", + } +) + + +def export( + prediction: Prediction, + export_format: str, + export_dir: str, + **kwargs, +): + if "-" in export_format: + export_formats = export_format.split("-") + for export_format in export_formats: + export(prediction, export_format, export_dir, **kwargs) + return # Prevent falling through to single-format handling + + if export_format == "glb": + from .glb import export_to_glb + + export_to_glb(prediction, export_dir, **kwargs.get(export_format, {})) + elif export_format == "mini_npz": + from .npz import export_to_mini_npz + + export_to_mini_npz(prediction, export_dir) + elif export_format == "npz": + from .npz import export_to_npz + + export_to_npz(prediction, export_dir) + elif export_format == "feat_vis": + from .feat_vis import export_to_feat_vis + + export_to_feat_vis(prediction, export_dir, **kwargs.get(export_format, {})) + elif export_format == "depth_vis": + from .depth_vis import export_to_depth_vis + + export_to_depth_vis(prediction, export_dir) + elif export_format == "gs_ply": + from .gs import export_to_gs_ply + + export_to_gs_ply(prediction, export_dir, **kwargs.get(export_format, {})) + elif export_format == "gs_video": + from .gs import export_to_gs_video + + export_to_gs_video(prediction, export_dir, **kwargs.get(export_format, {})) + elif export_format == "colmap": + from .colmap import export_to_colmap + + export_to_colmap(prediction, export_dir, **kwargs.get(export_format, {})) + else: + raise ValueError(f"Unsupported export format: {export_format}") + + +__all__ = [ + export, +] diff --git a/patches/depth-anything-3/src/depth_anything_3/utils/ray_utils.py b/patches/depth-anything-3/src/depth_anything_3/utils/ray_utils.py new file mode 100644 index 0000000..b92434f --- /dev/null +++ b/patches/depth-anything-3/src/depth_anything_3/utils/ray_utils.py @@ -0,0 +1,531 @@ +# Copyright (c) 2025 ByteDance Ltd. and/or its affiliates +# +# 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. + +import torch +from einops import repeat +from .geometry import unproject_depth + + +def compute_optimal_rotation_intrinsics_batch( + rays_origin, rays_target, z_threshold=1e-4, reproj_threshold=0.2, weights=None, + n_sample = None, + n_iter=100, + num_sample_for_ransac=8, + rand_sample_iters_idx=None, +): + """ + Args: + rays_origin (torch.Tensor): (B, N, 3) + rays_target (torch.Tensor): (B, N, 3) + z_threshold (float): Threshold for z value to be considered valid. + + Returns: + R (torch.tensor): (3, 3) + focal_length (torch.tensor): (2,) + principal_point (torch.tensor): (2,) + """ + device = rays_origin.device + B, N, _ = rays_origin.shape + z_mask = torch.logical_and( + torch.abs(rays_target[:, :, 2]) > z_threshold, torch.abs(rays_origin[:, :, 2]) > z_threshold + ) # (B, N, 1) + rays_origin = rays_origin.clone() + rays_target = rays_target.clone() + rays_origin[:, :, 0][z_mask] /= rays_origin[:, :, 2][z_mask] + rays_origin[:, :, 1][z_mask] /= rays_origin[:, :, 2][z_mask] + rays_target[:, :, 0][z_mask] /= rays_target[:, :, 2][z_mask] + rays_target[:, :, 1][z_mask] /= rays_target[:, :, 2][z_mask] + + rays_origin = rays_origin[:, :, :2] + rays_target = rays_target[:, :, :2] + assert weights is not None, "weights must be provided" + weights[~z_mask] = 0 + + A_list = [] + max_chunk_size = 2 + for i in range(0, rays_origin.shape[0], max_chunk_size): + A = ransac_find_homography_weighted_fast_batch( + rays_origin[i:i+max_chunk_size], + rays_target[i:i+max_chunk_size], + weights[i:i+max_chunk_size], + n_iter=n_iter, + n_sample = n_sample, + num_sample_for_ransac=num_sample_for_ransac, + reproj_threshold=reproj_threshold, + rand_sample_iters_idx=rand_sample_iters_idx, + max_inlier_num=8000, + ) + A = A.to(device) + A_need_inv_mask = torch.linalg.det(A) < 0 + A[A_need_inv_mask] = -A[A_need_inv_mask] + A_list.append(A) + + A = torch.cat(A_list, dim=0) + + R_list = [] + f_list = [] + pp_list = [] + for i in range(A.shape[0]): + R, L = ql_decomposition(A[i]) + L = L / L[2][2] + + f = torch.stack((L[0][0], L[1][1])) + pp = torch.stack((L[2][0], L[2][1])) + R_list.append(R) + f_list.append(f) + pp_list.append(pp) + + R = torch.stack(R_list) + f = torch.stack(f_list) + pp = torch.stack(pp_list) + + return R, f, pp + + +# https://www.reddit.com/r/learnmath/comments/v1crd7/linear_algebra_qr_to_ql_decomposition/ +def ql_decomposition(A): + device = A.device + P = torch.tensor([[0, 0, 1], [0, 1, 0], [1, 0, 0]], device=device).float() + A_tilde = torch.matmul(A, P) + # MPS 尚未实现 torch.linalg.qr,仅把 3x3 分解放到 CPU 执行后移回 + use_cpu = device.type == "mps" + if use_cpu: + A_tilde = A_tilde.cpu() + Q_tilde, R_tilde = torch.linalg.qr(A_tilde) + if use_cpu: + Q_tilde = Q_tilde.to(device=device) + R_tilde = R_tilde.to(device=device) + Q = torch.matmul(Q_tilde, P) + L = torch.matmul(torch.matmul(P, R_tilde), P) + d = torch.diag(L) + Q[:, 0] *= torch.sign(d[0]) + Q[:, 1] *= torch.sign(d[1]) + Q[:, 2] *= torch.sign(d[2]) + L[0] *= torch.sign(d[0]) + L[1] *= torch.sign(d[1]) + L[2] *= torch.sign(d[2]) + return Q, L + +def find_homography_least_squares_weighted_torch(src_pts, dst_pts, confident_weight): + """ + src_pts: (N,2) source points (torch.Tensor, float32/float64) + dst_pts: (N,2) target points (torch.Tensor, float32/float64) + confident_weight: (N,) weights (torch.Tensor) + Returns: (3,3) homography matrix H (torch.Tensor) + """ + assert src_pts.shape == dst_pts.shape + N = src_pts.shape[0] + if N < 4: + raise ValueError("At least 4 points are required to compute homography.") + assert confident_weight.shape == (N,) + + w = confident_weight.sqrt().unsqueeze(1) # (N,1) + + x = src_pts[:, 0:1] # (N,1) + y = src_pts[:, 1:2] # (N,1) + u = dst_pts[:, 0:1] + v = dst_pts[:, 1:2] + + zeros = torch.zeros_like(x) + + # Construct A matrix (2N, 9) + A1 = torch.cat([-x * w, -y * w, -w, zeros, zeros, zeros, x * u * w, y * u * w, u * w], dim=1) + A2 = torch.cat([zeros, zeros, zeros, -x * w, -y * w, -w, x * v * w, y * v * w, v * w], dim=1) + A = torch.cat([A1, A2], dim=0) # (2N, 9) + + # SVD + # Note: torch.linalg.svd returns U, S, Vh, where Vh is the transpose of V + _, _, Vh = torch.linalg.svd(A) + H = Vh[-1].reshape(3, 3) + H = H / H[-1, -1] + return H + + +def ransac_find_homography_weighted( + src_pts, + dst_pts, + confident_weight, + n_iter=100, + sample_ratio=0.2, + reproj_threshold=3.0, + num_sample_for_ransac=16, + random_seed=None, +): + """ + RANSAC version of weighted Homography estimation. + Sample 4 points from the top 50% weighted points each time. + reproj_threshold: points with reprojection error less than this value are inliers + Returns: best_H + """ + if random_seed is not None: + torch.manual_seed(random_seed) + N = src_pts.shape[0] + assert N >= 4 + # 1. Select top 50% weighted points + sorted_idx = torch.argsort(confident_weight, descending=True) + n_sample = max(num_sample_for_ransac, int(N * sample_ratio)) + candidate_idx = sorted_idx[:n_sample] + best_inlier_mask = None + best_score = 0 + for _ in range(n_iter): + # 2. Randomly sample 4 points + idx = candidate_idx[torch.randperm(n_sample)[:num_sample_for_ransac]] + # 3. Compute Homography + try: + H = find_homography_least_squares_weighted_torch( + src_pts[idx], dst_pts[idx], confident_weight[idx] + ) + except Exception: + H = torch.eye(3, dtype=src_pts.dtype, device=src_pts.device) + # 4. Compute reprojection error for all points + src_homo = torch.cat( + [src_pts, torch.ones(N, 1, dtype=src_pts.dtype, device=src_pts.device)], dim=1 + ) + proj = (H @ src_homo.T).T + proj = proj[:, :2] / proj[:, 2:3] + error = ((proj - dst_pts) ** 2).sum(dim=1).sqrt() # Euclidean distance + inlier_mask = error < reproj_threshold + total_score = (inlier_mask * confident_weight).sum().item() + n_inlier = inlier_mask.sum().item() + if n_inlier < 4: + continue # At least 4 inliers required for fitting + + if total_score > best_score: + best_score = total_score + best_inlier_mask = inlier_mask + + # 5. Refit Homography using inliers + H_inlier = find_homography_least_squares_weighted_torch( + src_pts[best_inlier_mask], dst_pts[best_inlier_mask], confident_weight[best_inlier_mask] + ) + + return H_inlier + + +def find_homography_least_squares_weighted_torch_batch( + src_pts_batch, dst_pts_batch, confident_weight_batch +): + """ + Batch version of weighted least squares Homography + src_pts_batch: (B, K, 2) + dst_pts_batch: (B, K, 2) + confident_weight_batch: (B, K) + Returns: (B, 3, 3) + """ + B, K, _ = src_pts_batch.shape + w = confident_weight_batch.sqrt().unsqueeze(2) # (B,K,1) + x = src_pts_batch[:, :, 0:1] + y = src_pts_batch[:, :, 1:2] + u = dst_pts_batch[:, :, 0:1] + v = dst_pts_batch[:, :, 1:2] + zeros = torch.zeros_like(x) + A1 = torch.cat([-x * w, -y * w, -w, zeros, zeros, zeros, x * u * w, y * u * w, u * w], dim=2) + A2 = torch.cat([zeros, zeros, zeros, -x * w, -y * w, -w, x * v * w, y * v * w, v * w], dim=2) + A = torch.cat([A1, A2], dim=1) # (B, 2K, 9) + # SVD: torch.linalg.svd supports batch + _, _, Vh = torch.linalg.svd(A) + H = Vh[:, -1].reshape(B, 3, 3) + H = H / H[:, 2:3, 2:3] + return H + + +def ransac_find_homography_weighted_fast( + src_pts, + dst_pts, + confident_weight, + n_sample, + n_iter=100, + reproj_threshold=3.0, + num_sample_for_ransac=8, + random_seed=None, + rand_sample_iters_idx=None, +): + """ + Batch version of RANSAC weighted Homography estimation. + Returns: H_inlier + """ + if random_seed is not None: + torch.manual_seed(random_seed) + N = src_pts.shape[0] + device = src_pts.device + assert N >= 4 + # 1. Select top weighted points by sample_ratio + sorted_idx = torch.argsort(confident_weight, descending=True) + candidate_idx = sorted_idx[:n_sample] # (n_sample,) + if rand_sample_iters_idx is None: + rand_sample_iters_idx = torch.stack( + [torch.randperm(n_sample, device=device)[:num_sample_for_ransac] for _ in range(n_iter)], + dim=0, + ) # (n_iter, num_sample_for_ransac) + # 2. Generate all sampling groups at once + # shape: (n_iter, num_sample_for_ransac) + rand_idx = candidate_idx[rand_sample_iters_idx] # (n_iter, num_sample_for_ransac) + # 3. Construct batch input + src_pts_batch = src_pts[rand_idx] # (n_iter, num_sample_for_ransac, 2) + dst_pts_batch = dst_pts[rand_idx] # (n_iter, num_sample_for_ransac, 2) + confident_weight_batch = confident_weight[rand_idx] # (n_iter, num_sample_for_ransac) + # 4. Batch fit Homography + H_batch = find_homography_least_squares_weighted_torch_batch( + src_pts_batch, dst_pts_batch, confident_weight_batch + ) # (n_iter, 3, 3) + # 5. Batch evaluate inliers for all H + src_homo = torch.cat( + [src_pts, torch.ones(N, 1, dtype=src_pts.dtype, device=src_pts.device)], dim=1 + ) # (N,3) + src_homo_expand = src_homo.unsqueeze(0).expand(n_iter, N, 3) # (n_iter, N, 3) + dst_pts_expand = dst_pts.unsqueeze(0).expand(n_iter, N, 2) # (n_iter, N, 2) + confident_weight_expand = confident_weight.unsqueeze(0).expand(n_iter, N) # (n_iter, N) + # H_batch: (n_iter, 3, 3) + proj = torch.bmm(src_homo_expand, H_batch.transpose(1, 2)) # (n_iter, N, 3) + proj_xy = proj[:, :, :2] / proj[:, :, 2:3] # (n_iter, N, 2) + error = ((proj_xy - dst_pts_expand) ** 2).sum(dim=2).sqrt() # (n_iter, N) + inlier_mask = error < reproj_threshold # (n_iter, N) + total_score = (inlier_mask * confident_weight_expand).sum(dim=1) # (n_iter,) + # 6. Select the sampling group with the highest score + best_idx = torch.argmax(total_score) + best_inlier_mask = inlier_mask[best_idx] # (N,) + inlier_src_pts = src_pts[best_inlier_mask] + inlier_dst_pts = dst_pts[best_inlier_mask] + inlier_confident_weight = confident_weight[best_inlier_mask] + + max_inlier_num = 10000 + sorted_idx = torch.argsort(inlier_confident_weight, descending=True) + + # method 1: sort according to confident_weight, and only keep max_inlier_num pts + # sorted_idx = sorted_idx[:max_inlier_num] + + # method 2: random choose max_inlier_num pts + sorted_idx = sorted_idx[torch.randperm(len(sorted_idx))[:max_inlier_num]] + + inlier_src_pts = inlier_src_pts[sorted_idx] + inlier_dst_pts = inlier_dst_pts[sorted_idx] + inlier_confident_weight = inlier_confident_weight[sorted_idx] + # 7. Refit Homography using inliers + H_inlier = find_homography_least_squares_weighted_torch( + inlier_src_pts, inlier_dst_pts, inlier_confident_weight + ) + return H_inlier + + +def ransac_find_homography_weighted_fast_batch( + src_pts, # (B, N, 3) + dst_pts, # (B, N, 2) + confident_weight, # (B, N) + n_sample, + n_iter=100, + reproj_threshold=3.0, + num_sample_for_ransac=8, + max_inlier_num=10000, + random_seed=None, + rand_sample_iters_idx=None, +): + """ + Batch version of RANSAC weighted Homography estimation (supports batch). + Input: + src_pts: (B, N, 2) + dst_pts: (B, N, 2) + confident_weight: (B, N) + Returns: + H_inlier: (B, 3, 3) + """ + if random_seed is not None: + torch.manual_seed(random_seed) + B, N, _ = src_pts.shape + assert N >= 4 + + device = src_pts.device + + # 1. Select top weighted points by sample_ratio + sorted_idx = torch.argsort(confident_weight, descending=True, dim=1) # (B, N) + candidate_idx = sorted_idx[:, :n_sample] # (B, n_sample) + + # 2. Generate all sampling groups at once + # rand_idx: (B, n_iter, num_sample_for_ransac) + if rand_sample_iters_idx is None: + rand_sample_iters_idx = torch.stack( + [torch.randperm(n_sample, device=device)[:num_sample_for_ransac] for _ in range(n_iter)], + dim=0, + ) # (n_iter, num_sample_for_ransac) + + rand_idx = candidate_idx[:, rand_sample_iters_idx] # (B, n_iter, num_sample_for_ransac) + + # 3. Construct batch input + # Indexing method below: (B, n_iter, num_sample_for_ransac, ...) + b_idx = torch.arange(B, device=device).view(B, 1, 1).expand(B, n_iter, num_sample_for_ransac) + src_pts_batch = src_pts[b_idx, rand_idx] # (B, n_iter, num_sample_for_ransac, 2) + dst_pts_batch = dst_pts[b_idx, rand_idx] # (B, n_iter, num_sample_for_ransac, 2) + confident_weight_batch = confident_weight[b_idx, rand_idx] # (B, n_iter, num_sample_for_ransac) + + # 4. Batch fit Homography + # Need to implement batch version that supports (B, n_iter, num_sample_for_ransac, ...) input + # Output H_batch: (B, n_iter, 3, 3) + cB, cN = src_pts_batch.shape[:2] + H_batch = find_homography_least_squares_weighted_torch_batch( + src_pts_batch.flatten(0, 1), dst_pts_batch.flatten(0, 1), confident_weight_batch.flatten(0, 1) + ) # (B, n_iter, 3, 3) + H_batch = H_batch.unflatten(0, (cB, cN)) + + # 5. Batch evaluate inliers for all H + src_homo = torch.cat( + [src_pts, torch.ones(B, N, 1, dtype=src_pts.dtype, device=src_pts.device)], dim=2 + ) # (B, N, 3) + src_homo_expand = src_homo.unsqueeze(1).expand(B, n_iter, N, 3) # (B, n_iter, N, 3) + dst_pts_expand = dst_pts.unsqueeze(1).expand(B, n_iter, N, 2) # (B, n_iter, N, 2) + confident_weight_expand = confident_weight.unsqueeze(1).expand(B, n_iter, N) # (B, n_iter, N) + + # H_batch: (B, n_iter, 3, 3) + # Need to reshape H_batch to (B*n_iter, 3, 3), src_homo_expand to (B*n_iter, N, 3) + H_batch_flat = H_batch.reshape(-1, 3, 3) + src_homo_expand_flat = src_homo_expand.reshape(-1, N, 3) + proj = torch.bmm(src_homo_expand_flat, H_batch_flat.transpose(1, 2)) # (B*n_iter, N, 3) + proj_xy = proj[:, :, :2] / proj[:, :, 2:3] # (B*n_iter, N, 2) + proj_xy = proj_xy.reshape(B, n_iter, N, 2) + error = ((proj_xy - dst_pts_expand) ** 2).sum(dim=3).sqrt() # (B, n_iter, N) + inlier_mask = error < reproj_threshold # (B, n_iter, N) + total_score = (inlier_mask * confident_weight_expand).sum(dim=2) # (B, n_iter) + + # 6. Select the sampling group with the highest score + best_idx = torch.argmax(total_score, dim=1) # (B,) + best_inlier_mask = inlier_mask[torch.arange(B, device=device), best_idx] # (B, N) + + # 7. Refit Homography using inliers + H_inlier_list = [] + for b in range(B): + mask = best_inlier_mask[b] + inlier_src_pts = src_pts[b][mask] # (?, 3) + inlier_dst_pts = dst_pts[b][mask] # (?, 2) + inlier_confident_weight = confident_weight[b][mask] # (?) + + sorted_idx = torch.argsort(inlier_confident_weight, descending=True) + # # method 1: sort according to confident_weight, and only keep max_inlier_num pts + # sorted_idx = sorted_idx[:max_inlier_num] + # method 2: random choose max_inlier_num pts + if len(sorted_idx) > max_inlier_num: + # random choose from first 95% confident pts + keep_len = max(int(len(sorted_idx) * 0.95), max_inlier_num) + sorted_idx = sorted_idx[:keep_len] + perm = torch.randperm(len(sorted_idx), device=device)[:max_inlier_num] + sorted_idx = sorted_idx[perm] + inlier_src_pts = inlier_src_pts[sorted_idx] + inlier_dst_pts = inlier_dst_pts[sorted_idx] + inlier_confident_weight = inlier_confident_weight[sorted_idx] + + H_inlier = find_homography_least_squares_weighted_torch( + inlier_src_pts, inlier_dst_pts, inlier_confident_weight + ) # (3, 3) + H_inlier_list.append(H_inlier) + H_inlier = torch.stack(H_inlier_list, dim=0) # (B, 3, 3) + return H_inlier + +def get_params_for_ransac(N, device): + n_iter=100 + sample_ratio=0.3 + num_sample_for_ransac=8 + n_sample = max(num_sample_for_ransac, int(N * sample_ratio)) + rand_sample_iters_idx = torch.stack( + [torch.randperm(n_sample, device=device)[:num_sample_for_ransac] for _ in range(n_iter)], + dim=0, + ) # (n_iter, num_sample_for_ransac) + return n_iter, num_sample_for_ransac, n_sample, rand_sample_iters_idx + + +def camray_to_caminfo(camray, confidence=None, reproj_threshold=0.2, training=False): + """ + Args: + camray: (B, S, num_patches_y, num_patches_x, 6) + confidence: (B, S, num_patches_y, num_patches_x) + Returns: + R: (B, S, 3, 3) + T: (B, S, 3) + focal_lengths: (B, S, 2) + principal_points: (B, S, 2) + """ + if confidence is None: + confidence = torch.ones_like(camray[:, :, :, :, 0]) + B, S, num_patches_y, num_patches_x, _ = camray.shape + # identity K, assume imw=imh=2.0 + I_K = torch.eye(3, dtype=camray.dtype, device=camray.device) + I_K[0, 2] = 1.0 + I_K[1, 2] = 1.0 + # repeat I_K to match camray + I_K = I_K.unsqueeze(0).unsqueeze(0).expand(B, S, -1, -1) + + cam_plane_depth = torch.ones( + B, S, num_patches_y, num_patches_x, 1, dtype=camray.dtype, device=camray.device + ) + I_cam_plane_unproj = unproject_depth( + cam_plane_depth, + I_K, + c2w=None, + ixt_normalized=True, + num_patches_x=num_patches_x, + num_patches_y=num_patches_y, + ) # (B, S, num_patches_y, num_patches_x, 3) + + camray = camray.flatten(0, 1).flatten(1, 2) # (B*S, num_patches_y*num_patches_x, 6) + I_cam_plane_unproj = I_cam_plane_unproj.flatten(0, 1).flatten( + 1, 2 + ) # (B*S, num_patches_y*num_patches_x, 3) + confidence = confidence.flatten(0, 1).flatten(1, 2) # (B*S, num_patches_y*num_patches_x) + + # Compute optimal rotation to align rays + N = camray.shape[-2] + device = camray.device + n_iter, num_sample_for_ransac, n_sample, rand_sample_iters_idx = get_params_for_ransac(N, device) + + # Use batch processing (confidence is guaranteed to be not None at this point) + if training: + camray = camray.clone().detach() + I_cam_plane_unproj = I_cam_plane_unproj.clone().detach() + confidence = confidence.clone().detach() + R, focal_lengths, principal_points = compute_optimal_rotation_intrinsics_batch( + I_cam_plane_unproj, + camray[:, :, :3], + reproj_threshold=reproj_threshold, + weights=confidence, + n_sample = n_sample, + n_iter=n_iter, + num_sample_for_ransac=num_sample_for_ransac, + rand_sample_iters_idx=rand_sample_iters_idx, + ) + + T = torch.sum(camray[:, :, 3:] * confidence.unsqueeze(-1), dim=1) / torch.sum( + confidence, dim=-1, keepdim=True + ) + + R = R.reshape(B, S, 3, 3) + T = T.reshape(B, S, 3) + focal_lengths = focal_lengths.reshape(B, S, 2) + principal_points = principal_points.reshape(B, S, 2) + + return R, T, 1.0 / focal_lengths, principal_points + 1.0 + +def get_extrinsic_from_camray(camray, conf, patch_size_y, patch_size_x, training=False): + pred_R, pred_T, pred_focal_lengths, pred_principal_points = camray_to_caminfo( + camray, confidence=conf.squeeze(-1), training=training + ) + + pred_extrinsic = torch.cat( + [ + torch.cat([pred_R, pred_T.unsqueeze(-1)], dim=-1), + repeat( + torch.tensor([0, 0, 0, 1], dtype=pred_R.dtype, device=pred_R.device), + "c -> b s 1 c", + b=pred_R.shape[0], + s=pred_R.shape[1], + ), + ], + dim=-2, + ) # B, S, 4, 4 + return pred_extrinsic, pred_focal_lengths, pred_principal_points diff --git a/prepare_vendor.py b/prepare_vendor.py new file mode 100644 index 0000000..dd09544 --- /dev/null +++ b/prepare_vendor.py @@ -0,0 +1,111 @@ +import argparse +import hashlib +import sys +import tomllib +import urllib.request +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parent +LOCK_PATH = PROJECT_ROOT / "vendor.lock.toml" +VENDOR_DIR = PROJECT_ROOT / "vendor" +CHUNK_SIZE = 1024 * 1024 + + +def load_lock(path=LOCK_PATH): + """Load and validate the vendor lock file.""" + with Path(path).open("rb") as stream: + lock = tomllib.load(stream) + if lock.get("schema_version") != 1: + raise RuntimeError("Unsupported vendor.lock.toml schema") + return lock + + +def file_sha256(path): + """Return the SHA-256 digest for a file.""" + digest = hashlib.sha256() + with Path(path).open("rb") as stream: + while chunk := stream.read(CHUNK_SIZE): + digest.update(chunk) + return digest.hexdigest() + + +def download_artifact(artifact, destination_dir=VENDOR_DIR): + """Download one locked artifact and verify its content.""" + destination_dir = Path(destination_dir) + destination = destination_dir / artifact["filename"] + expected = artifact["sha256"] + if destination.is_file() and file_sha256(destination) == expected: + print(f"Ready: {destination.name}") + return destination + + destination_dir.mkdir(parents=True, exist_ok=True) + temporary = destination.with_name(f"{destination.name}.part") + temporary.unlink(missing_ok=True) + print(f"Downloading: {destination.name}") + request = urllib.request.Request( + artifact["url"], + headers={"User-Agent": "SnapForm vendor preparation"}, + ) + try: + with urllib.request.urlopen(request) as response, temporary.open("wb") as output: + while chunk := response.read(CHUNK_SIZE): + output.write(chunk) + actual = file_sha256(temporary) + if actual != expected: + raise RuntimeError( + f"SHA-256 mismatch for {destination.name}: " + f"expected {expected}, got {actual}" + ) + temporary.replace(destination) + except Exception: + temporary.unlink(missing_ok=True) + raise + return destination + + +def prepare(platforms=None, lock_path=LOCK_PATH, destination_dir=VENDOR_DIR): + """Prepare common third-party archives for selected platforms.""" + lock = load_lock(lock_path) + available = lock["platforms"] + selected = list(available) if platforms is None else platforms + unknown = sorted(set(selected) - set(available)) + if unknown: + raise ValueError(f"Unsupported platform: {', '.join(unknown)}") + + artifacts = list(lock["artifacts"].values()) + artifacts.extend(available[platform] for platform in selected) + return [ + download_artifact(artifact, destination_dir) + for artifact in artifacts + ] + + +def parse_args(argv=None): + """Parse command-line options.""" + parser = argparse.ArgumentParser( + description="Download and verify third-party files for SnapForm.", + ) + parser.add_argument( + "--platform", + action="append", + dest="platforms", + help="Prepare one platform; repeat as needed. Default: all platforms.", + ) + return parser.parse_args(argv) + + +def main(argv=None): + """Run vendor preparation from the command line.""" + args = parse_args(argv) + try: + paths = prepare(args.platforms) + except (OSError, RuntimeError, ValueError) as error: + print(f"Preparation failed: {error}", file=sys.stderr) + return 1 + print(f"Prepared {len(paths)} files") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/pyproject.toml b/pyproject.toml index 3484f15..cdbcc6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ license = "GPL-3.0-or-later" dependencies = [] [dependency-groups] -dev = ["pytest"] +dev = ["numpy", "pytest"] [tool.uv] package = false diff --git a/src/snapform/blender_manifest.toml b/src/snapform/blender_manifest.toml index 4db0bd5..0699e33 100644 --- a/src/snapform/blender_manifest.toml +++ b/src/snapform/blender_manifest.toml @@ -8,7 +8,7 @@ maintainer = "OmooLab" type = "add-on" blender_version_min = "4.5.0" -platforms = ["windows-x64", "macos-x64", "macos-arm64", "linux-x64"] +platforms = ["windows-x64", "macos-arm64", "linux-x64"] license = ["SPDX:GPL-3.0-or-later"] [permissions] diff --git a/src/snapform/bootstrap_runtime.py b/src/snapform/bootstrap_runtime.py index 18a42c0..143bd2b 100644 --- a/src/snapform/bootstrap_runtime.py +++ b/src/snapform/bootstrap_runtime.py @@ -1,4 +1,5 @@ import argparse +import filecmp import json import os import platform @@ -316,10 +317,10 @@ def install_stages(args, runtime_dir, python, uv): else effective_torch_index(args.backend) ) def archive_requirement(project_name, filename): - local = Path(args.runtime_dir).parent / "sources" / filename + local = Path(args.runtime_dir).parent / "vendor" / filename if not local.is_file(): raise RuntimeError( - "Bundled source archive is missing; reinstall the extension: " + "Bundled vendor artifact is missing; reinstall the extension: " f"{local}" ) return f"{project_name} @ {local.as_uri()}" @@ -539,29 +540,32 @@ def uv_download_target(): raise RuntimeError(f"Unsupported platform for uv download: {sys.platform}") -def bundled_sources_directory(): - return Path(__file__).with_name("sources") +def bundled_vendor_directory(): + packaged = Path(__file__).with_name("vendor") + if packaged.is_dir(): + return packaged + project_root = Path(__file__).resolve().parents[2] + if (project_root / "pyproject.toml").is_file(): + return project_root / "vendor" + return packaged def bundled_uv_archive(): target, archive = uv_download_target() - path = bundled_sources_directory() / f"uv-{target}.{archive}" + path = bundled_vendor_directory() / f"uv-{target}.{archive}" return path if path.is_file() else None -def sync_bundled_sources(bundled_dir, sources_dir): - """Copy bundled source archives into the storage sources directory.""" +def sync_bundled_vendor(bundled_dir, vendor_dir): + """Copy bundled third-party archives into persistent storage.""" for name in ("depth-anything-3.tar.gz", "ben2.tar.gz"): bundled = Path(bundled_dir) / name if not bundled.is_file(): continue - target = Path(sources_dir) / name - if ( - target.is_file() - and target.stat().st_size == bundled.stat().st_size - ): + target = Path(vendor_dir) / name + if target.is_file() and filecmp.cmp(bundled, target, shallow=False): continue - Path(sources_dir).mkdir(parents=True, exist_ok=True) + Path(vendor_dir).mkdir(parents=True, exist_ok=True) shutil.copy2(bundled, target) @@ -610,7 +614,7 @@ def install_uv( if bundled_uv is None: raise RuntimeError( "Bundled uv is missing; reinstall the extension matching your " - f"platform (searched {bundled_sources_directory()})" + f"platform (searched {bundled_vendor_directory()})" ) if not archive_is_valid(bundled_uv): raise RuntimeError(f"Bundled uv archive is invalid: {bundled_uv}") @@ -716,9 +720,9 @@ def install(args): uv = Path(resolved) if resolved else uv uv_missing = not uv.is_file() python = environment_python(runtime_dir) - sync_bundled_sources( - bundled_sources_directory(), - Path(args.runtime_dir).parent / "sources", + sync_bundled_vendor( + bundled_vendor_directory(), + Path(args.runtime_dir).parent / "vendor", ) stages = install_stages(args, runtime_dir, python, uv) runtime_stages = [stage for stage in stages if stage[3] != "download"] diff --git a/tests/test_bootstrap_runtime.py b/tests/test_bootstrap_runtime.py index c8a6c91..ada2b21 100644 --- a/tests/test_bootstrap_runtime.py +++ b/tests/test_bootstrap_runtime.py @@ -27,12 +27,12 @@ def load_bootstrap_runtime(): class BootstrapRuntimeTest(unittest.TestCase): - def create_bundled_sources(self, root): - sources = root / "sources" - sources.mkdir() - (sources / "depth-anything-3.tar.gz").write_bytes(b"da3") - (sources / "ben2.tar.gz").write_bytes(b"ben2") - return sources + def create_bundled_vendor(self, root): + vendor = root / "vendor" + vendor.mkdir() + (vendor / "depth-anything-3.tar.gz").write_bytes(b"da3") + (vendor / "ben2.tar.gz").write_bytes(b"ben2") + return vendor def test_installs_da3_from_archives_without_gsplat(self): bootstrap = load_bootstrap_runtime() @@ -40,11 +40,11 @@ class BootstrapRuntimeTest(unittest.TestCase): root = Path(directory) runtime_dir = root / "runtime" runtime_dir.mkdir() - self.create_bundled_sources(root) + self.create_bundled_vendor(root) da3_uri = ( - root / "sources" / "depth-anything-3.tar.gz" + root / "vendor" / "depth-anything-3.tar.gz" ).as_uri() - ben2_uri = (root / "sources" / "ben2.tar.gz").as_uri() + ben2_uri = (root / "vendor" / "ben2.tar.gz").as_uri() uv_path = root / "uv" uv_path.write_bytes(b"uv") args = SimpleNamespace( @@ -184,7 +184,7 @@ class BootstrapRuntimeTest(unittest.TestCase): root = Path(directory) runtime_dir = root / "runtime" runtime_dir.mkdir() - self.create_bundled_sources(root) + self.create_bundled_vendor(root) uv_path = root / "uv" uv_path.write_bytes(b"uv") args = SimpleNamespace( @@ -356,7 +356,7 @@ class BootstrapRuntimeTest(unittest.TestCase): root = Path(directory) runtime_dir = root / "runtime" runtime_dir.mkdir() - self.create_bundled_sources(root) + self.create_bundled_vendor(root) uv_path = root / "uv" uv_path.write_bytes(b"uv") args = SimpleNamespace( @@ -662,7 +662,7 @@ class BootstrapRuntimeTest(unittest.TestCase): root = Path(directory) runtime_dir = root / "runtime" runtime_dir.mkdir() - self.create_bundled_sources(root) + self.create_bundled_vendor(root) uv_path = root / "uv" uv_path.write_bytes(b"uv") args = SimpleNamespace( @@ -705,7 +705,7 @@ class BootstrapRuntimeTest(unittest.TestCase): root = Path(directory) runtime_dir = root / "runtime" runtime_dir.mkdir() - self.create_bundled_sources(root) + self.create_bundled_vendor(root) (runtime_dir / "partial-python.exe").write_text("junk") args = SimpleNamespace( uv="uv", @@ -738,7 +738,7 @@ class BootstrapRuntimeTest(unittest.TestCase): root = Path(directory) runtime_dir = root / "runtime" runtime_dir.mkdir(parents=True) - self.create_bundled_sources(root) + self.create_bundled_vendor(root) (runtime_dir / "Scripts").mkdir() (runtime_dir / "Scripts" / "python.exe").write_text("python") (runtime_dir / "snapform_ready.json").write_text( @@ -783,7 +783,7 @@ class BootstrapRuntimeTest(unittest.TestCase): root = Path(directory) runtime_dir = root / "runtime" runtime_dir.mkdir() - self.create_bundled_sources(root) + self.create_bundled_vendor(root) args = SimpleNamespace( uv="uv", runtime_dir=str(runtime_dir), @@ -823,7 +823,7 @@ class BootstrapRuntimeTest(unittest.TestCase): root = Path(directory) runtime_dir = root / "runtime" runtime_dir.mkdir() - self.create_bundled_sources(root) + self.create_bundled_vendor(root) uv_path = root / "uv" / "uv.exe" args = SimpleNamespace( uv=str(uv_path), @@ -858,7 +858,7 @@ class BootstrapRuntimeTest(unittest.TestCase): ): bootstrap.install(args) - def test_install_uses_bundled_sources_and_uv_without_network(self): + def test_install_uses_bundled_vendor_and_uv_without_network(self): bootstrap = load_bootstrap_runtime() with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -911,7 +911,7 @@ class BootstrapRuntimeTest(unittest.TestCase): with patch.object( bootstrap, - "bundled_sources_directory", + "bundled_vendor_directory", return_value=bundled, ), patch.object( bootstrap, @@ -946,8 +946,8 @@ class BootstrapRuntimeTest(unittest.TestCase): for command in install_commands if any("depth-anything-3 @" in str(value) for value in command) ) - da3_uri = (root / "sources" / "depth-anything-3.tar.gz").as_uri() - ben2_uri = (root / "sources" / "ben2.tar.gz").as_uri() + da3_uri = (root / "vendor" / "depth-anything-3.tar.gz").as_uri() + ben2_uri = (root / "vendor" / "ben2.tar.gz").as_uri() self.assertIn(f"depth-anything-3 @ {da3_uri}", ai_command) self.assertIn(f"ben2 @ {ben2_uri}", ai_command) @@ -957,7 +957,7 @@ class BootstrapRuntimeTest(unittest.TestCase): root = Path(directory) runtime_dir = root / "runtime" runtime_dir.mkdir() - self.create_bundled_sources(root) + self.create_bundled_vendor(root) uv_path = root / "uv.exe" uv_path.write_bytes(b"uv") args = SimpleNamespace( @@ -995,7 +995,7 @@ class BootstrapRuntimeTest(unittest.TestCase): self.assertEqual(uv_path.read_bytes(), b"binary") - def test_sync_bundled_sources_copies_missing_archives(self): + def test_sync_bundled_vendor_copies_missing_archives(self): bootstrap = load_bootstrap_runtime() with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -1003,29 +1003,48 @@ class BootstrapRuntimeTest(unittest.TestCase): bundled.mkdir() (bundled / "depth-anything-3.tar.gz").write_bytes(b"da3") (bundled / "ben2.tar.gz").write_bytes(b"ben2") - sources = root / "sources" + vendor = root / "vendor" - bootstrap.sync_bundled_sources(bundled, sources) + bootstrap.sync_bundled_vendor(bundled, vendor) self.assertEqual( - (sources / "depth-anything-3.tar.gz").read_bytes(), + (vendor / "depth-anything-3.tar.gz").read_bytes(), b"da3", ) self.assertEqual( - (sources / "ben2.tar.gz").read_bytes(), + (vendor / "ben2.tar.gz").read_bytes(), b"ben2", ) + def test_sync_bundled_vendor_replaces_same_size_stale_archive(self): + bootstrap = load_bootstrap_runtime() + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + bundled = root / "bundled" + bundled.mkdir() + (bundled / "depth-anything-3.tar.gz").write_bytes(b"new") + (bundled / "ben2.tar.gz").write_bytes(b"ben2") + vendor = root / "vendor" + vendor.mkdir() + (vendor / "depth-anything-3.tar.gz").write_bytes(b"old") + + bootstrap.sync_bundled_vendor(bundled, vendor) + + self.assertEqual( + (vendor / "depth-anything-3.tar.gz").read_bytes(), + b"new", + ) + def test_install_stages_uses_local_source_archives(self): bootstrap = load_bootstrap_runtime() with tempfile.TemporaryDirectory() as directory: root = Path(directory) runtime_dir = root / "runtime" runtime_dir.mkdir() - sources = root / "sources" - sources.mkdir() - (sources / "depth-anything-3.tar.gz").write_bytes(b"da3") - (sources / "ben2.tar.gz").write_bytes(b"ben2") + vendor = root / "vendor" + vendor.mkdir() + (vendor / "depth-anything-3.tar.gz").write_bytes(b"da3") + (vendor / "ben2.tar.gz").write_bytes(b"ben2") uv_path = root / "uv" uv_path.write_bytes(b"uv") args = SimpleNamespace( @@ -1051,8 +1070,8 @@ class BootstrapRuntimeTest(unittest.TestCase): if kind == "install" and any("depth-anything-3" in str(value) for value in command) ) - da3_local = (sources / "depth-anything-3.tar.gz").as_uri() - ben2_local = (sources / "ben2.tar.gz").as_uri() + da3_local = (vendor / "depth-anything-3.tar.gz").as_uri() + ben2_local = (vendor / "ben2.tar.gz").as_uri() self.assertIn(f"depth-anything-3 @ {da3_local}", ai_command) self.assertIn(f"ben2 @ {ben2_local}", ai_command) @@ -1062,10 +1081,10 @@ class BootstrapRuntimeTest(unittest.TestCase): root = Path(directory) runtime_dir = root / "runtime" runtime_dir.mkdir() - sources = root / "sources" - sources.mkdir() - (sources / "depth-anything-3.tar.gz").write_bytes(b"da3") - (sources / "ben2.tar.gz").write_bytes(b"ben2") + vendor = root / "vendor" + vendor.mkdir() + (vendor / "depth-anything-3.tar.gz").write_bytes(b"da3") + (vendor / "ben2.tar.gz").write_bytes(b"ben2") uv_path = root / "uv" uv_path.write_bytes(b"uv") args = SimpleNamespace( @@ -1100,7 +1119,7 @@ class BootstrapRuntimeTest(unittest.TestCase): root = Path(directory) runtime_dir = root / "runtime" runtime_dir.mkdir() - self.create_bundled_sources(root) + self.create_bundled_vendor(root) uv_path = root / "uv" uv_path.write_bytes(b"uv") args = SimpleNamespace( @@ -1213,7 +1232,7 @@ class BootstrapRuntimeTest(unittest.TestCase): tar.addfile(info, io.BytesIO(content)) with patch.object( bootstrap, - "bundled_sources_directory", + "bundled_vendor_directory", return_value=bundled, ): bootstrap.install_uv(uv_path, str(root / "status.json")) @@ -1229,7 +1248,7 @@ class BootstrapRuntimeTest(unittest.TestCase): bundled.mkdir() with patch.object( bootstrap, - "bundled_sources_directory", + "bundled_vendor_directory", return_value=bundled, ): with self.assertRaisesRegex( diff --git a/tests/test_pack.py b/tests/test_pack.py index 7cd3f98..8b0c14c 100644 --- a/tests/test_pack.py +++ b/tests/test_pack.py @@ -3,6 +3,7 @@ import importlib.util import io import tarfile import tempfile +import tomllib import unittest from pathlib import Path from zipfile import ZipFile @@ -32,13 +33,16 @@ class PackTest(unittest.TestCase): finally: pack.DIST_DIR = original_dist + version = tomllib.loads( + (pack.SOURCE_DIR / "blender_manifest.toml").read_text( + encoding="utf-8" + ) + )["version"] self.assertEqual( {path.name for path in output_paths}, { - "SnapForm.v0.10.10.windows-x64.zip", - "SnapForm.v0.10.10.macos-x64.zip", - "SnapForm.v0.10.10.macos-arm64.zip", - "SnapForm.v0.10.10.linux-x64.zip", + f"SnapForm.v{version}.{platform}.zip" + for platform in pack.PLATFORMS }, ) for output_path in output_paths: @@ -56,12 +60,24 @@ class PackTest(unittest.TestCase): self.assertIn("ai_host.py", names) self.assertNotIn("snapform/__init__.py", names) self.assertNotIn("importer.py", names) - self.assertIn("sources/depth-anything-3.tar.gz", names) - self.assertIn("sources/ben2.tar.gz", names) + self.assertIn("vendor/depth-anything-3.tar.gz", names) + self.assertIn("vendor/ben2.tar.gz", names) self.assertIn( - f"sources/{pack.PLATFORMS[platform]}", + f"vendor/{pack.PLATFORMS[platform]}", names, ) + self.assertFalse( + any(name.startswith("sources/") for name in names) + ) + bundled_uv = { + name + for name in names + if name.startswith("vendor/uv-") + } + self.assertEqual( + bundled_uv, + {f"vendor/{pack.PLATFORMS[platform]}"}, + ) self.assertIn(f'platforms = ["{platform}"]', manifest) self.assertNotIn( "windows-x64" @@ -75,11 +91,24 @@ class PackTest(unittest.TestCase): with self.assertRaisesRegex(ValueError, "Unsupported platform"): pack.build_extension("windows-arm64") + def test_platforms_match_manifest(self): + pack = load_pack() + manifest = tomllib.loads( + (pack.SOURCE_DIR / "blender_manifest.toml").read_text( + encoding="utf-8" + ) + ) + self.assertEqual(set(pack.PLATFORMS), set(manifest["platforms"])) + self.assertEqual( + set(pack.PLATFORMS), + {"windows-x64", "macos-arm64", "linux-x64"}, + ) + def test_bundled_depth_anything_3_has_no_xformers_dependency(self): pack = load_pack() with tarfile.open( fileobj=io.BytesIO( - pack.bundled_source_bytes("depth-anything-3.tar.gz") + pack.bundled_artifact_bytes("depth-anything-3.tar.gz") ), mode="r:gz", ) as archive: @@ -95,7 +124,7 @@ class PackTest(unittest.TestCase): pack = load_pack() with tarfile.open( fileobj=io.BytesIO( - pack.bundled_source_bytes("depth-anything-3.tar.gz") + pack.bundled_artifact_bytes("depth-anything-3.tar.gz") ), mode="r:gz", ) as archive: @@ -116,7 +145,7 @@ class PackTest(unittest.TestCase): def test_official_depth_anything_3_baseline_is_pristine(self): pack = load_pack() - archive_path = pack.SOURCES_DIR / "depth-anything-3.tar.gz" + archive_path = pack.VENDOR_DIR / "depth-anything-3.tar.gz" with tarfile.open(archive_path, "r:gz") as archive: raw_pyproject = archive.extractfile( "Depth-Anything-3-3d835ec1a5802d64a8b8b15f817a1ab54809bfe4/" @@ -138,7 +167,7 @@ class PackTest(unittest.TestCase): pack = load_pack() with tarfile.open( fileobj=io.BytesIO( - pack.bundled_source_bytes("depth-anything-3.tar.gz") + pack.bundled_artifact_bytes("depth-anything-3.tar.gz") ), mode="r:gz", ) as archive: @@ -152,7 +181,7 @@ class PackTest(unittest.TestCase): def test_official_depth_anything_3_ray_utils_is_pristine(self): pack = load_pack() - archive_path = pack.SOURCES_DIR / "depth-anything-3.tar.gz" + archive_path = pack.VENDOR_DIR / "depth-anything-3.tar.gz" with tarfile.open(archive_path, "r:gz") as archive: source = archive.extractfile( "Depth-Anything-3-3d835ec1a5802d64a8b8b15f817a1ab54809bfe4/" diff --git a/tests/test_prepare_vendor.py b/tests/test_prepare_vendor.py new file mode 100644 index 0000000..fd8ea24 --- /dev/null +++ b/tests/test_prepare_vendor.py @@ -0,0 +1,67 @@ +import hashlib +import importlib.util +import io +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def load_prepare_vendor(): + specification = importlib.util.spec_from_file_location( + "snapform_prepare_vendor", + PROJECT_ROOT / "prepare_vendor.py", + ) + module = importlib.util.module_from_spec(specification) + specification.loader.exec_module(module) + return module + + +class PrepareVendorTest(unittest.TestCase): + def test_downloads_and_verifies_artifact(self): + prepare = load_prepare_vendor() + content = b"locked artifact" + artifact = { + "filename": "artifact.tar.gz", + "url": "https://example.invalid/artifact.tar.gz", + "sha256": hashlib.sha256(content).hexdigest(), + } + with tempfile.TemporaryDirectory() as directory, patch.object( + prepare.urllib.request, + "urlopen", + return_value=io.BytesIO(content), + ): + path = prepare.download_artifact(artifact, Path(directory)) + self.assertEqual(path.read_bytes(), content) + + def test_rejects_hash_mismatch_without_leaving_partial_file(self): + prepare = load_prepare_vendor() + artifact = { + "filename": "artifact.tar.gz", + "url": "https://example.invalid/artifact.tar.gz", + "sha256": hashlib.sha256(b"expected").hexdigest(), + } + with tempfile.TemporaryDirectory() as directory, patch.object( + prepare.urllib.request, + "urlopen", + return_value=io.BytesIO(b"unexpected"), + ): + destination = Path(directory) + with self.assertRaisesRegex(RuntimeError, "SHA-256 mismatch"): + prepare.download_artifact(artifact, destination) + self.assertFalse((destination / artifact["filename"]).exists()) + self.assertFalse( + (destination / f"{artifact['filename']}.part").exists() + ) + + def test_rejects_unknown_platform(self): + prepare = load_prepare_vendor() + with self.assertRaisesRegex(ValueError, "Unsupported platform"): + prepare.prepare(["windows-arm64"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock index bea7574..5304761 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 3 requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version < '3.12'", +] [[package]] name = "colorama" @@ -20,6 +24,142 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" }, + { url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" }, + { url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" }, + { url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" }, + { url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" }, + { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" }, + { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" }, + { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" }, + { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" }, + { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" }, + { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" }, + { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" }, + { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" }, + { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" }, + { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" }, + { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" }, + { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" }, + { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, +] + [[package]] name = "packaging" version = "26.3" @@ -70,10 +210,15 @@ source = { virtual = "." } [package.dev-dependencies] dev = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "pytest" }, ] [package.metadata] [package.metadata.requires-dev] -dev = [{ name = "pytest" }] +dev = [ + { name = "numpy" }, + { name = "pytest" }, +] diff --git a/vendor.lock.toml b/vendor.lock.toml new file mode 100644 index 0000000..a74fec4 --- /dev/null +++ b/vendor.lock.toml @@ -0,0 +1,32 @@ +schema_version = 1 + +[artifacts.depth-anything-3] +revision = "3d835ec1a5802d64a8b8b15f817a1ab54809bfe4" +filename = "depth-anything-3.tar.gz" +url = "https://codeload.github.com/ByteDance-Seed/Depth-Anything-3/tar.gz/3d835ec1a5802d64a8b8b15f817a1ab54809bfe4" +sha256 = "98aa2dd53ab44b96cef5190ae4841c6ae51797d099b784e08e12a1a55bd3a69b" +patches = "depth-anything-3" + +[artifacts.ben2] +revision = "2c99a5da477b5523585bfa5c893888a6e818a8f6" +filename = "ben2.tar.gz" +url = "https://codeload.github.com/PramaLLC/BEN2/tar.gz/2c99a5da477b5523585bfa5c893888a6e818a8f6" +sha256 = "01fd26716f3ff3a3f18ad2336c768af5b759edfc7c38bb3bf733b7c86eee6282" + +[uv] +version = "0.11.3" + +[platforms.windows-x64] +filename = "uv-x86_64-pc-windows-msvc.zip" +url = "https://github.com/astral-sh/uv/releases/download/0.11.3/uv-x86_64-pc-windows-msvc.zip" +sha256 = "ae681c0aaec7cc96af184648cb88d73f8393ed60fa5880abdd6bdb910f9b227c" + +[platforms.macos-arm64] +filename = "uv-aarch64-apple-darwin.tar.gz" +url = "https://github.com/astral-sh/uv/releases/download/0.11.3/uv-aarch64-apple-darwin.tar.gz" +sha256 = "2bc3d0c7bf2bd08325b1e170abac6f7e5b3346e1d4eab3370d17cefec934996f" + +[platforms.linux-x64] +filename = "uv-x86_64-unknown-linux-gnu.tar.gz" +url = "https://github.com/astral-sh/uv/releases/download/0.11.3/uv-x86_64-unknown-linux-gnu.tar.gz" +sha256 = "c0f3236f146e55472663cfbcc9be3042a9f1092275bbe3fe2a56a6cbfd3da5ce"