mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
Merge pull request #454 from willmiao/codex/migrate-recipe-http-layer-architecture
Refactor recipe routes to use registrar scaffolding
This commit is contained in:
50
docs/architecture/recipe_routes.md
Normal file
50
docs/architecture/recipe_routes.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Recipe route scaffolding
|
||||
|
||||
The recipe HTTP stack is being migrated to mirror the shared model routing
|
||||
architecture. The first phase extracts the registrar/controller scaffolding so
|
||||
future handler sets can plug into a stable surface area. The stack now mirrors
|
||||
the same separation of concerns described in
|
||||
`docs/architecture/model_routes.md`:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph HTTP
|
||||
A[RecipeRouteRegistrar] -->|binds| B[BaseRecipeRoutes handler owner]
|
||||
end
|
||||
subgraph Application
|
||||
B --> C[Recipe handler set]
|
||||
C --> D[Async handlers]
|
||||
D --> E[Services / scanners]
|
||||
end
|
||||
```
|
||||
|
||||
## Responsibilities
|
||||
|
||||
| Layer | Module(s) | Responsibility |
|
||||
| --- | --- | --- |
|
||||
| Registrar | `py/routes/recipe_route_registrar.py` | Declarative list of every recipe endpoint and helper that binds them to an `aiohttp` application. |
|
||||
| Base controller | `py/routes/base_recipe_routes.py` | Lazily resolves shared services, registers the server-side i18n filter exactly once, pre-warms caches on startup, and exposes a `{handler_name: coroutine}` mapping used by the registrar. |
|
||||
| Handler set (upcoming) | `py/routes/handlers/recipe_handlers.py` (planned) | Will group HTTP handlers by concern (page rendering, listings, mutations, queries, sharing) and surface them to `BaseRecipeRoutes.get_handler_owner()`. |
|
||||
|
||||
`RecipeRoutes` subclasses the base controller to keep compatibility with the
|
||||
existing monolithic handlers. Once the handler set is extracted the subclass
|
||||
will simply provide the concrete owner returned by `get_handler_owner()`.
|
||||
|
||||
## High-level test baseline
|
||||
|
||||
The new smoke suite in `tests/routes/test_recipe_route_scaffolding.py`
|
||||
guarantees the registrar/controller contract remains intact:
|
||||
|
||||
* `BaseRecipeRoutes.attach_dependencies` resolves registry services only once
|
||||
and protects the i18n filter from duplicate registration.
|
||||
* Startup hooks are appended exactly once so cache pre-warming and dependency
|
||||
resolution run during application boot.
|
||||
* `BaseRecipeRoutes.to_route_mapping()` uses the handler owner as the source of
|
||||
callables, enabling the upcoming handler set without touching the registrar.
|
||||
* `RecipeRouteRegistrar` binds every declarative route to the aiohttp router.
|
||||
* `RecipeRoutes.setup_routes` wires the registrar and startup hooks together so
|
||||
future refactors can swap in the handler set without editing callers.
|
||||
|
||||
These guardrails mirror the expectations in the model route architecture and
|
||||
provide confidence that future refactors can focus on handlers and use cases
|
||||
without breaking HTTP wiring.
|
||||
109
py/routes/base_recipe_routes.py
Normal file
109
py/routes/base_recipe_routes.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""Base infrastructure shared across recipe routes."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Callable, Mapping
|
||||
|
||||
import jinja2
|
||||
from aiohttp import web
|
||||
|
||||
from ..config import config
|
||||
from ..services.server_i18n import server_i18n
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..services.settings_manager import settings
|
||||
from .recipe_route_registrar import ROUTE_DEFINITIONS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseRecipeRoutes:
|
||||
"""Common dependency and startup wiring for recipe routes."""
|
||||
|
||||
_HANDLER_NAMES: tuple[str, ...] = tuple(
|
||||
definition.handler_name for definition in ROUTE_DEFINITIONS
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.recipe_scanner = None
|
||||
self.lora_scanner = None
|
||||
self.civitai_client = None
|
||||
self.settings = settings
|
||||
self.server_i18n = server_i18n
|
||||
self.template_env = jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader(config.templates_path),
|
||||
autoescape=True,
|
||||
)
|
||||
|
||||
self._i18n_registered = False
|
||||
self._startup_hooks_registered = False
|
||||
self._handler_mapping: dict[str, Callable] | None = None
|
||||
|
||||
async def attach_dependencies(self, app: web.Application | None = None) -> None:
|
||||
"""Resolve shared services from the registry."""
|
||||
|
||||
await self._ensure_services()
|
||||
self._ensure_i18n_filter()
|
||||
|
||||
async def ensure_dependencies_ready(self) -> None:
|
||||
"""Ensure dependencies are available for request handlers."""
|
||||
|
||||
if self.recipe_scanner is None or self.civitai_client is None:
|
||||
await self.attach_dependencies()
|
||||
|
||||
def register_startup_hooks(self, app: web.Application) -> None:
|
||||
"""Register startup hooks once for dependency wiring."""
|
||||
|
||||
if self._startup_hooks_registered:
|
||||
return
|
||||
|
||||
app.on_startup.append(self.attach_dependencies)
|
||||
app.on_startup.append(self.prewarm_cache)
|
||||
self._startup_hooks_registered = True
|
||||
|
||||
async def prewarm_cache(self, app: web.Application | None = None) -> None:
|
||||
"""Pre-load recipe and LoRA caches on startup."""
|
||||
|
||||
try:
|
||||
await self.attach_dependencies(app)
|
||||
|
||||
if self.lora_scanner is not None:
|
||||
await self.lora_scanner.get_cached_data()
|
||||
hash_index = getattr(self.lora_scanner, "_hash_index", None)
|
||||
if hash_index is not None and hasattr(hash_index, "_hash_to_path"):
|
||||
_ = len(hash_index._hash_to_path)
|
||||
|
||||
if self.recipe_scanner is not None:
|
||||
await self.recipe_scanner.get_cached_data(force_refresh=True)
|
||||
except Exception as exc:
|
||||
logger.error("Error pre-warming recipe cache: %s", exc, exc_info=True)
|
||||
|
||||
def to_route_mapping(self) -> Mapping[str, Callable]:
|
||||
"""Return a mapping of handler name to coroutine for registrar binding."""
|
||||
|
||||
if self._handler_mapping is None:
|
||||
owner = self.get_handler_owner()
|
||||
self._handler_mapping = {
|
||||
name: getattr(owner, name) for name in self._HANDLER_NAMES
|
||||
}
|
||||
return self._handler_mapping
|
||||
|
||||
# Internal helpers -------------------------------------------------
|
||||
|
||||
async def _ensure_services(self) -> None:
|
||||
if self.recipe_scanner is None:
|
||||
self.recipe_scanner = await ServiceRegistry.get_recipe_scanner()
|
||||
self.lora_scanner = getattr(self.recipe_scanner, "_lora_scanner", None)
|
||||
|
||||
if self.civitai_client is None:
|
||||
self.civitai_client = await ServiceRegistry.get_civitai_client()
|
||||
|
||||
def _ensure_i18n_filter(self) -> None:
|
||||
if not self._i18n_registered:
|
||||
self.template_env.filters["t"] = self.server_i18n.create_template_filter()
|
||||
self._i18n_registered = True
|
||||
|
||||
def get_handler_owner(self):
|
||||
"""Return the object supplying bound handler coroutines."""
|
||||
|
||||
return self
|
||||
|
||||
64
py/routes/recipe_route_registrar.py
Normal file
64
py/routes/recipe_route_registrar.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Route registrar for recipe endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Mapping
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RouteDefinition:
|
||||
"""Declarative definition for a recipe HTTP route."""
|
||||
|
||||
method: str
|
||||
path: str
|
||||
handler_name: str
|
||||
|
||||
|
||||
ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("GET", "/loras/recipes", "render_page"),
|
||||
RouteDefinition("GET", "/api/lm/recipes", "list_recipes"),
|
||||
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}", "get_recipe"),
|
||||
RouteDefinition("POST", "/api/lm/recipes/analyze-image", "analyze_uploaded_image"),
|
||||
RouteDefinition("POST", "/api/lm/recipes/analyze-local-image", "analyze_local_image"),
|
||||
RouteDefinition("POST", "/api/lm/recipes/save", "save_recipe"),
|
||||
RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/top-tags", "get_top_tags"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/base-models", "get_base_models"),
|
||||
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share", "share_recipe"),
|
||||
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share/download", "download_shared_recipe"),
|
||||
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"),
|
||||
RouteDefinition("PUT", "/api/lm/recipe/{recipe_id}/update", "update_recipe"),
|
||||
RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"),
|
||||
RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"),
|
||||
RouteDefinition("POST", "/api/lm/recipes/save-from-widget", "save_recipe_from_widget"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/for-lora", "get_recipes_for_lora"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/scan", "scan_recipes"),
|
||||
)
|
||||
|
||||
|
||||
class RecipeRouteRegistrar:
|
||||
"""Bind declarative recipe definitions to an aiohttp router."""
|
||||
|
||||
_METHOD_MAP = {
|
||||
"GET": "add_get",
|
||||
"POST": "add_post",
|
||||
"PUT": "add_put",
|
||||
"DELETE": "add_delete",
|
||||
}
|
||||
|
||||
def __init__(self, app: web.Application) -> None:
|
||||
self._app = app
|
||||
|
||||
def register_routes(self, handler_lookup: Mapping[str, Callable[[web.Request], object]]) -> None:
|
||||
for definition in ROUTE_DEFINITIONS:
|
||||
handler = handler_lookup[definition.handler_name]
|
||||
self._bind_route(definition.method, definition.path, handler)
|
||||
|
||||
def _bind_route(self, method: str, path: str, handler: Callable) -> None:
|
||||
add_method_name = self._METHOD_MAP[method.upper()]
|
||||
add_method = getattr(self._app.router, add_method_name)
|
||||
add_method(path, handler)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import os
|
||||
import time
|
||||
import base64
|
||||
import jinja2
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
import io
|
||||
@@ -12,20 +11,18 @@ import tempfile
|
||||
import json
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
from .base_recipe_routes import BaseRecipeRoutes
|
||||
from .recipe_route_registrar import RecipeRouteRegistrar
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
from ..recipes import RecipeParserFactory
|
||||
from ..utils.constants import CARD_PREVIEW_WIDTH
|
||||
|
||||
from ..services.settings_manager import settings
|
||||
from ..services.server_i18n import server_i18n
|
||||
from ..config import config
|
||||
from ..services.downloader import get_downloader
|
||||
|
||||
# Check if running in standalone mode
|
||||
standalone_mode = os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
|
||||
|
||||
from ..services.service_registry import ServiceRegistry # Add ServiceRegistry import
|
||||
from ..services.downloader import get_downloader
|
||||
|
||||
# Only import MetadataRegistry in non-standalone mode
|
||||
if not standalone_mode:
|
||||
# Import metadata_collector functions and classes conditionally
|
||||
@@ -35,111 +32,30 @@ if not standalone_mode:
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RecipeRoutes:
|
||||
"""API route handlers for Recipe management"""
|
||||
|
||||
def __init__(self):
|
||||
# Initialize service references as None, will be set during async init
|
||||
self.recipe_scanner = None
|
||||
self.civitai_client = None
|
||||
self.template_env = jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader(config.templates_path),
|
||||
autoescape=True
|
||||
)
|
||||
|
||||
# Pre-warm the cache
|
||||
self._init_cache_task = None
|
||||
|
||||
async def init_services(self):
|
||||
"""Initialize services from ServiceRegistry"""
|
||||
self.recipe_scanner = await ServiceRegistry.get_recipe_scanner()
|
||||
self.civitai_client = await ServiceRegistry.get_civitai_client()
|
||||
class RecipeRoutes(BaseRecipeRoutes):
|
||||
"""API route handlers for Recipe management."""
|
||||
|
||||
@classmethod
|
||||
def setup_routes(cls, app: web.Application):
|
||||
"""Register API routes"""
|
||||
"""Register API routes using the declarative registrar."""
|
||||
|
||||
routes = cls()
|
||||
app.router.add_get('/loras/recipes', routes.handle_recipes_page)
|
||||
registrar = RecipeRouteRegistrar(app)
|
||||
registrar.register_routes(routes.to_route_mapping())
|
||||
routes.register_startup_hooks(app)
|
||||
|
||||
app.router.add_get('/api/lm/recipes', routes.get_recipes)
|
||||
app.router.add_get('/api/lm/recipe/{recipe_id}', routes.get_recipe_detail)
|
||||
app.router.add_post('/api/lm/recipes/analyze-image', routes.analyze_recipe_image)
|
||||
app.router.add_post('/api/lm/recipes/analyze-local-image', routes.analyze_local_image)
|
||||
app.router.add_post('/api/lm/recipes/save', routes.save_recipe)
|
||||
app.router.add_delete('/api/lm/recipe/{recipe_id}', routes.delete_recipe)
|
||||
|
||||
# Add new filter-related endpoints
|
||||
app.router.add_get('/api/lm/recipes/top-tags', routes.get_top_tags)
|
||||
app.router.add_get('/api/lm/recipes/base-models', routes.get_base_models)
|
||||
|
||||
# Add new sharing endpoints
|
||||
app.router.add_get('/api/lm/recipe/{recipe_id}/share', routes.share_recipe)
|
||||
app.router.add_get('/api/lm/recipe/{recipe_id}/share/download', routes.download_shared_recipe)
|
||||
|
||||
# Add new endpoint for getting recipe syntax
|
||||
app.router.add_get('/api/lm/recipe/{recipe_id}/syntax', routes.get_recipe_syntax)
|
||||
|
||||
# Add new endpoint for updating recipe metadata (name, tags and source_path)
|
||||
app.router.add_put('/api/lm/recipe/{recipe_id}/update', routes.update_recipe)
|
||||
|
||||
# Add new endpoint for reconnecting deleted LoRAs
|
||||
app.router.add_post('/api/lm/recipe/lora/reconnect', routes.reconnect_lora)
|
||||
|
||||
# Add new endpoint for finding duplicate recipes
|
||||
app.router.add_get('/api/lm/recipes/find-duplicates', routes.find_duplicates)
|
||||
|
||||
# Add new endpoint for bulk deletion of recipes
|
||||
app.router.add_post('/api/lm/recipes/bulk-delete', routes.bulk_delete)
|
||||
|
||||
# Start cache initialization
|
||||
app.on_startup.append(routes._init_cache)
|
||||
|
||||
app.router.add_post('/api/lm/recipes/save-from-widget', routes.save_recipe_from_widget)
|
||||
|
||||
# Add route to get recipes for a specific Lora
|
||||
app.router.add_get('/api/lm/recipes/for-lora', routes.get_recipes_for_lora)
|
||||
|
||||
# Add new endpoint for scanning and rebuilding the recipe cache
|
||||
app.router.add_get('/api/lm/recipes/scan', routes.scan_recipes)
|
||||
|
||||
async def _init_cache(self, app):
|
||||
"""Initialize cache on startup"""
|
||||
async def render_page(self, request: web.Request) -> web.Response:
|
||||
"""Handle GET /loras/recipes request."""
|
||||
try:
|
||||
# Initialize services first
|
||||
await self.init_services()
|
||||
|
||||
# Now that services are initialized, get the lora scanner
|
||||
lora_scanner = self.recipe_scanner._lora_scanner
|
||||
|
||||
# Get lora cache to ensure it's initialized
|
||||
lora_cache = await lora_scanner.get_cached_data()
|
||||
|
||||
# Verify hash index is built
|
||||
if hasattr(lora_scanner, '_hash_index'):
|
||||
hash_index_size = len(lora_scanner._hash_index._hash_to_path) if hasattr(lora_scanner._hash_index, '_hash_to_path') else 0
|
||||
|
||||
# Now that lora scanner is initialized, initialize recipe cache
|
||||
await self.recipe_scanner.get_cached_data(force_refresh=True)
|
||||
except Exception as e:
|
||||
logger.error(f"Error pre-warming recipe cache: {e}", exc_info=True)
|
||||
await self.ensure_dependencies_ready()
|
||||
|
||||
async def handle_recipes_page(self, request: web.Request) -> web.Response:
|
||||
"""Handle GET /loras/recipes request"""
|
||||
try:
|
||||
# Ensure services are initialized
|
||||
await self.init_services()
|
||||
|
||||
# 获取用户语言设置
|
||||
user_language = settings.get('language', 'en')
|
||||
|
||||
user_language = self.settings.get('language', 'en')
|
||||
|
||||
# 设置服务端i18n语言
|
||||
server_i18n.set_locale(user_language)
|
||||
|
||||
# 为模板环境添加i18n过滤器
|
||||
if not hasattr(self.template_env, '_i18n_filter_added'):
|
||||
self.template_env.filters['t'] = server_i18n.create_template_filter()
|
||||
self.template_env._i18n_filter_added = True
|
||||
|
||||
self.server_i18n.set_locale(user_language)
|
||||
|
||||
# Skip initialization check and directly try to get cached data
|
||||
try:
|
||||
# Recipe scanner will initialize cache if needed
|
||||
@@ -148,10 +64,10 @@ class RecipeRoutes:
|
||||
rendered = template.render(
|
||||
recipes=[], # Frontend will load recipes via API
|
||||
is_initializing=False,
|
||||
settings=settings,
|
||||
settings=self.settings,
|
||||
request=request,
|
||||
# 添加服务端翻译函数
|
||||
t=server_i18n.get_translation,
|
||||
t=self.server_i18n.get_translation,
|
||||
)
|
||||
except Exception as cache_error:
|
||||
logger.error(f"Error loading recipe cache data: {cache_error}")
|
||||
@@ -159,10 +75,10 @@ class RecipeRoutes:
|
||||
template = self.template_env.get_template('recipes.html')
|
||||
rendered = template.render(
|
||||
is_initializing=True,
|
||||
settings=settings,
|
||||
settings=self.settings,
|
||||
request=request,
|
||||
# 添加服务端翻译函数
|
||||
t=server_i18n.get_translation,
|
||||
t=self.server_i18n.get_translation,
|
||||
)
|
||||
logger.info("Recipe cache error, returning initialization page")
|
||||
|
||||
@@ -178,11 +94,11 @@ class RecipeRoutes:
|
||||
status=500
|
||||
)
|
||||
|
||||
async def get_recipes(self, request: web.Request) -> web.Response:
|
||||
"""API endpoint for getting paginated recipes"""
|
||||
async def list_recipes(self, request: web.Request) -> web.Response:
|
||||
"""API endpoint for getting paginated recipes."""
|
||||
try:
|
||||
# Ensure services are initialized
|
||||
await self.init_services()
|
||||
await self.ensure_dependencies_ready()
|
||||
|
||||
# Get query parameters with defaults
|
||||
page = int(request.query.get('page', '1'))
|
||||
@@ -250,11 +166,11 @@ class RecipeRoutes:
|
||||
logger.error(f"Error retrieving recipes: {e}", exc_info=True)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
async def get_recipe_detail(self, request: web.Request) -> web.Response:
|
||||
"""Get detailed information about a specific recipe"""
|
||||
async def get_recipe(self, request: web.Request) -> web.Response:
|
||||
"""Get detailed information about a specific recipe."""
|
||||
try:
|
||||
# Ensure services are initialized
|
||||
await self.init_services()
|
||||
await self.ensure_dependencies_ready()
|
||||
|
||||
recipe_id = request.match_info['recipe_id']
|
||||
|
||||
@@ -305,12 +221,12 @@ class RecipeRoutes:
|
||||
from datetime import datetime
|
||||
return datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
async def analyze_recipe_image(self, request: web.Request) -> web.Response:
|
||||
"""Analyze an uploaded image or URL for recipe metadata"""
|
||||
async def analyze_uploaded_image(self, request: web.Request) -> web.Response:
|
||||
"""Analyze an uploaded image or URL for recipe metadata."""
|
||||
temp_path = None
|
||||
try:
|
||||
# Ensure services are initialized
|
||||
await self.init_services()
|
||||
await self.ensure_dependencies_ready()
|
||||
|
||||
# Check if request contains multipart data (image) or JSON data (url)
|
||||
content_type = request.headers.get('Content-Type', '')
|
||||
@@ -480,7 +396,7 @@ class RecipeRoutes:
|
||||
"""Analyze a local image file for recipe metadata"""
|
||||
try:
|
||||
# Ensure services are initialized
|
||||
await self.init_services()
|
||||
await self.ensure_dependencies_ready()
|
||||
|
||||
# Get JSON data from request
|
||||
data = await request.json()
|
||||
@@ -573,7 +489,7 @@ class RecipeRoutes:
|
||||
"""Save a recipe to the recipes folder"""
|
||||
try:
|
||||
# Ensure services are initialized
|
||||
await self.init_services()
|
||||
await self.ensure_dependencies_ready()
|
||||
|
||||
reader = await request.multipart()
|
||||
|
||||
@@ -779,7 +695,7 @@ class RecipeRoutes:
|
||||
"""Delete a recipe by ID"""
|
||||
try:
|
||||
# Ensure services are initialized
|
||||
await self.init_services()
|
||||
await self.ensure_dependencies_ready()
|
||||
|
||||
recipe_id = request.match_info['recipe_id']
|
||||
|
||||
@@ -829,7 +745,7 @@ class RecipeRoutes:
|
||||
"""Get top tags used in recipes"""
|
||||
try:
|
||||
# Ensure services are initialized
|
||||
await self.init_services()
|
||||
await self.ensure_dependencies_ready()
|
||||
|
||||
# Get limit parameter with default
|
||||
limit = int(request.query.get('limit', '20'))
|
||||
@@ -864,7 +780,7 @@ class RecipeRoutes:
|
||||
"""Get base models used in recipes"""
|
||||
try:
|
||||
# Ensure services are initialized
|
||||
await self.init_services()
|
||||
await self.ensure_dependencies_ready()
|
||||
|
||||
# Get all recipes from cache
|
||||
cache = await self.recipe_scanner.get_cached_data()
|
||||
@@ -895,7 +811,7 @@ class RecipeRoutes:
|
||||
"""Process a recipe image for sharing by adding metadata to EXIF"""
|
||||
try:
|
||||
# Ensure services are initialized
|
||||
await self.init_services()
|
||||
await self.ensure_dependencies_ready()
|
||||
|
||||
recipe_id = request.match_info['recipe_id']
|
||||
|
||||
@@ -957,7 +873,7 @@ class RecipeRoutes:
|
||||
"""Serve a processed recipe image for download"""
|
||||
try:
|
||||
# Ensure services are initialized
|
||||
await self.init_services()
|
||||
await self.ensure_dependencies_ready()
|
||||
|
||||
recipe_id = request.match_info['recipe_id']
|
||||
|
||||
@@ -1016,7 +932,7 @@ class RecipeRoutes:
|
||||
"""Save a recipe from the LoRAs widget"""
|
||||
try:
|
||||
# Ensure services are initialized
|
||||
await self.init_services()
|
||||
await self.ensure_dependencies_ready()
|
||||
|
||||
# Get metadata using the metadata collector instead of workflow parsing
|
||||
raw_metadata = get_metadata()
|
||||
@@ -1216,7 +1132,7 @@ class RecipeRoutes:
|
||||
"""Generate recipe syntax for LoRAs in the recipe, looking up proper file names using hash_index"""
|
||||
try:
|
||||
# Ensure services are initialized
|
||||
await self.init_services()
|
||||
await self.ensure_dependencies_ready()
|
||||
|
||||
recipe_id = request.match_info['recipe_id']
|
||||
|
||||
@@ -1299,7 +1215,7 @@ class RecipeRoutes:
|
||||
"""Update recipe metadata (name and tags)"""
|
||||
try:
|
||||
# Ensure services are initialized
|
||||
await self.init_services()
|
||||
await self.ensure_dependencies_ready()
|
||||
|
||||
recipe_id = request.match_info['recipe_id']
|
||||
data = await request.json()
|
||||
@@ -1329,7 +1245,7 @@ class RecipeRoutes:
|
||||
"""Reconnect a deleted LoRA in a recipe to a local LoRA file"""
|
||||
try:
|
||||
# Ensure services are initialized
|
||||
await self.init_services()
|
||||
await self.ensure_dependencies_ready()
|
||||
|
||||
# Parse request data
|
||||
data = await request.json()
|
||||
@@ -1438,7 +1354,7 @@ class RecipeRoutes:
|
||||
"""Get recipes that use a specific Lora"""
|
||||
try:
|
||||
# Ensure services are initialized
|
||||
await self.init_services()
|
||||
await self.ensure_dependencies_ready()
|
||||
|
||||
lora_hash = request.query.get('hash')
|
||||
|
||||
@@ -1487,7 +1403,7 @@ class RecipeRoutes:
|
||||
"""API endpoint for scanning and rebuilding the recipe cache"""
|
||||
try:
|
||||
# Ensure services are initialized
|
||||
await self.init_services()
|
||||
await self.ensure_dependencies_ready()
|
||||
|
||||
# Force refresh the recipe cache
|
||||
logger.info("Manually triggering recipe cache rebuild")
|
||||
@@ -1508,7 +1424,7 @@ class RecipeRoutes:
|
||||
"""Find all duplicate recipes based on fingerprints"""
|
||||
try:
|
||||
# Ensure services are initialized
|
||||
await self.init_services()
|
||||
await self.ensure_dependencies_ready()
|
||||
|
||||
# Get all duplicate recipes
|
||||
duplicate_groups = await self.recipe_scanner.find_all_duplicate_recipes()
|
||||
@@ -1566,7 +1482,7 @@ class RecipeRoutes:
|
||||
"""Delete multiple recipes by ID"""
|
||||
try:
|
||||
# Ensure services are initialized
|
||||
await self.init_services()
|
||||
await self.ensure_dependencies_ready()
|
||||
|
||||
# Parse request data
|
||||
data = await request.json()
|
||||
@@ -1650,3 +1566,9 @@ class RecipeRoutes:
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
# Legacy method aliases retained for compatibility with existing imports.
|
||||
handle_recipes_page = render_page
|
||||
get_recipes = list_recipes
|
||||
get_recipe_detail = get_recipe
|
||||
analyze_recipe_image = analyze_uploaded_image
|
||||
|
||||
229
tests/routes/test_recipe_route_scaffolding.py
Normal file
229
tests/routes/test_recipe_route_scaffolding.py
Normal file
@@ -0,0 +1,229 @@
|
||||
"""Smoke tests for the recipe routing scaffolding.
|
||||
|
||||
The cases keep the registrar/controller contract aligned with
|
||||
``docs/architecture/recipe_routes.md`` so future refactors can focus on handler
|
||||
logic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import sys
|
||||
import types
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable, Dict
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
PY_PACKAGE_PATH = REPO_ROOT / "py"
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"py_local",
|
||||
PY_PACKAGE_PATH / "__init__.py",
|
||||
submodule_search_locations=[str(PY_PACKAGE_PATH)],
|
||||
)
|
||||
py_local = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(py_local)
|
||||
sys.modules.setdefault("py_local", py_local)
|
||||
|
||||
base_routes_module = importlib.import_module("py_local.routes.base_recipe_routes")
|
||||
recipe_routes_module = importlib.import_module("py_local.routes.recipe_routes")
|
||||
registrar_module = importlib.import_module("py_local.routes.recipe_route_registrar")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_service_registry(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Ensure each test starts from a clean registry state."""
|
||||
|
||||
services_module = importlib.import_module("py_local.services.service_registry")
|
||||
registry = services_module.ServiceRegistry
|
||||
previous_services = dict(registry._services)
|
||||
previous_locks = dict(registry._locks)
|
||||
registry._services.clear()
|
||||
registry._locks.clear()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
registry._services = previous_services
|
||||
registry._locks = previous_locks
|
||||
|
||||
|
||||
def _make_stub_scanner():
|
||||
class _StubScanner:
|
||||
def __init__(self):
|
||||
self._cache = types.SimpleNamespace()
|
||||
|
||||
async def _lora_get_cached_data(): # pragma: no cover - smoke hook
|
||||
return None
|
||||
|
||||
self._lora_scanner = types.SimpleNamespace(
|
||||
get_cached_data=_lora_get_cached_data,
|
||||
_hash_index=types.SimpleNamespace(_hash_to_path={}),
|
||||
)
|
||||
|
||||
async def get_cached_data(self, force_refresh: bool = False):
|
||||
return self._cache
|
||||
|
||||
return _StubScanner()
|
||||
|
||||
|
||||
def test_attach_dependencies_resolves_services_once(monkeypatch: pytest.MonkeyPatch):
|
||||
base_module = base_routes_module
|
||||
services_module = importlib.import_module("py_local.services.service_registry")
|
||||
registry = services_module.ServiceRegistry
|
||||
server_i18n = importlib.import_module("py_local.services.server_i18n").server_i18n
|
||||
|
||||
scanner = _make_stub_scanner()
|
||||
civitai_client = object()
|
||||
filter_calls = Counter()
|
||||
|
||||
async def fake_get_recipe_scanner():
|
||||
return scanner
|
||||
|
||||
async def fake_get_civitai_client():
|
||||
return civitai_client
|
||||
|
||||
def fake_create_filter():
|
||||
filter_calls["create_filter"] += 1
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(registry, "get_recipe_scanner", fake_get_recipe_scanner)
|
||||
monkeypatch.setattr(registry, "get_civitai_client", fake_get_civitai_client)
|
||||
monkeypatch.setattr(server_i18n, "create_template_filter", fake_create_filter)
|
||||
|
||||
async def scenario():
|
||||
routes = base_module.BaseRecipeRoutes()
|
||||
|
||||
await routes.attach_dependencies()
|
||||
await routes.attach_dependencies() # idempotent
|
||||
|
||||
assert routes.recipe_scanner is scanner
|
||||
assert routes.lora_scanner is scanner._lora_scanner
|
||||
assert routes.civitai_client is civitai_client
|
||||
assert routes.template_env.filters["t"] is not None
|
||||
assert filter_calls["create_filter"] == 1
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_register_startup_hooks_appends_once():
|
||||
routes = base_routes_module.BaseRecipeRoutes()
|
||||
|
||||
app = web.Application()
|
||||
routes.register_startup_hooks(app)
|
||||
routes.register_startup_hooks(app)
|
||||
|
||||
startup_bound_to_routes = [
|
||||
callback for callback in app.on_startup if getattr(callback, "__self__", None) is routes
|
||||
]
|
||||
|
||||
assert routes.attach_dependencies in startup_bound_to_routes
|
||||
assert routes.prewarm_cache in startup_bound_to_routes
|
||||
assert len(startup_bound_to_routes) == 2
|
||||
|
||||
|
||||
def test_to_route_mapping_uses_handler_owner(monkeypatch: pytest.MonkeyPatch):
|
||||
class DummyOwner:
|
||||
async def render_page(self, request):
|
||||
return web.Response(text="ok")
|
||||
|
||||
async def list_recipes(self, request): # pragma: no cover - invoked via mapping
|
||||
return web.json_response({})
|
||||
|
||||
class DummyRoutes(base_routes_module.BaseRecipeRoutes):
|
||||
def get_handler_owner(self): # noqa: D401 - simple override for test
|
||||
return DummyOwner()
|
||||
|
||||
monkeypatch.setattr(
|
||||
base_routes_module.BaseRecipeRoutes,
|
||||
"_HANDLER_NAMES",
|
||||
("render_page", "list_recipes"),
|
||||
)
|
||||
|
||||
routes = DummyRoutes()
|
||||
mapping = routes.to_route_mapping()
|
||||
|
||||
assert set(mapping.keys()) == {"render_page", "list_recipes"}
|
||||
assert asyncio.iscoroutinefunction(mapping["render_page"])
|
||||
# Cached mapping reused on subsequent calls
|
||||
assert routes.to_route_mapping() is mapping
|
||||
|
||||
|
||||
def test_recipe_route_registrar_binds_every_route():
|
||||
class FakeRouter:
|
||||
def __init__(self):
|
||||
self.calls: list[tuple[str, str, Callable[..., Awaitable[Any]]]] = []
|
||||
|
||||
def add_get(self, path, handler):
|
||||
self.calls.append(("GET", path, handler))
|
||||
|
||||
def add_post(self, path, handler):
|
||||
self.calls.append(("POST", path, handler))
|
||||
|
||||
def add_put(self, path, handler):
|
||||
self.calls.append(("PUT", path, handler))
|
||||
|
||||
def add_delete(self, path, handler):
|
||||
self.calls.append(("DELETE", path, handler))
|
||||
|
||||
class FakeApp:
|
||||
def __init__(self):
|
||||
self.router = FakeRouter()
|
||||
|
||||
app = FakeApp()
|
||||
registrar = registrar_module.RecipeRouteRegistrar(app)
|
||||
|
||||
handler_mapping = {
|
||||
definition.handler_name: object()
|
||||
for definition in registrar_module.ROUTE_DEFINITIONS
|
||||
}
|
||||
|
||||
registrar.register_routes(handler_mapping)
|
||||
|
||||
assert {
|
||||
(method, path)
|
||||
for method, path, _ in app.router.calls
|
||||
} == {(d.method, d.path) for d in registrar_module.ROUTE_DEFINITIONS}
|
||||
|
||||
|
||||
def test_recipe_routes_setup_routes_uses_registrar(monkeypatch: pytest.MonkeyPatch):
|
||||
registered_mappings: list[Dict[str, Callable[..., Awaitable[Any]]]] = []
|
||||
|
||||
class DummyRegistrar:
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
def register_routes(self, mapping):
|
||||
registered_mappings.append(mapping)
|
||||
|
||||
monkeypatch.setattr(recipe_routes_module, "RecipeRouteRegistrar", DummyRegistrar)
|
||||
|
||||
expected_mapping = {name: object() for name in ("render_page", "list_recipes")}
|
||||
|
||||
def fake_to_route_mapping(self):
|
||||
return expected_mapping
|
||||
|
||||
monkeypatch.setattr(base_routes_module.BaseRecipeRoutes, "to_route_mapping", fake_to_route_mapping)
|
||||
monkeypatch.setattr(
|
||||
base_routes_module.BaseRecipeRoutes,
|
||||
"_HANDLER_NAMES",
|
||||
tuple(expected_mapping.keys()),
|
||||
)
|
||||
|
||||
app = web.Application()
|
||||
recipe_routes_module.RecipeRoutes.setup_routes(app)
|
||||
|
||||
assert registered_mappings == [expected_mapping]
|
||||
recipe_callbacks = {
|
||||
cb
|
||||
for cb in app.on_startup
|
||||
if isinstance(getattr(cb, "__self__", None), recipe_routes_module.RecipeRoutes)
|
||||
}
|
||||
assert {type(cb.__self__) for cb in recipe_callbacks} == {recipe_routes_module.RecipeRoutes}
|
||||
assert {cb.__name__ for cb in recipe_callbacks} == {"attach_dependencies", "prewarm_cache"}
|
||||
Reference in New Issue
Block a user