diff --git a/README.md b/README.md index 6ac17e9..482b2f5 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,8 @@ SnapForm 是一个 Blender 扩展,可以把一张照片、一段视频或一 - Linux:`SnapForm.v0.10.10.linux-x64.zip` 2. 打开 Blender,进入 `Preferences > Extensions` 3. 点击 `Install from Disk`,选择下载的压缩包 -4. 在扩展偏好设置中确认 `uv` 路径与 Torch 后端(保持默认即可);如果下载经常超时, - 可以在 `PyPI Index` 里填一个国内镜像,例如 `https://pypi.tuna.tsinghua.edu.cn/simple` +4. 在扩展偏好设置中确认 Storage Root 与 Device(保持默认即可);Device 默认取本机 + 最快的 CUDA/MPS,否则 CPU。下载超时会自动重试国内镜像,无需手动配置 5. 打开 3D 视图右侧的 `SnapForm` 面板,点击 `Install Runtime` DA3、BEN2 源码包和 uv 已内置在扩展里,安装时直接从本地解包,不需要访问 GitHub。首次使用仍会自动 diff --git a/docs/technical.md b/docs/technical.md index 2378dc3..28612db 100644 --- a/docs/technical.md +++ b/docs/technical.md @@ -50,18 +50,21 @@ `linux-x64` 四个平台的 Extension ZIP(如 `SnapForm.v0.10.10.macos-arm64.zip`)。 每个包只含对应平台的 uv 二进制,并内置 DA3、BEN2 源码包。 2. 在 Blender 的 `Preferences > Extensions` 中选择 `Install from Disk`。 -3. 在 Extension Preferences 中确认 `uv` 路径和 Torch Backend。 +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 Small`、`DA3 Base` 和 `BEN2 Base` 模型。安装按 8 个阶段依次推进,每段进度 从 0 到 100%:解包内置 uv、创建 Python Runtime、下载 PyTorch、安装 xFormers(macOS 跳过, 内置 DA3 源码包为官方原版并已移除 xformers 硬依赖,自动回退纯 PyTorch 实现)、安装 AI 依赖, - 然后是三个模型的下载。进度文字统一为 `阶段/总数 阶段名: 状态`, + 然后是三个模型的下载。安装完成后进入 `验证 Runtime` 阶段,会导入 Torch 与全部 AI 依赖; + macOS 首次导入 Torch 会初始化 Metal 后端(编译 shader 缓存),该阶段可能持续较久,状态栏 + 会显示已用时间而不是卡住。进度文字统一为 `阶段/总数 阶段名: 状态`, 模型下载阶段会实时显示下载速度,例如 `6/8 da3 small: downloading 14.2MB/s`。 Runtime 就绪后即使模型下载中断也不影响 Host 使用,可稍后在 `Models` 面板单独补下。 - 安装不做启动网络预检:若网络无法稳定访问 pypi.org,可在扩展偏好设置的 `PyPI Index` - 中指定镜像源(如清华、阿里),该地址会通过 `--pypi-index` 传给 bootstrap 并优先使用; - 未指定时,pip 安装失败会自动重试一次清华镜像,模型下载由 worker 自行探测回退到 HF 镜像。 + 安装不做启动网络预检:pip 安装失败会自动依次重试清华、阿里镜像,模型下载由 worker + 自行探测回退到 HF 镜像;需要手动指定镜像时可通过 `PIP_INDEX_URL` 环境变量覆盖。 5. 在 3D View 按 `N`,打开 `SnapForm` 页签。 6. 如需其他模型,展开 `Host > Models`,找到模型并点击该行的 `Download`。 7. 展开默认收起的 `Debug`,选择 `Export Format` 和本次执行使用的模型。 @@ -85,7 +88,7 @@ 状态和原始内容。BEN2 的 `foreground.png` 也会保留在对应 job 目录中。 Image Empty 引用视频或序列帧时,Remove Background 直接执行,默认取 Empty 的 Image Frames -数值(未设置时为完整帧数),起始帧跟随 Empty 的 Offset;若 Offset 加 Frames 超出视频长度, +数值(未设置时为完整帧数,显式设为 1 时只处理 1 帧),起始帧跟随 Empty 的 Offset;若 Offset 加 Frames 超出视频长度, 会把窗口整体收进视频内(保证实际处理 Frames 帧数),Offset 过小时从第 1 帧开始。输出 `foreground.0001.png` 等透明序列帧(帧号从 0001 开始,与 Blender 场景帧一一对应),并把 源帧以 `frame.0001.jpg` 等命名复制到 job 目录根下供材质颜色导入。替换 Empty 的 Image 为 @@ -115,7 +118,7 @@ Cyclic 沿用原 Empty 的设置。序列帧无法被 Blender 整体 Pack,因 `img.foreground` 与 `img.depth`,不会生成 `img.foreground.depth`。Original 仍用原图作为 位移来源,不改变原有深度逻辑。 Image Empty 引用视频或序列帧时,AI Generated 会按 Empty 的 Image Frames 数值逐帧估计 - 深度(未设置时为完整帧数),起始帧跟随 Empty 的 Offset,与 Remove Background 范围一致; + 深度(未设置时为完整帧数,显式设为 1 时只处理 1 帧),起始帧跟随 Empty 的 Offset,与 Remove Background 范围一致; Offset 超出视频长度时同样把窗口收进视频内。材质节点与置换贴图的 Start 保持与原 Empty 一致;Original 模式(无 Remove Background)时,单帧会复制原图改名为 `.depth` 供位移使用, 视频或序列帧则抽帧保存为 job 目录根下的 `frame.0001.jpg` 等,并作为 `.depth` 序列使用。 @@ -307,8 +310,12 @@ Windows 上代理若只在系统设置里而未写入环境变量,安装程序 并核对覆盖文件是否仍适用 - `addict` 2.4.0(补充 DA3 上游包元数据暂未声明的运行依赖) -默认 CUDA Backend 为 CUDA 12.8,也可以选择 CPU。CPU 可以用于验证,但实际推理 -通常很慢。 +安装 Runtime 时会自动选择 PyTorch 后端:检测到 NVIDIA 驱动则安装 CUDA 12.8 版本,否则安装 +CPU 版;macOS 始终安装 CPU 版 PyTorch(MPS 包含其中)。CPU 可以用于验证,但实际推理通常很慢。 +Extension Preferences 的 `Device` 决定正式功能的推理设备,默认取本机最快的 CUDA/MPS,否则 +CPU;Debug 面板的 `Debug Device` 只影响 Debug 任务。两处设备选项都只列出本机支持的设备: +Windows 不显示 MPS,机器上没有可用 NVIDIA 驱动时不显示 CUDA。Runtime 安装完成时会检测真实 +可用的设备并写入 `snapform_ready.json`,设备列表以该检测结果为准。 CUDA 12.8 的 PyTorch wheel 自带 CUDA 运行库,无需安装 CUDA Toolkit;只需要 NVIDIA 驱动 ≥ 570(数据中心显卡可用更老驱动)。驱动不满足或没有 NVIDIA 显卡时, 设备会自动回退到 CPU(macOS 为 MPS),只有显式选择 CUDA 才会报错。 @@ -352,6 +359,9 @@ Runtime、模型和任务结果默认保存在用户主目录的 `~/.snapform` └── host.lock # Host 运行时持有 ``` +`models/` 下每个模型一个文件夹,命名统一带前缀:DA3 为 `da3_*`(如 `da3_small`、`da3_base`), +BEN2 为 `ben2_*`(如 `ben2_base`)。旧版无前缀的 DA3 文件夹会在首次使用时自动改名为新名称。 + 不同 Blender 版本安装相同 Extension 后会共用这个目录。也可以在 Extension Preferences 的 `Storage Root` 中修改共享位置。不要把模型写入 Extension 安装目录, 因为该目录可能只读,并且升级时会被替换。不要同时从多个 Blender 实例执行 @@ -360,7 +370,7 @@ Preferences 的 `Storage Root` 中修改共享位置。不要把模型写入 Ext ## 开发 ```bash -uv run python -m unittest discover -s tests -v +uv run pytest tests -v uv run python pack.py ``` diff --git a/pyproject.toml b/pyproject.toml index 8f2755f..c06bce8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,5 +7,8 @@ requires-python = ">=3.11" license = "GPL-3.0-or-later" dependencies = [] +[dependency-groups] +dev = ["pytest"] + [tool.uv] package = false diff --git a/src/snapform/ai_host.py b/src/snapform/ai_host.py index 7f7a062..82b3f0f 100644 --- a/src/snapform/ai_host.py +++ b/src/snapform/ai_host.py @@ -147,7 +147,7 @@ class ModelCache: Path(self.ben2_key[0]).name if self.ben2_key is not None else "" ), "da3": ( - f"da3_{Path(self.da3_key[0]).name}" + Path(self.da3_key[0]).name if self.da3_key is not None else "" ), diff --git a/src/snapform/bootstrap_runtime.py b/src/snapform/bootstrap_runtime.py index 62d1416..4d27786 100644 --- a/src/snapform/bootstrap_runtime.py +++ b/src/snapform/bootstrap_runtime.py @@ -50,11 +50,15 @@ PLAIN_SLICE_SPAN = 0.02 RAMP_RATE = 0.02 CREEP_CAP = 0.60 CHECK_CODE = ( - "import torch; " + "import json; import torch; " "from depth_anything_3.api import DepthAnything3; " "from ben2 import BEN_Base; " "import fastapi, uvicorn; " - "print(torch.__version__, torch.cuda.is_available())" + "mps = getattr(torch.backends, 'mps', None); " + "print('SNAPFORM_DEVICES=' + json.dumps({" + "'torch': torch.__version__, " + "'cuda': torch.cuda.is_available(), " + "'mps': bool(mps is not None and mps.is_available())}))" ) @@ -373,7 +377,7 @@ def install_stages(args, runtime_dir, python, uv): "--repository", model.repository, "--model-dir", - models_dir / model.key.lower(), + models_dir / model.directory_name, "--status", args.status, ] @@ -405,6 +409,70 @@ def verify_command(python): return [python, "-c", CHECK_CODE] +def run_verify( + python, + status_path, + start=VERIFY_SLICE_START, + end=VERIFY_SLICE_END, + clock=time.monotonic, +): + """Run the runtime import check with heartbeat progress; return the device report.""" + command = verify_command(python) + print("Running:", subprocess.list2cmdline([str(value) for value in command])) + process = subprocess.Popen( + [str(value) for value in command], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="replace", + ) + output_queue = queue.Queue() + + def read_output(): + for raw_line in process.stdout: + output_queue.put(raw_line) + output_queue.put(None) + + threading.Thread(target=read_output, daemon=True).start() + + report = {} + started = clock() + last_heartbeat = 0.0 + while True: + try: + raw_line = output_queue.get(timeout=0.2) + empty = False + except queue.Empty: + raw_line = None + empty = True + if raw_line is None and not empty: + break + if empty: + elapsed = clock() - started + if elapsed - last_heartbeat >= 5.0: + last_heartbeat = elapsed + creep = min(elapsed / 60.0, CREEP_CAP) + progress = start + creep * (end - start) + write_status( + status_path, + progress, + f"verifying runtime {format_elapsed(elapsed)}", + ) + continue + line = raw_line.rstrip() + print(line) + if line.startswith("SNAPFORM_DEVICES="): + try: + report = json.loads(line[len("SNAPFORM_DEVICES=") :]) + except json.JSONDecodeError: + report = {} + return_code = process.wait() + if return_code != 0: + raise subprocess.CalledProcessError(return_code, command) + return report + + def uv_download_target(): machine = platform.machine().lower() if sys.platform == "win32": @@ -643,9 +711,15 @@ def install(args): installed_packages.extend(stage_installed) runtime_offset = stage_end write_status(args.status, VERIFY_SLICE_START, "verifying runtime") - run(verify_command(python)) + device_report = run_verify(python, args.status) write_status(args.status, VERIFY_SLICE_END, "Runtime is ready") + devices = [] + if device_report.get("cuda"): + devices.append("cuda") + if device_report.get("mps"): + devices.append("mps") + devices.append("cpu") runtime_dir.mkdir(parents=True, exist_ok=True) ready_path = runtime_dir / "snapform_ready.json" ready_path.write_text( @@ -658,6 +732,7 @@ def install(args): "fastapi": FASTAPI_VERSION, "uvicorn": UVICORN_VERSION, "backend": args.backend, + "devices": devices, "da3_commit": args.da3_commit, "ben2_commit": args.ben2_commit, "packages": sorted(set(installed_packages)), diff --git a/src/snapform/image_depth.py b/src/snapform/image_depth.py index 6228d53..214ddb7 100644 --- a/src/snapform/image_depth.py +++ b/src/snapform/image_depth.py @@ -97,7 +97,7 @@ def extract_frame_range(source_object): ) frame_count = ( min(image_frames, total_frames) - if image_frames > 1 + if image_frames >= 1 else total_frames ) start_frame = min(1 + frame_offset, total_frames - frame_count + 1) @@ -1400,12 +1400,14 @@ class RemoveImageBackground(bpy.types.Operator): @classmethod def poll(cls, context): from .operators import active_job - from .runtime import host_busy + from .runtime import host_busy, model_ready + from .models import BEN2_MODEL return ( is_image_empty(context.object) and active_job() is None and not host_busy() + and model_ready(BEN2_MODEL.key) ) def execute(self, context): @@ -1511,12 +1513,14 @@ class ConvertImageToDepthPlane(bpy.types.Operator): @classmethod def poll(cls, context): from .operators import active_job - from .runtime import host_busy + from .runtime import host_busy, model_ready + from .models import MODELS return ( is_image_empty(context.object) and active_job() is None and not host_busy() + and any(model_ready(model_key) for model_key in MODELS) ) def invoke(self, context, _event): @@ -1535,6 +1539,9 @@ class ConvertImageToDepthPlane(bpy.types.Operator): return context.window_manager.invoke_props_dialog(self, width=340) def draw(self, context): + from .models import BEN2_MODEL + from .runtime import model_ready + layout = self.layout layout.prop(self, "snapform_depth_source") if self.snapform_depth_source == "ESTIMATE": @@ -1563,7 +1570,9 @@ class ConvertImageToDepthPlane(bpy.types.Operator): if settings_body is not None: settings_body.prop(self, "snapform_displacement_via") settings_body.prop(self, "snapform_subdivision_level") - settings_body.prop(self, "snapform_remove_background") + remove_background_row = settings_body.row() + remove_background_row.enabled = model_ready(BEN2_MODEL.key) + remove_background_row.prop(self, "snapform_remove_background") settings_body.prop(self, "snapform_unshaded") layout.prop(self, "snapform_delete_original") diff --git a/src/snapform/models.py b/src/snapform/models.py index 2f438bf..6b8fd67 100644 --- a/src/snapform/models.py +++ b/src/snapform/models.py @@ -5,6 +5,7 @@ from dataclasses import dataclass class ModelSpec: key: str label: str + directory_name: str repository: str license_name: str description: str @@ -16,6 +17,7 @@ MODELS = { "SMALL": ModelSpec( key="SMALL", label="DA3 Small", + directory_name="da3_small", repository="depth-anything/DA3-SMALL", license_name="Apache 2.0", description="Fastest any-view model with Apache 2.0 weights", @@ -24,6 +26,7 @@ MODELS = { "BASE": ModelSpec( key="BASE", label="DA3 Base", + directory_name="da3_base", repository="depth-anything/DA3-BASE", license_name="Apache 2.0", description="Balanced any-view model with Apache 2.0 weights", @@ -32,6 +35,7 @@ MODELS = { "LARGE_1_1": ModelSpec( key="LARGE_1_1", label="DA3 Large 1.1", + directory_name="da3_large_1_1", repository="depth-anything/DA3-LARGE-1.1", license_name="CC BY-NC 4.0", description="Higher quality non-commercial model with larger VRAM use", @@ -40,6 +44,7 @@ MODELS = { "GIANT_1_1": ModelSpec( key="GIANT_1_1", label="DA3 Giant 1.1", + directory_name="da3_giant_1_1", repository="depth-anything/DA3-GIANT-1.1", license_name="CC BY-NC 4.0", description="Non-commercial Giant model with Gaussian head", @@ -48,6 +53,7 @@ MODELS = { "NESTED_GIANT_LARGE_1_1": ModelSpec( key="NESTED_GIANT_LARGE_1_1", label="DA3 Nested Giant Large 1.1", + directory_name="da3_nested_giant_large_1_1", repository="depth-anything/DA3NESTED-GIANT-LARGE-1.1", license_name="CC BY-NC 4.0", description="Non-commercial nested model with metric depth and Gaussian head", @@ -58,6 +64,7 @@ MODELS = { BEN2_MODEL = ModelSpec( key="BEN2_BASE", label="BEN2 Base", + directory_name="ben2_base", repository="PramaLLC/BEN2", license_name="MIT", description="Fast high-quality background removal model", @@ -142,3 +149,8 @@ def get_downloadable_model(model_key): return DOWNLOADABLE_MODELS[model_key] except KeyError as error: raise ValueError(f"Unknown model: {model_key}") from error + + +def model_directory_name(model_key): + """Return the model's folder name inside the shared models directory.""" + return get_downloadable_model(model_key).directory_name diff --git a/src/snapform/operators.py b/src/snapform/operators.py index 6599dad..c6f0dce 100644 --- a/src/snapform/operators.py +++ b/src/snapform/operators.py @@ -337,6 +337,7 @@ class RunJob(bpy.types.Operator): paths["result"], BEN2_MODEL.key, start_frame=start_frame, + device=self._task_device(settings), ), paths, ) @@ -398,10 +399,21 @@ class RunJob(bpy.types.Operator): self.job_type == IMAGE_DEPTH_JOB and remove_background ), + device=self._task_device(settings), ), paths, ) + def _task_device(self, settings): + """Debug panels use the scene device; production tasks use Preferences.""" + if self.job_type in { + IMAGE_DEPTH_JOB, + IMAGE_BACKGROUND_JOB, + ORIGINAL_BACKGROUND_JOB, + }: + return runtime.production_device() + return settings.device + def _require_online_access(self): if not bpy.app.online_access: raise RuntimeError( diff --git a/src/snapform/preferences.py b/src/snapform/preferences.py index 3cc7d66..f81c949 100644 --- a/src/snapform/preferences.py +++ b/src/snapform/preferences.py @@ -1,9 +1,9 @@ -import shutil -import sys from pathlib import Path import bpy +from .runtime import device_enum_items + class SnapFormPreferences(bpy.types.AddonPreferences): bl_idname = __package__ @@ -17,31 +17,14 @@ class SnapFormPreferences(bpy.types.AddonPreferences): subtype="DIR_PATH", default=str(Path.home() / ".snapform"), ) - uv_executable: bpy.props.StringProperty( - name="uv Executable", + device: bpy.props.EnumProperty( + name="Device", description=( - "Path to uv; leave empty to auto-download a standalone uv " - "when it is missing" + "Inference device used by production tasks; only devices supported " + "on this machine are listed" ), - subtype="FILE_PATH", - default=shutil.which("uv") or "", - ) - torch_backend: bpy.props.EnumProperty( - name="Torch Backend", - description="PyTorch build installed into the isolated Runtime", - items=( - ("CU128", "CUDA 12.8", "NVIDIA CUDA 12.8 PyTorch wheels"), - ("CPU", "CPU", "CPU-only PyTorch wheels"), - ), - default="CPU" if sys.platform == "darwin" else "CU128", - ) - pypi_index_url: bpy.props.StringProperty( - name="PyPI Index", - description=( - "Package index for the isolated Runtime; leave empty to auto-detect " - "with mirror fallback. Example: https://pypi.tuna.tsinghua.edu.cn/simple" - ), - default="", + items=device_enum_items, + default=0, ) def draw(self, _context): @@ -50,9 +33,7 @@ class SnapFormPreferences(bpy.types.AddonPreferences): layout = self.layout layout.prop(self, "storage_root") - layout.prop(self, "uv_executable") - layout.prop(self, "torch_backend") - layout.prop(self, "pypi_index_url") + layout.prop(self, "device") layout.separator() layout.label(text=f"Runtime: {runtime_directory()}") if runtime_ready(): diff --git a/src/snapform/properties.py b/src/snapform/properties.py index 6ade35b..b894676 100644 --- a/src/snapform/properties.py +++ b/src/snapform/properties.py @@ -1,6 +1,7 @@ import bpy from .models import gs_model_enum_items, model_enum_items +from .runtime import device_enum_items class SnapFormSettings(bpy.types.PropertyGroup): @@ -18,7 +19,7 @@ class SnapFormSettings(bpy.types.PropertyGroup): name="Extract Frames", description="Maximum number of consecutive frames extracted from the start of a video or frame sequence", default=16, - min=2, + min=1, soft_max=64, ) model: bpy.props.EnumProperty( @@ -32,15 +33,13 @@ class SnapFormSettings(bpy.types.PropertyGroup): items=gs_model_enum_items, ) device: bpy.props.EnumProperty( - name="Device", - description="Inference device used by the external Runtime", - items=( - ("AUTO", "Auto", "Use CUDA, then MPS, otherwise CPU"), - ("CUDA", "CUDA", "Require an NVIDIA CUDA device"), - ("MPS", "MPS", "Use Apple Metal (MPS)"), - ("CPU", "CPU", "Run on CPU"), + name="Debug Device", + description=( + "Inference device used by Debug tasks only; production tasks use " + "the Device setting in the Add-on Preferences" ), - default="AUTO", + items=device_enum_items, + default=0, ) process_res: bpy.props.IntProperty( name="Process Res", diff --git a/src/snapform/runtime.py b/src/snapform/runtime.py index 561792e..44aab9f 100644 --- a/src/snapform/runtime.py +++ b/src/snapform/runtime.py @@ -1,5 +1,6 @@ import json import os +import platform import secrets import shutil import socket @@ -15,7 +16,12 @@ from urllib.request import ProxyHandler, Request, build_opener import bpy -from .models import BEN2_MODEL, get_downloadable_model, selected_model_key +from .models import ( + BEN2_MODEL, + get_downloadable_model, + model_directory_name, + selected_model_key, +) DA3_COMMIT = "3d835ec1a5802d64a8b8b15f817a1ab54809bfe4" @@ -107,7 +113,16 @@ def models_directory(): def model_directory(model_key): - return models_directory() / model_key.lower() + root = models_directory() + directory = root / model_directory_name(model_key) + legacy_directory = root / model_key.lower() + if ( + not directory.is_dir() + and legacy_directory.is_dir() + and legacy_directory != directory + ): + legacy_directory.rename(directory) + return directory def model_ready(model_key): @@ -117,6 +132,61 @@ def model_ready(model_key): ) +def runtime_devices(): + """Devices detected by the installed Runtime, or None when unknown.""" + ready_path = runtime_directory() / "snapform_ready.json" + if not ready_path.is_file(): + return None + try: + payload = json.loads(ready_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return None + devices = payload.get("devices") + if not isinstance(devices, list) or not devices: + return None + return [str(name).lower() for name in devices] + + +def machine_devices(): + """Devices the machine can run, fastest first; used before Runtime install.""" + devices = ["cpu"] + if shutil.which("nvidia-smi"): + devices.insert(0, "cuda") + elif sys.platform == "darwin" and platform.machine().lower() in { + "arm64", + "aarch64", + }: + devices.insert(0, "mps") + return devices + + +def supported_devices(): + """Inference devices available on this machine, fastest first.""" + return runtime_devices() or machine_devices() + + +DEVICE_LABELS = { + "cuda": ("CUDA", "NVIDIA CUDA"), + "mps": ("MPS", "Apple Metal (MPS)"), + "cpu": ("CPU", "CPU"), +} + + +def device_enum_items(_owner, _context): + """Blender EnumProperty items listing only devices supported on this machine.""" + return [ + (name.upper(), *DEVICE_LABELS[name], index) + for index, name in enumerate(supported_devices()) + ] + + +def production_device(): + """Device configured for production tasks, falling back to Auto.""" + addon_preferences = preferences() + configured = getattr(addon_preferences, "device", "") + return configured or "AUTO" + + def jobs_directory(): path = storage_root() / "jobs" path.mkdir(parents=True, exist_ok=True) @@ -153,13 +223,7 @@ def new_job_paths(job_type): def uv_executable(): - addon_preferences = preferences() - configured = getattr(addon_preferences, "uv_executable", "uv") - resolved = shutil.which(configured) - if resolved: - return resolved - if Path(configured).is_file(): - return str(Path(configured)) + """Return the bundled uv path inside the shared storage.""" return str(uv_cache_path()) @@ -171,9 +235,7 @@ def uv_cache_path(): def install_runtime_command(status_path): executable = uv_executable() - addon_preferences = preferences() - backend = getattr(addon_preferences, "torch_backend", "CU128") - pypi_index = getattr(addon_preferences, "pypi_index_url", "") or "" + backend = "cu128" if "cuda" in machine_devices() else "cpu" script = Path(__file__).with_name("bootstrap_runtime.py") command = [ sys.executable, @@ -185,7 +247,7 @@ def install_runtime_command(status_path): "--models-dir", str(models_directory()), "--backend", - backend.lower(), + backend, "--da3-commit", DA3_COMMIT, "--ben2-commit", @@ -193,8 +255,6 @@ def install_runtime_command(status_path): "--status", str(status_path), ] - if pypi_index: - command.extend(["--pypi-index", pypi_index]) return command @@ -640,6 +700,7 @@ def remove_background_request( model_key, video_frames=None, start_frame=1, + device=None, ): return HostJobRequest( { @@ -650,7 +711,7 @@ def remove_background_request( "model_dir": str(model_directory(model_key)), "output_dir": str(result_directory), "status": str(status_path), - "device": settings.device.lower(), + "device": (device or settings.device).lower(), "video_frames": ( settings.video_frames if video_frames is None @@ -701,6 +762,7 @@ def reconstruct_request( use_ray_pose=None, remove_background=False, start_frame=1, + device=None, ): snapform_selected_key = model_key or selected_model_key(settings, export_format) snapform_selected_process_res = ( @@ -732,7 +794,7 @@ def reconstruct_request( "gs_views_interval": settings.gs_views_interval, "video_frames": settings.video_frames, "start_frame": start_frame, - "device": settings.device.lower(), + "device": (device or settings.device).lower(), "use_ray_pose": 1 if snapform_selected_ray_pose else 0, "ref_view_strategy": settings.ref_view_strategy, "remove_background_model_dir": "", diff --git a/tests/test_addon.py b/tests/test_addon.py index 9f47880..87d082c 100644 --- a/tests/test_addon.py +++ b/tests/test_addon.py @@ -1356,6 +1356,104 @@ class AddonTest(unittest.TestCase): ["GIANT_1_1", "NESTED_GIANT_LARGE_1_1"], ) + def test_device_enums_list_only_supported_devices(self): + runtime = importlib.import_module("snapform.runtime") + with patch.object( + runtime, + "supported_devices", + return_value=["cuda", "cpu"], + ): + items = runtime.device_enum_items(None, None) + + self.assertEqual([item[0] for item in items], ["CUDA", "CPU"]) + self.assertEqual(items[0][2], "NVIDIA CUDA") + + def test_device_enums_default_to_the_fastest_supported_device(self): + for class_type in ( + self.addon.SnapFormSettings, + self.addon.SnapFormPreferences, + ): + with self.subTest(class_type=class_type.__name__): + self.assertEqual( + class_type.__annotations__["device"]["default"], + 0, + ) + + def test_preferences_expose_production_device(self): + preferences = self.addon.SnapFormPreferences + + self.assertIn("device", preferences.__annotations__) + items_callback = preferences.__annotations__["device"]["items"] + with patch.object( + self.addon.runtime, + "supported_devices", + return_value=["cpu"], + ): + items = items_callback(None, None) + self.assertEqual([item[0] for item in items], ["CPU"]) + + def test_remove_background_poll_requires_ben2_model(self): + context = SimpleNamespace( + object=SimpleNamespace( + type="EMPTY", + empty_display_type="IMAGE", + data=object(), + ) + ) + with patch.object( + self.addon.runtime, + "host_busy", + return_value=False, + ), patch.object( + self.addon.runtime, + "model_ready", + return_value=False, + ): + self.assertFalse(self.addon.RemoveImageBackground.poll(context)) + with patch.object( + self.addon.runtime, + "host_busy", + return_value=False, + ), patch.object( + self.addon.runtime, + "model_ready", + return_value=True, + ): + self.assertTrue(self.addon.RemoveImageBackground.poll(context)) + + def test_convert_to_depth_plane_poll_requires_da3_model(self): + context = SimpleNamespace( + object=SimpleNamespace( + type="EMPTY", + empty_display_type="IMAGE", + data=object(), + ) + ) + with patch.object( + self.addon.runtime, + "host_busy", + return_value=False, + ), patch.object( + self.addon.runtime, + "model_ready", + side_effect=lambda key: key == "BASE", + ): + self.assertTrue( + self.addon.ConvertImageToDepthPlane.poll(context) + ) + with patch.object( + self.addon.runtime, + "host_busy", + return_value=False, + ), patch.object( + self.addon.runtime, + "model_ready", + return_value=False, + ): + self.assertFalse( + self.addon.ConvertImageToDepthPlane.poll(context) + ) + def test_debug_panel_is_closed_by_default(self): self.assertIn( "DEFAULT_CLOSED", @@ -1485,6 +1583,95 @@ class AddonTest(unittest.TestCase): self.assertEqual(remove_background.call_args.args[1], input_path) self.assertEqual(remove_background.call_args.args[4], "BEN2_BASE") + def test_debug_jobs_use_the_scene_device(self): + settings = SimpleNamespace( + background_input_path="input.png", + device="CPU", + ) + operator = self.addon.RunJob() + operator.job_type = self.addon.operators.BACKGROUND_REMOVAL_JOB + + with TemporaryDirectory() as directory: + root = Path(directory) + input_path = root / "input.png" + input_path.touch() + settings.background_input_path = str(input_path) + paths = { + "log": root / "job.log", + "status": root / "job.json", + "result": root / "result", + } + with patch.object( + self.addon.runtime, + "new_job_paths", + return_value=paths, + ), patch.object( + self.addon.runtime, + "runtime_ready", + return_value=True, + ), patch.object( + self.addon.runtime, + "model_ready", + return_value=True, + ), patch.object( + self.addon.runtime, + "remove_background_request", + return_value=self.addon.runtime.HostJobRequest( + {"job_id": "test"} + ), + ) as remove_background: + operator._prepare_command(None, settings) + + self.assertEqual( + remove_background.call_args.kwargs["device"], + "CPU", + ) + + def test_production_jobs_use_the_preferences_device(self): + settings = SimpleNamespace(device="CPU", video_frames=16) + operator = self.addon.RunJob() + operator.job_type = self.addon.operators.IMAGE_BACKGROUND_JOB + + with TemporaryDirectory() as directory: + root = Path(directory) + input_path = root / "input.png" + input_path.touch() + operator.snapform_input_path = str(input_path) + paths = { + "log": root / "job.log", + "status": root / "job.json", + "result": root / "result", + } + with patch.object( + self.addon.runtime, + "new_job_paths", + return_value=paths, + ), patch.object( + self.addon.runtime, + "runtime_ready", + return_value=True, + ), patch.object( + self.addon.runtime, + "model_ready", + return_value=True, + ), patch.object( + self.addon.runtime, + "production_device", + return_value="MPS", + ), patch.object( + self.addon.runtime, + "remove_background_request", + return_value=self.addon.runtime.HostJobRequest( + {"job_id": "test"} + ), + ) as remove_background: + operator._prepare_command(None, settings) + + self.assertEqual( + remove_background.call_args.kwargs["device"], + "MPS", + ) + def test_image_background_job_uses_operator_input(self): settings = SimpleNamespace(device="AUTO") operator = self.addon.RunJob() @@ -1804,8 +1991,8 @@ class AddonTest(unittest.TestCase): return_value="C:/uv/uv.exe", ), patch.object( runtime, - "preferences", - return_value=SimpleNamespace(torch_backend="CU128"), + "machine_devices", + return_value=["cuda", "cpu"], ), patch.object( runtime, "runtime_directory", @@ -1821,11 +2008,15 @@ class AddonTest(unittest.TestCase): self.assertEqual(command[0], sys.executable) self.assertIn("--uv", command) self.assertIn("C:/uv/uv.exe", command) + self.assertEqual( + command[command.index("--backend") + 1], + "cu128", + ) self.assertTrue( any("bootstrap_runtime.py" in str(value) for value in command) ) - def test_install_runtime_command_passes_configured_pypi_index(self): + def test_install_runtime_command_auto_selects_backend(self): runtime = importlib.import_module("snapform.runtime") with patch.object( runtime, @@ -1833,11 +2024,8 @@ class AddonTest(unittest.TestCase): return_value="C:/uv/uv.exe", ), patch.object( runtime, - "preferences", - return_value=SimpleNamespace( - torch_backend="CU128", - pypi_index_url="https://pypi.tuna.tsinghua.edu.cn/simple", - ), + "machine_devices", + return_value=["cpu"], ), patch.object( runtime, "runtime_directory", @@ -1849,11 +2037,12 @@ class AddonTest(unittest.TestCase): ): command = runtime.install_runtime_command(Path("status.json")) - index_position = command.index("--pypi-index") + self.assertNotIn("--pypi-index", command) self.assertEqual( - command[index_position + 1], - "https://pypi.tuna.tsinghua.edu.cn/simple", + command[command.index("--backend") + 1], + "cpu", ) + self.assertIn("C:/uv/uv.exe", command) def test_completed_export_opens_job_directory(self): calls = [] diff --git a/tests/test_ai_host.py b/tests/test_ai_host.py index dc18646..3156bea 100644 --- a/tests/test_ai_host.py +++ b/tests/test_ai_host.py @@ -109,7 +109,7 @@ class HostTest(unittest.TestCase): self.assertIs(first, second) self.assertEqual(loads, ["model"]) - self.assertEqual(cache.status()["da3"], "da3_model") + self.assertEqual(cache.status()["da3"], "model") def test_host_tracks_clients_and_rejects_shared_shutdown(self): fake_models = SimpleNamespace( diff --git a/tests/test_bootstrap_runtime.py b/tests/test_bootstrap_runtime.py index 6a2fdf0..7b7c4af 100644 --- a/tests/test_bootstrap_runtime.py +++ b/tests/test_bootstrap_runtime.py @@ -62,7 +62,11 @@ class BootstrapRuntimeTest(unittest.TestCase): ) as mocked_run, patch.object( bootstrap, "run_install", - ) as mocked_run_install: + ) as mocked_run_install, patch.object( + bootstrap, + "run_verify", + return_value={}, + ): bootstrap.install(args) commands = [call.args[0] for call in mocked_run.call_args_list] @@ -113,10 +117,11 @@ class BootstrapRuntimeTest(unittest.TestCase): self.assertIn(f"uvicorn=={bootstrap.UVICORN_VERSION}", host_command) self.assertIn("setuptools", host_command) self.assertNotIn("ninja", host_command) - check_command = next( - command - for command in commands - if any("import torch;" in str(value) for value in command) + check_command = bootstrap.verify_command( + bootstrap.environment_python(runtime_dir) + ) + self.assertTrue( + any("import torch;" in str(value) for value in check_command) ) self.assertTrue( any( @@ -130,6 +135,12 @@ class BootstrapRuntimeTest(unittest.TestCase): self.assertFalse( any("import gsplat" in str(value) for value in check_command) ) + self.assertTrue( + any( + "SNAPFORM_DEVICES=" in str(value) + for value in check_command + ) + ) default_downloads = [ command for command in commands @@ -190,7 +201,11 @@ class BootstrapRuntimeTest(unittest.TestCase): ), patch.object( bootstrap, "run_install", - ) as mocked_run_install: + ) as mocked_run_install, patch.object( + bootstrap, + "run_verify", + return_value={}, + ): bootstrap.install(args) commands = [ @@ -372,7 +387,11 @@ class BootstrapRuntimeTest(unittest.TestCase): bootstrap, "run_stage", side_effect=fake_run_stage, - ), patch.object(bootstrap, "run"): + ), patch.object(bootstrap, "run"), patch.object( + bootstrap, + "run_verify", + return_value={}, + ): bootstrap.install(args) starts = [item[1] for item in stage_ranges] @@ -604,34 +623,6 @@ class BootstrapRuntimeTest(unittest.TestCase): "https://pypi.tuna.tsinghua.edu.cn/simple", ) - def test_child_environment_injects_registry_proxy(self): - bootstrap = load_bootstrap_runtime() - with patch.object( - bootstrap.urllib.request, - "getproxies", - return_value={ - "http": "http://proxy:8080", - "https": "http://proxy:8080", - }, - ): - env = bootstrap.child_environment() - - self.assertEqual(env["HTTP_PROXY"], "http://proxy:8080") - self.assertEqual(env["HTTPS_PROXY"], "http://proxy:8080") - - def test_child_environment_keeps_explicit_proxy(self): - bootstrap = load_bootstrap_runtime() - with patch.object( - bootstrap.urllib.request, - "getproxies", - return_value={"https": "http://proxy:8080"}, - ): - env = bootstrap.child_environment( - {"HTTPS_PROXY": "http://explicit:3128"} - ) - - self.assertEqual(env["HTTPS_PROXY"], "http://explicit:3128") - def test_ready_json_records_installed_packages(self): bootstrap = load_bootstrap_runtime() with tempfile.TemporaryDirectory() as directory: @@ -657,6 +648,10 @@ class BootstrapRuntimeTest(unittest.TestCase): bootstrap, "run_stage", return_value=["torch==2.9.1+cu128", "numpy==1.26.4"], + ), patch.object( + bootstrap, + "run_verify", + return_value={"cuda": True, "mps": False}, ): bootstrap.install(args) @@ -669,6 +664,7 @@ class BootstrapRuntimeTest(unittest.TestCase): ready["packages"], ["numpy==1.26.4", "torch==2.9.1+cu128"], ) + self.assertEqual(ready["devices"], ["cuda", "cpu"]) def test_broken_runtime_is_cleaned_before_reinstall(self): bootstrap = load_bootstrap_runtime() @@ -693,6 +689,10 @@ class BootstrapRuntimeTest(unittest.TestCase): ), patch.object( bootstrap, "run_install", + ), patch.object( + bootstrap, + "run_verify", + return_value={}, ): bootstrap.install(args) @@ -728,6 +728,10 @@ class BootstrapRuntimeTest(unittest.TestCase): ), patch.object( bootstrap, "run_install", + ), patch.object( + bootstrap, + "run_verify", + return_value={}, ): bootstrap.install(args) @@ -758,7 +762,11 @@ class BootstrapRuntimeTest(unittest.TestCase): bootstrap, "run", side_effect=fake_run, - ), patch.object(bootstrap, "run_install"): + ), patch.object(bootstrap, "run_install"), patch.object( + bootstrap, + "run_verify", + return_value={}, + ): bootstrap.install(args) self.assertTrue((runtime_dir / "snapform_ready.json").is_file()) @@ -872,6 +880,10 @@ class BootstrapRuntimeTest(unittest.TestCase): bootstrap, "run_install", side_effect=fake_run_install, + ), patch.object( + bootstrap, + "run_verify", + return_value={}, ): bootstrap.install(args) @@ -918,6 +930,10 @@ class BootstrapRuntimeTest(unittest.TestCase): ), patch.object( bootstrap, "run_install", + ), patch.object( + bootstrap, + "run_verify", + return_value={}, ): bootstrap.install(args) @@ -995,6 +1011,110 @@ class BootstrapRuntimeTest(unittest.TestCase): self.assertIn(f"depth-anything-3 @ {da3_local}", ai_command) self.assertIn(f"ben2 @ {ben2_local}", ai_command) + def test_install_stages_download_models_into_prefixed_directories(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") + uv_path = root / "uv" + uv_path.write_bytes(b"uv") + args = SimpleNamespace( + uv=str(uv_path), + runtime_dir=str(runtime_dir), + models_dir=str(root / "models"), + backend="cu128", + da3_commit="test-commit", + ben2_commit="test-ben2-commit", + status=str(root / "status.json"), + ) + + stages = bootstrap.install_stages( + args, + runtime_dir, + root / "python", + uv_path, + ) + + downloads = { + label: command + for label, command, _environment, kind in stages + if kind == "download" + } + self.assertIn("da3_small", " ".join(map(str, downloads["da3 small"]))) + self.assertIn("da3_base", " ".join(map(str, downloads["da3 base"]))) + self.assertIn("ben2_base", " ".join(map(str, downloads["ben2 base"]))) + + def test_run_verify_returns_detected_devices(self): + bootstrap = load_bootstrap_runtime() + + class FakeProcess: + def __init__(self, lines): + self.stdout = lines + + def wait(self): + return 0 + + fake_process = FakeProcess( + ['SNAPFORM_DEVICES={"cuda": false, "mps": true}'] + ) + with patch.object( + bootstrap.subprocess, + "Popen", + return_value=fake_process, + ): + report = bootstrap.run_verify( + "python", + "status.json", + clock=lambda: 0.0, + ) + + self.assertFalse(report["cuda"]) + self.assertTrue(report["mps"]) + + def test_run_verify_writes_heartbeat_progress(self): + bootstrap = load_bootstrap_runtime() + + class FakeProcess: + def __init__(self, lines): + self.stdout = lines + + def wait(self): + return 0 + + clock_state = {"now": 0.0} + + def fake_clock(): + clock_state["now"] += 6.0 + return clock_state["now"] + + def delayed_empty_lines(): + time.sleep(0.6) + if False: + yield + + fake_process = FakeProcess(delayed_empty_lines()) + with patch.object( + bootstrap.subprocess, + "Popen", + return_value=fake_process, + ), patch.object(bootstrap, "write_status") as mocked_write: + report = bootstrap.run_verify( + "python", + "status.json", + clock=fake_clock, + ) + + messages = [call.args[2] for call in mocked_write.call_args_list] + self.assertTrue( + any("verifying runtime" in message for message in messages) + ) + self.assertEqual(report, {}) + def test_install_uv_extracts_bundled_archive(self): bootstrap = load_bootstrap_runtime() with tempfile.TemporaryDirectory() as directory: diff --git a/tests/test_image_depth.py b/tests/test_image_depth.py index 9edd366..1f5fc36 100644 --- a/tests/test_image_depth.py +++ b/tests/test_image_depth.py @@ -209,6 +209,21 @@ class ImageDepthTest(unittest.TestCase): (1, 1), ) + def test_extract_frame_range_honors_single_frame_setting(self): + source_object = SimpleNamespace( + data=SimpleNamespace(source="MOVIE", frame_duration=97), + image_user=SimpleNamespace( + frame_start=1, + frame_offset=70, + frame_duration=1, + ), + ) + + self.assertEqual( + self.image_depth.extract_frame_range(source_object), + (71, 1), + ) + def test_configure_image_user_preserves_cyclic_setting(self): image_user = SimpleNamespace( frame_start=0, diff --git a/tests/test_models.py b/tests/test_models.py index 5b9f794..4b258aa 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -54,3 +54,21 @@ class ModelTest(unittest.TestCase): ("config.json", "model.safetensors"), ) self.assertNotIn("BEN2_BASE", self.models.MODELS) + + def test_da3_model_directories_share_the_da3_prefix(self): + for model_key, model in self.models.MODELS.items(): + with self.subTest(model_key=model_key): + self.assertTrue( + model.directory_name.startswith("da3_"), + model.directory_name, + ) + self.assertEqual( + self.models.model_directory_name(model_key), + model.directory_name, + ) + + def test_ben2_model_directory_uses_ben2_prefix(self): + self.assertEqual( + self.models.model_directory_name("BEN2_BASE"), + "ben2_base", + ) diff --git a/tests/test_worker.py b/tests/test_worker.py index a203a4d..82d0c97 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -385,79 +385,6 @@ class WorkerTest(unittest.TestCase): with self.assertRaisesRegex(RuntimeError, "Unsupported input type"): self.worker.prepare_input(input_path, 16, directory) - def test_download_passes_optional_file_patterns(self): - calls = [] - fake_hugging_face = ModuleType("huggingface_hub") - fake_hugging_face.snapshot_download = lambda **options: calls.append( - options - ) - args = SimpleNamespace( - repository="PramaLLC/BEN2", - model_dir="model", - status="status.json", - allow_pattern=["config.json", "model.safetensors"], - ) - - with tempfile.TemporaryDirectory() as directory, patch.dict( - sys.modules, - {"huggingface_hub": fake_hugging_face}, - ), patch.object( - self.worker, - "create_download_progress_class", - return_value=object, - ): - args.model_dir = str(Path(directory) / "model") - args.status = str(Path(directory) / "status.json") - self.worker.download_model(args) - - self.assertEqual( - calls[0]["allow_patterns"], - ["config.json", "model.safetensors"], - ) - self.assertEqual(calls[0]["endpoint"], self.worker.HF_ENDPOINT) - - def test_download_model_falls_back_to_modelscope_when_direct_fails(self): - calls = [] - fake_hugging_face = ModuleType("huggingface_hub") - - def fake_snapshot_download(**options): - calls.append(options) - raise OSError("Connection reset by peer") - - fake_hugging_face.snapshot_download = fake_snapshot_download - args = SimpleNamespace( - repository="PramaLLC/BEN2", - model_dir="model", - status="status.json", - allow_pattern=[], - ) - - with tempfile.TemporaryDirectory() as directory, patch.dict( - sys.modules, - {"huggingface_hub": fake_hugging_face}, - ), patch.object( - self.worker, - "create_download_progress_class", - return_value=object, - ), patch.dict(os.environ, {"HF_ENDPOINT": ""}), patch.object( - self.worker, - "download_from_modelscope", - ) as mocked_modelscope: - def fake_download_from_modelscope(*args, **kwargs): - model_dir = Path(args[1]) - model_dir.mkdir(parents=True, exist_ok=True) - (model_dir / "config.json").write_text("{}") - (model_dir / "model.safetensors").write_bytes(b"x") - - mocked_modelscope.side_effect = fake_download_from_modelscope - args.model_dir = str(Path(directory) / "model") - args.status = str(Path(directory) / "status.json") - self.worker.download_model(args) - - self.assertEqual(len(calls), 1) - self.assertEqual(calls[0]["endpoint"], self.worker.HF_ENDPOINT) - self.assertEqual(mocked_modelscope.call_count, 1) - def test_download_model_skips_unreachable_endpoint(self): calls = [] fake_hugging_face = ModuleType("huggingface_hub") diff --git a/uv.lock b/uv.lock index 4d59669..891ab1d 100644 --- a/uv.lock +++ b/uv.lock @@ -2,7 +2,78 @@ version = 1 revision = 3 requires-python = ">=3.11" +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +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 = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + [[package]] name = "snapform" version = "0.10.10" source = { virtual = "." } + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [{ name = "pytest" }]