diff --git a/__init__.py b/__init__.py index cf317063..5223fc45 100644 --- a/__init__.py +++ b/__init__.py @@ -1,13 +1,32 @@ -from .py.lora_manager import LoraManager -from .py.nodes.lora_loader import LoraManagerLoader, LoraManagerTextLoader -from .py.nodes.trigger_word_toggle import TriggerWordToggle -from .py.nodes.lora_stacker import LoraStacker -from .py.nodes.save_image import SaveImage -from .py.nodes.debug_metadata import DebugMetadata -from .py.nodes.wanvideo_lora_select import WanVideoLoraSelect -from .py.nodes.wanvideo_lora_select_from_text import WanVideoLoraSelectFromText -# Import metadata collector to install hooks on startup -from .py.metadata_collector import init as init_metadata_collector +try: # pragma: no cover - import fallback for pytest collection + from .py.lora_manager import LoraManager + from .py.nodes.lora_loader import LoraManagerLoader, LoraManagerTextLoader + from .py.nodes.trigger_word_toggle import TriggerWordToggle + from .py.nodes.lora_stacker import LoraStacker + from .py.nodes.save_image import SaveImage + from .py.nodes.debug_metadata import DebugMetadata + from .py.nodes.wanvideo_lora_select import WanVideoLoraSelect + from .py.nodes.wanvideo_lora_select_from_text import WanVideoLoraSelectFromText + from .py.metadata_collector import init as init_metadata_collector +except ImportError: # pragma: no cover - allows running under pytest without package install + import importlib + import pathlib + import sys + + package_root = pathlib.Path(__file__).resolve().parent + if str(package_root) not in sys.path: + sys.path.append(str(package_root)) + + LoraManager = importlib.import_module("py.lora_manager").LoraManager + LoraManagerLoader = importlib.import_module("py.nodes.lora_loader").LoraManagerLoader + LoraManagerTextLoader = importlib.import_module("py.nodes.lora_loader").LoraManagerTextLoader + TriggerWordToggle = importlib.import_module("py.nodes.trigger_word_toggle").TriggerWordToggle + LoraStacker = importlib.import_module("py.nodes.lora_stacker").LoraStacker + SaveImage = importlib.import_module("py.nodes.save_image").SaveImage + DebugMetadata = importlib.import_module("py.nodes.debug_metadata").DebugMetadata + WanVideoLoraSelect = importlib.import_module("py.nodes.wanvideo_lora_select").WanVideoLoraSelect + WanVideoLoraSelectFromText = importlib.import_module("py.nodes.wanvideo_lora_select_from_text").WanVideoLoraSelectFromText + init_metadata_collector = importlib.import_module("py.metadata_collector").init NODE_CLASS_MAPPINGS = { LoraManagerLoader.NAME: LoraManagerLoader, diff --git a/docs/architecture/model_routes.md b/docs/architecture/model_routes.md index d32420dd..00329299 100644 --- a/docs/architecture/model_routes.md +++ b/docs/architecture/model_routes.md @@ -9,77 +9,119 @@ exposed in `py/routes/base_model_routes.py`, the collaborators it leans on, and any cache or WebSocket side effects implemented in `py/utils/routes_common.py`. +## Contents + +- [Handler catalogue](#handler-catalogue) +- [Dependency map and contracts](#dependency-map-and-contracts) + - [Cache and metadata mutations](#cache-and-metadata-mutations) + - [Download and WebSocket flows](#download-and-websocket-flows) + - [Read-only queries](#read-only-queries) + - [Template rendering and initialization](#template-rendering-and-initialization) + ## Handler catalogue -| Endpoint(s) | Handler | Purpose | Collaborators | Cache / WebSocket side effects | -| --- | --- | --- | --- | --- | -| `/{prefix}` | `handle_models_page` | Renders the HTML page for a model type, populating the template from cached scanner data when available. | `settings`, `server_i18n`, `service.scanner.get_cached_data()` | Reads scanner cache to build folder list; flags initialization state without mutating cache. | -| `/api/lm/{prefix}/list` | `get_models` | Returns paginated model metadata. | `service.get_paginated_data()`, `service.format_response()` | None (read-only). | -| `/api/lm/{prefix}/delete` | `delete_model` | Removes a single model from disk and cache. | `ModelRouteUtils.handle_delete_model()` | Deletes files, prunes `scanner.get_cached_data().raw_data`, calls `cache.resort()`, and updates `scanner._hash_index`. | -| `/api/lm/{prefix}/exclude` | `exclude_model` | Marks a model as excluded so it no longer appears in listings. | `ModelRouteUtils.handle_exclude_model()` | Updates metadata, decrements `scanner._tags_count`, removes cache entry and hash index entry, and appends to `scanner._excluded_models`. | -| `/api/lm/{prefix}/fetch-civitai` | `fetch_civitai` | Fetches metadata for a specific model from CivitAI. | `ModelRouteUtils.fetch_and_update_model()` | Uses scanner cache to find the target record and updates it via `scanner.update_single_model_cache`. | -| `/api/lm/{prefix}/fetch-all-civitai` | `fetch_all_civitai` | Bulk refreshes metadata for models missing CivitAI info. | `ModelRouteUtils.fetch_and_update_model()`, `ws_manager.broadcast()` | Streams progress to all clients, updates cache entries, optionally resorts cached data. | -| `/api/lm/{prefix}/relink-civitai` | `relink_civitai` | Re-associates a local file with a CivitAI entry. | `ModelRouteUtils.handle_relink_civitai()` | Updates metadata, refreshes cache via `scanner.update_single_model_cache`. | -| `/api/lm/{prefix}/replace-preview` | `replace_preview` | Replaces the preview asset attached to a model. | `ModelRouteUtils.handle_replace_preview()` | Writes new preview file, updates metadata, and calls `scanner.update_preview_in_cache()`. | -| `/api/lm/{prefix}/save-metadata` | `save_metadata` | Persists edits to model metadata. | `ModelRouteUtils.handle_save_metadata()` | Saves metadata file and syncs the cache entry. | -| `/api/lm/{prefix}/add-tags` | `add_tags` | Adds or increments tags for a model. | `ModelRouteUtils.handle_add_tags()` | Mutates metadata, increments `scanner._tags_count`, and updates the cached model. | -| `/api/lm/{prefix}/rename` | `rename_model` | Renames a model and its related assets. | `ModelRouteUtils.handle_rename_model()` | Renames files on disk, updates cache indices, refreshes metadata. | -| `/api/lm/{prefix}/bulk-delete` | `bulk_delete_models` | Deletes multiple models in one request. | `ModelRouteUtils.handle_bulk_delete_models()` | Delegates to `scanner.bulk_delete_models()` which removes disk assets and cache records in bulk. | -| `/api/lm/{prefix}/verify-duplicates` | `verify_duplicates` | Confirms that a list of files share the same hash. | `ModelRouteUtils.handle_verify_duplicates()` | Recalculates hashes, updates metadata, and patches cache entries when stored hashes change. | -| `/api/lm/{prefix}/top-tags` | `get_top_tags` | Returns the most frequently used tags. | `service.get_top_tags()` | None (read-only). | -| `/api/lm/{prefix}/base-models` | `get_base_models` | Lists base models referenced by this model type. | `service.get_base_models()` | None (read-only). | -| `/api/lm/{prefix}/scan` | `scan_models` | Triggers a rescan of the filesystem. | `service.scan_models()` | Scanner rebuilds its cache as part of the service workflow. | -| `/api/lm/{prefix}/roots` | `get_model_roots` | Enumerates root directories searched for this model type. | `service.get_model_roots()` | None (read-only). | -| `/api/lm/{prefix}/folders` | `get_folders` | Returns cached folder summaries. | `service.scanner.get_cached_data()` | Reads cached structure without mutation. | -| `/api/lm/{prefix}/folder-tree` | `get_folder_tree` | Builds a nested folder tree of cached items. | `service.scanner.get_cached_data()` | Reads cache; does not mutate. | -| `/api/lm/{prefix}/unified-folder-tree` | `get_unified_folder_tree` | Returns a tree aggregating all roots. | `service.scanner.get_cached_data()` | Reads cache; does not mutate. | -| `/api/lm/{prefix}/find-duplicates` | `find_duplicate_models` | Finds duplicate hashes within the cache. | `service.scanner.get_duplicates()`, `service.scanner.get_hash_by_filename()` | Uses cache data to assemble duplicate groups; no mutation. | -| `/api/lm/{prefix}/find-filename-conflicts` | `find_filename_conflicts` | Groups models that share a filename across directories. | `service.scanner.get_filename_conflicts()`, `service.get_path_by_hash()` | Reads cache while formatting results. | -| `/api/lm/{prefix}/get-notes` | `get_model_notes` | Retrieves saved notes for a model. | `service.get_model_notes()` | None (read-only). | -| `/api/lm/{prefix}/preview-url` | `get_model_preview_url` | Resolves the static preview URL for a model. | `service.get_model_preview_url()` | None (read-only). | -| `/api/lm/{prefix}/civitai-url` | `get_model_civitai_url` | Returns the CivitAI permalink for a model. | `service.get_model_civitai_url()` | None (read-only). | -| `/api/lm/{prefix}/metadata` | `get_model_metadata` | Loads the raw metadata payload for a model. | `service.get_model_metadata()` | None (read-only). | -| `/api/lm/{prefix}/model-description` | `get_model_description` | Returns a formatted description for the UI. | `service.get_model_description()` | None (read-only). | -| `/api/lm/{prefix}/relative-paths` | `get_relative_paths` | Provides filesystem auto-complete suggestions. | `service.get_relative_paths()` | None (read-only). | -| `/api/lm/{prefix}/civitai/versions/{model_id}` | `get_civitai_versions` | Lists remote versions and indicates which exist locally. | `get_default_metadata_provider()`, `self.service.has_hash()`, `self.service.get_path_by_hash()` | Read-only; consults cache/service indices to mark local availability. | -| `/api/lm/{prefix}/civitai/model/version/{modelVersionId}` | `get_civitai_model_by_version` | Fetches detailed metadata for a specific CivitAI version. | `get_default_metadata_provider()` | None (read-only). | -| `/api/lm/{prefix}/civitai/model/hash/{hash}` | `get_civitai_model_by_hash` | Fetches CivitAI details using a hash. | `get_default_metadata_provider()` | None (read-only). | -| `/api/lm/download-model` (POST) & `/api/lm/download-model-get` (GET) | `download_model`, `download_model_get` | Starts a download through the shared download manager. | `ModelRouteUtils.handle_download_model()`, `ServiceRegistry.get_download_manager()` | The helper broadcasts download progress via `ws_manager.broadcast_download_progress()` and stores state in `ws_manager._download_progress`. | -| `/api/lm/cancel-download-get` | `cancel_download_get` | Cancels an active download. | `ModelRouteUtils.handle_cancel_download()` | Broadcasts a cancellation message via `ws_manager.broadcast_download_progress()` and prunes download progress entries. | -| `/api/lm/download-progress/{download_id}` | `get_download_progress` | Reports cached download progress for a download ID. | `ws_manager.get_download_progress()` | Read-only view of cached progress. | -| `/api/lm/{prefix}/move_model` | `move_model` | Moves a model to a new folder. | `ModelMoveService.move_model()` | File operations performed by the injected service may update scanner caches downstream. | -| `/api/lm/{prefix}/move_models_bulk` | `move_models_bulk` | Bulk move models to a new location. | `ModelMoveService.move_models_bulk()` | File operations delegated to the service. | -| `/api/lm/{prefix}/auto-organize` (GET/POST) | `auto_organize_models` | Launches auto-organization for models, optionally limited to selected files. | `ModelFileService.auto_organize_models()`, `ws_manager.get_auto_organize_lock()`, `WebSocketProgressCallback` | Uses a shared asyncio lock, streams progress through `ws_manager.broadcast_auto_organize_progress()`, and relies on `ws_manager.is_auto_organize_running()` state. | -| `/api/lm/{prefix}/auto-organize-progress` | `get_auto_organize_progress` | Polls the latest auto-organize progress snapshot. | `ws_manager.get_auto_organize_progress()` | Read-only view of the WebSocket manager’s cached progress. | +The routes exposed by `BaseModelRoutes` combine HTTP wiring with a handful of +shared helper classes. Services surface filesystem and metadata operations, +`ModelRouteUtils` bundles cache-sensitive mutations, and `ws_manager` +coordinates fan-out to browser clients. The tables below expand the existing +catalogue into explicit dependency maps and invariants so refactors can reason +about the expectations each collaborator must uphold. -## Shared utility side effects +## Dependency map and contracts -The delegated helpers in `ModelRouteUtils` encapsulate most cache and -WebSocket mutations. The smoke tests in this repository exercise the -following contracts from `py/utils/routes_common.py`: +### Cache and metadata mutations -* `handle_delete_model` removes matching records from - `scanner.get_cached_data().raw_data`, awaits `cache.resort()`, and calls - `scanner._hash_index.remove_by_path()` when an index is present before - returning a success payload. -* `handle_replace_preview` writes a new preview file, persists metadata via - `MetadataManager.save_metadata()`, and then invokes - `scanner.update_preview_in_cache()` with the normalized preview path and - NSFW level so downstream requests surface the updated asset. -* `handle_download_model` acquires the shared download manager from - `ServiceRegistry`, injects a WebSocket progress callback, and relies on - `ws_manager.broadcast_download_progress()` to update the cached progress map - that `get_download_progress` later reads. -* `handle_bulk_delete_models`, `handle_add_tags`, `handle_exclude_model`, and - `handle_verify_duplicates` all mutate scanner-maintained collections (hash - indices, tag counts, exclusion lists, or cached metadata) so route handlers - can stay thin while cache consistency remains centralized in the utility - module. -* `ws_manager.broadcast_auto_organize_progress()` stores the latest progress - snapshot consumed by `get_auto_organize_progress`, while - `ws_manager.broadcast()` is used to notify clients during CivitAI bulk - refreshes and other background operations. +| Endpoint(s) | Delegate(s) | State touched | Invariants / contracts | +| --- | --- | --- | --- | +| `/api/lm/{prefix}/delete` | `ModelRouteUtils.handle_delete_model()` | Removes files from disk, prunes `scanner._cache.raw_data`, awaits `scanner._cache.resort()`, calls `scanner._hash_index.remove_by_path()`. | Cache and hash index must no longer reference the deleted path; resort must complete before responding to keep pagination deterministic. | +| `/api/lm/{prefix}/exclude` | `ModelRouteUtils.handle_exclude_model()` | Mutates metadata records, `scanner._cache.raw_data`, `scanner._hash_index`, `scanner._tags_count`, and `scanner._excluded_models`. | Excluded models remain discoverable via exclusion list while being hidden from listings; tag counts stay balanced after removal. | +| `/api/lm/{prefix}/fetch-civitai` | `ModelRouteUtils.fetch_and_update_model()` | Reads `scanner._cache.raw_data`, writes metadata JSON through `MetadataManager`, syncs cache via `scanner.update_single_model_cache`. | Requires a cached SHA256 hash; cache entries must reflect merged metadata before formatted response is returned. | +| `/api/lm/{prefix}/fetch-all-civitai` | `ModelRouteUtils.fetch_and_update_model()`, `ws_manager.broadcast()` | Iterates over cache, updates metadata files and cache records, optionally awaits `scanner._cache.resort()`. | Progress broadcasts follow started → processing → completed; if any model name changes, cache resort must run once before completion broadcast. | +| `/api/lm/{prefix}/relink-civitai` | `ModelRouteUtils.handle_relink_civitai()` | Updates metadata on disk and resynchronizes the cache entry. | The new association must propagate to `scanner.update_single_model_cache` so duplicate resolution and listings reflect the change immediately. | +| `/api/lm/{prefix}/replace-preview` | `ModelRouteUtils.handle_replace_preview()` | Writes optimized preview file, persists metadata via `MetadataManager`, updates cache with `scanner.update_preview_in_cache()`. | Preview path stored in metadata and cache must match the normalized file system path; NSFW level integer is synchronized across metadata and cache. | +| `/api/lm/{prefix}/save-metadata` | `ModelRouteUtils.handle_save_metadata()` | Writes metadata JSON and ensures cache entry mirrors the latest content. | Metadata persistence must be atomic—cache data should match on-disk metadata before response emits success. | +| `/api/lm/{prefix}/add-tags` | `ModelRouteUtils.handle_add_tags()` | Updates metadata tags, increments `scanner._tags_count`, and patches cached item. | Tag frequency map remains in sync with cache and metadata after increments. | +| `/api/lm/{prefix}/rename` | `ModelRouteUtils.handle_rename_model()` | Renames files, metadata, previews; updates cache indices and hash mappings. | File moves succeed or rollback as a unit so cache state never points to a missing file; hash index entries track the new path. | +| `/api/lm/{prefix}/bulk-delete` | `ModelRouteUtils.handle_bulk_delete_models()` | Delegates to `scanner.bulk_delete_models()` to delete files, trim cache, resort, and drop hash index entries. | Every requested path is removed from cache and index; resort happens once after bulk deletion. | +| `/api/lm/{prefix}/verify-duplicates` | `ModelRouteUtils.handle_verify_duplicates()` | Recomputes hashes, updates metadata and cached entries if discrepancies found. | Hash metadata stored in cache must mirror recomputed values to guarantee future duplicate checks operate on current data. | +| `/api/lm/{prefix}/scan` | `service.scan_models()` | Rescans filesystem, rebuilding scanner cache. | Scanner replaces its cache atomically so subsequent requests observe a consistent snapshot. | +| `/api/lm/{prefix}/move_model` | `ModelMoveService.move_model()` | Moves files/directories and notifies scanner via service layer conventions. | Move operations respect filesystem invariants (target path exists, metadata follows file) and emit success/failure without leaving partial moves. | +| `/api/lm/{prefix}/move_models_bulk` | `ModelMoveService.move_models_bulk()` | Batch move behavior as above. | Aggregated result enumerates successes/failures while preserving per-model atomicity. | +| `/api/lm/{prefix}/auto-organize` (GET/POST) | `ModelFileService.auto_organize_models()`, `ws_manager.get_auto_organize_lock()`, `WebSocketProgressCallback` | Writes organized files, updates metadata, and streams progress snapshots. | Only one auto-organize job may run; lock must guard reentrancy and WebSocket updates must include latest progress payload consumed by polling route. | -Keeping these side effects in mind is essential when refactoring route logic: -any replacement must continue to honor the implicit contracts the utilities -expect from scanners, caches, and the WebSocket manager. +### Download and WebSocket flows + +| Endpoint(s) | Delegate(s) | State touched | Invariants / contracts | +| --- | --- | --- | --- | +| `/api/lm/download-model` (POST) & `/api/lm/download-model-get` (GET) | `ModelRouteUtils.handle_download_model()`, `ServiceRegistry.get_download_manager()` | Schedules downloads, registers `ws_manager.broadcast_download_progress()` callback that stores progress in `ws_manager._download_progress`. | Download IDs remain stable across POST/GET helpers; every progress callback persists a timestamped entry so `/download-progress` and WebSocket clients share consistent snapshots. | +| `/api/lm/cancel-download-get` | `ModelRouteUtils.handle_cancel_download()` | Signals download manager, prunes `ws_manager._download_progress`, and emits cancellation broadcast. | Cancel requests must tolerate missing IDs gracefully while ensuring cached progress is removed once cancellation succeeds. | +| `/api/lm/download-progress/{download_id}` | `ws_manager.get_download_progress()` | Reads cached progress dictionary. | Returns `404` when progress is absent; successful payload surfaces the numeric `progress` stored during broadcasts. | +| `/api/lm/{prefix}/fetch-all-civitai` | `ws_manager.broadcast()` | Broadcast loop described above. | Broadcast cadence cannot skip completion/error messages so clients know when to clear UI spinners. | +| `/api/lm/{prefix}/auto-organize-progress` | `ws_manager.get_auto_organize_progress()` | Reads cached progress snapshot. | Route returns cached payload verbatim; absence yields `404` to signal idle state. | + +### Read-only queries + +| Endpoint(s) | Delegate(s) | State touched | Invariants / contracts | +| --- | --- | --- | --- | +| `/api/lm/{prefix}/list` | `service.get_paginated_data()`, `service.format_response()` | Reads service-managed pagination data. | Formatting must be applied to every item before response; pagination metadata echoes service result. | +| `/api/lm/{prefix}/top-tags` | `service.get_top_tags()` | Reads aggregated tag counts. | Limit parameter bounded to `[1, 100]`; response always wraps tags in `{success: True}` envelope. | +| `/api/lm/{prefix}/base-models` | `service.get_base_models()` | Reads service data. | Same limit handling as tags. | +| `/api/lm/{prefix}/roots` | `service.get_model_roots()` | Reads configured roots. | Always returns `{success: True, roots: [...]}`. | +| `/api/lm/{prefix}/folders` | `service.scanner.get_cached_data()` | Reads folder summaries from cache. | Cache access must tolerate initialization phases by surfacing errors via HTTP 500. | +| `/api/lm/{prefix}/folder-tree` | `service.get_folder_tree()` | Reads derived tree for requested root. | Rejects missing `model_root` with HTTP 400. | +| `/api/lm/{prefix}/unified-folder-tree` | `service.get_unified_folder_tree()` | Aggregated folder tree. | Returns `{success: True, tree: ...}` or 500 on error. | +| `/api/lm/{prefix}/find-duplicates` | `service.find_duplicate_hashes()`, `service.scanner.get_cached_data()`, `service.get_path_by_hash()` | Reads cache and hash index to format duplicates. | Only returns groups with more than one resolved model. | +| `/api/lm/{prefix}/find-filename-conflicts` | `service.find_duplicate_filenames()`, `service.scanner.get_cached_data()`, `service.scanner.get_hash_by_filename()` | Similar read-only assembly. | Includes resolved main index entry when available; empty `models` groups are omitted. | +| `/api/lm/{prefix}/get-notes` | `service.get_model_notes()` | Reads persisted notes. | Missing notes produce HTTP 404 with explicit error message. | +| `/api/lm/{prefix}/preview-url` | `service.get_model_preview_url()` | Resolves static URL. | Successful responses wrap URL in `{success: True}`; missing preview yields 404 error payload. | +| `/api/lm/{prefix}/civitai-url` | `service.get_model_civitai_url()` | Returns remote permalink info. | Response envelope matches preview pattern. | +| `/api/lm/{prefix}/metadata` | `service.get_model_metadata()` | Reads metadata JSON. | Responds with raw metadata dict or 500 on failure. | +| `/api/lm/{prefix}/model-description` | `service.get_model_description()` | Returns formatted description string. | Always JSON with success boolean. | +| `/api/lm/{prefix}/relative-paths` | `service.get_relative_paths()` | Resolves filesystem suggestions. | Maintains read-only contract. | +| `/api/lm/{prefix}/civitai/versions/{model_id}` | `get_default_metadata_provider()`, `service.has_hash()`, `service.get_path_by_hash()` | Reads remote API, cross-references cache. | Versions payload includes `existsLocally`/`localPath` only when hashes match local indices. | +| `/api/lm/{prefix}/civitai/model/version/{modelVersionId}` | `get_default_metadata_provider()` | Remote metadata lookup. | Errors propagate as JSON with `{success: False}` payload. | +| `/api/lm/{prefix}/civitai/model/hash/{hash}` | `get_default_metadata_provider()` | Remote metadata lookup. | Missing hashes return 404 with `{success: False}`. | + +### Template rendering and initialization + +| Endpoint(s) | Delegate(s) | State touched | Invariants / contracts | +| --- | --- | --- | --- | +| `/{prefix}` | `handle_models_page` | Reads configuration via `settings`, sets locale with `server_i18n`, pulls cached folders through `service.scanner.get_cached_data()`, renders Jinja template. | Template rendering must tolerate scanner initialization by flagging `is_initializing`; i18n filter is attached exactly once per environment to avoid duplicate registration errors. | + +### Contract sequences + +The following high-level sequences show how the collaborating services work +together for the most stateful operations: + +``` +delete_model request + → BaseModelRoutes.delete_model + → ModelRouteUtils.handle_delete_model + → filesystem delete + metadata cleanup + → scanner._cache.raw_data prune + → await scanner._cache.resort() + → scanner._hash_index.remove_by_path() +``` + +``` +replace_preview request + → BaseModelRoutes.replace_preview + → ModelRouteUtils.handle_replace_preview + → ExifUtils.optimize_image / config.get_preview_static_url + → MetadataManager.save_metadata + → scanner.update_preview_in_cache(model_path, preview_path, nsfw_level) +``` + +``` +download_model request + → BaseModelRoutes.download_model + → ModelRouteUtils.handle_download_model + → ServiceRegistry.get_download_manager().download_from_civitai(..., progress_callback) + → ws_manager.broadcast_download_progress(download_id, data) + → ws_manager._download_progress[download_id] updated with timestamp + → /api/lm/download-progress/{id} polls ws_manager.get_download_progress +``` + +These contracts complement the tables above: if any collaborator changes its +behavior, the invariants called out here must continue to hold for the routes +to remain predictable. diff --git a/py/__init__.py b/py/__init__.py index e69de29b..54e9a3f4 100644 --- a/py/__init__.py +++ b/py/__init__.py @@ -0,0 +1,12 @@ +"""Project namespace package.""" + +# pytest's internal compatibility layer still imports ``py.path.local`` from the +# historical ``py`` dependency. Because this project reuses the ``py`` package +# name, we expose a minimal shim so ``py.path.local`` resolves to ``pathlib.Path`` +# during test runs without pulling in the external dependency. +from pathlib import Path +from types import SimpleNamespace + +path = SimpleNamespace(local=Path) + +__all__ = ["path"] diff --git a/tests/routes/test_base_model_routes_smoke.py b/tests/routes/test_base_model_routes_smoke.py index f6674c08..bccd83a9 100644 --- a/tests/routes/test_base_model_routes_smoke.py +++ b/tests/routes/test_base_model_routes_smoke.py @@ -170,6 +170,8 @@ def test_replace_preview_writes_file_and_updates_cache( assert payload["preview_url"] == "/static/preview-model.webp" assert Path(expected_preview).exists() assert mock_scanner.preview_updates[-1]["preview_path"] == expected_preview + assert mock_scanner._cache.raw_data[0]["preview_url"] == expected_preview + assert mock_scanner._cache.raw_data[0]["preview_nsfw_level"] == 2 updated_metadata = json.loads(metadata_path.read_text()) assert updated_metadata["preview_url"] == expected_preview @@ -204,6 +206,15 @@ def test_download_model_invokes_download_manager( progress = ws_manager.get_download_progress(payload["download_id"]) assert progress is not None assert progress["progress"] == 42 + assert "timestamp" in progress + + progress_response = await client.get( + f"/api/lm/download-progress/{payload['download_id']}" + ) + progress_payload = await progress_response.json() + + assert progress_response.status == 200 + assert progress_payload == {"success": True, "progress": 42} ws_manager.cleanup_download_progress(payload["download_id"]) finally: await client.close()