From feb82fc4f707e864ff18119316112bd645556917 Mon Sep 17 00:00:00 2001 From: peterschmidt85 Date: Thu, 12 Feb 2026 10:47:51 +0100 Subject: [PATCH 1/2] Add server API for UI templates Add POST /api/project/{project_name}/templates/list endpoint that serves UI templates from an external git repo configured via DSTACK_SERVER_TEMPLATES_REPO. Templates are YAML files under .dstack/templates/ in the repo, parsed into typed pydantic models with a discriminated union for parameter types. Results are cached with a 3-minute TTL using cachetools.TTLCache. Currently returns only server-wide templates; project-specific templates will be added in a future iteration. Co-authored-by: Cursor --- src/dstack/_internal/core/models/templates.py | 70 +++++ src/dstack/_internal/server/app.py | 2 + .../_internal/server/routers/templates.py | 21 ++ .../_internal/server/services/templates.py | 99 ++++++ src/dstack/_internal/server/settings.py | 2 + .../_internal/core/models/test_templates.py | 199 ++++++++++++ .../server/routers/test_templates.py | 116 +++++++ .../server/services/test_templates.py | 292 ++++++++++++++++++ 8 files changed, 801 insertions(+) create mode 100644 src/dstack/_internal/core/models/templates.py create mode 100644 src/dstack/_internal/server/routers/templates.py create mode 100644 src/dstack/_internal/server/services/templates.py create mode 100644 src/tests/_internal/core/models/test_templates.py create mode 100644 src/tests/_internal/server/routers/test_templates.py create mode 100644 src/tests/_internal/server/services/test_templates.py diff --git a/src/dstack/_internal/core/models/templates.py b/src/dstack/_internal/core/models/templates.py new file mode 100644 index 0000000000..9bce32f02f --- /dev/null +++ b/src/dstack/_internal/core/models/templates.py @@ -0,0 +1,70 @@ +from typing import Annotated, Any, Dict, List, Literal, Optional, Union + +from pydantic import Field + +from dstack._internal.core.models.common import CoreModel + + +class BaseUITemplateParameter(CoreModel): + """Base for all UI template parameters.""" + + pass + + +class NameUITemplateParameter(BaseUITemplateParameter): + type: Annotated[Literal["name"], Field(description="The parameter type")] + + +class IDEUITemplateParameter(BaseUITemplateParameter): + type: Annotated[Literal["ide"], Field(description="The parameter type")] + + +class ResourcesUITemplateParameter(BaseUITemplateParameter): + type: Annotated[Literal["resources"], Field(description="The parameter type")] + + +class PythonOrDockerUITemplateParameter(BaseUITemplateParameter): + type: Annotated[Literal["python_or_docker"], Field(description="The parameter type")] + + +class RepoUITemplateParameter(BaseUITemplateParameter): + type: Annotated[Literal["repo"], Field(description="The parameter type")] + + +class WorkingDirUITemplateParameter(BaseUITemplateParameter): + type: Annotated[Literal["working_dir"], Field(description="The parameter type")] + + +class EnvUITemplateParameter(BaseUITemplateParameter): + type: Annotated[Literal["env"], Field(description="The parameter type")] + title: Annotated[Optional[str], Field(description="The display title")] = None + name: Annotated[Optional[str], Field(description="The environment variable name")] = None + value: Annotated[Optional[str], Field(description="The default value")] = None + + +AnyUITemplateParameter = Annotated[ + Union[ + NameUITemplateParameter, + IDEUITemplateParameter, + ResourcesUITemplateParameter, + PythonOrDockerUITemplateParameter, + RepoUITemplateParameter, + WorkingDirUITemplateParameter, + EnvUITemplateParameter, + ], + Field(discriminator="type"), +] + + +class UITemplate(CoreModel): + type: Annotated[Literal["ui-template"], Field(description="The template type")] + id: Annotated[str, Field(description="The unique template identifier")] + title: Annotated[str, Field(description="The human-readable template name")] + parameters: Annotated[ + List[AnyUITemplateParameter], + Field(description="The template parameters"), + ] = [] + template: Annotated[ + Dict[str, Any], + Field(description="The dstack run configuration"), + ] diff --git a/src/dstack/_internal/server/app.py b/src/dstack/_internal/server/app.py index dbea6f777b..4f391ad6b9 100644 --- a/src/dstack/_internal/server/app.py +++ b/src/dstack/_internal/server/app.py @@ -43,6 +43,7 @@ runs, secrets, server, + templates, users, volumes, ) @@ -240,6 +241,7 @@ def register_routes(app: FastAPI, ui: bool = True): app.include_router(prometheus.router) app.include_router(files.router) app.include_router(events.root_router) + app.include_router(templates.router) @app.exception_handler(ForbiddenError) async def forbidden_error_handler(request: Request, exc: ForbiddenError): diff --git a/src/dstack/_internal/server/routers/templates.py b/src/dstack/_internal/server/routers/templates.py new file mode 100644 index 0000000000..92f5d05018 --- /dev/null +++ b/src/dstack/_internal/server/routers/templates.py @@ -0,0 +1,21 @@ +from typing import List, Tuple + +from fastapi import APIRouter, Depends + +from dstack._internal.core.models.templates import UITemplate +from dstack._internal.server.models import ProjectModel, UserModel +from dstack._internal.server.security.permissions import ProjectMember +from dstack._internal.server.services import templates as templates_service +from dstack._internal.server.utils.routers import CustomORJSONResponse + +router = APIRouter( + prefix="/api/project/{project_name}/templates", + tags=["templates"], +) + + +@router.post("/list", response_model=List[UITemplate]) +async def list_templates( + user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectMember()), +): + return CustomORJSONResponse(await templates_service.list_templates()) diff --git a/src/dstack/_internal/server/services/templates.py b/src/dstack/_internal/server/services/templates.py new file mode 100644 index 0000000000..39e8cb70cf --- /dev/null +++ b/src/dstack/_internal/server/services/templates.py @@ -0,0 +1,99 @@ +import shutil +import threading +from pathlib import Path +from typing import List, Optional + +import git +import yaml +from cachetools import TTLCache, cached + +from dstack._internal.core.models.templates import UITemplate +from dstack._internal.server import settings +from dstack._internal.utils.common import run_async +from dstack._internal.utils.logging import get_logger + +logger = get_logger(__name__) + +TEMPLATES_DIR_NAME = ".dstack/templates" +CACHE_TTL_SECONDS = 180 + +_repo_path: Optional[Path] = None +_templates_cache: TTLCache = TTLCache(maxsize=1, ttl=CACHE_TTL_SECONDS) +_templates_lock = threading.Lock() + + +async def list_templates() -> List[UITemplate]: + """Return templates available for the UI. + + Currently returns only server-wide templates configured via DSTACK_SERVER_TEMPLATES_REPO. + Project-specific templates will be included once implemented. + """ + if not settings.SERVER_TEMPLATES_REPO: + return [] + return await run_async(_list_templates_sync) + + +@cached(cache=_templates_cache, lock=_templates_lock) +def _list_templates_sync() -> List[UITemplate]: + _fetch_templates_repo() + return _parse_templates() + + +def _fetch_templates_repo() -> None: + global _repo_path + + repo_dir = settings.SERVER_DATA_DIR_PATH / "templates-repo" + + if _repo_path is not None and _repo_path.exists(): + repo = git.Repo(str(_repo_path)) + repo.remotes.origin.pull() + return + + if repo_dir.exists(): + try: + repo = git.Repo(str(repo_dir)) + repo.remotes.origin.pull() + _repo_path = repo_dir + return + except (git.InvalidGitRepositoryError, git.GitCommandError): + logger.warning("Invalid templates repo at %s, re-cloning", repo_dir) + shutil.rmtree(repo_dir) + + assert settings.SERVER_TEMPLATES_REPO is not None + git.Repo.clone_from( + settings.SERVER_TEMPLATES_REPO, + str(repo_dir), + depth=1, + ) + _repo_path = repo_dir + + +def _parse_templates() -> List[UITemplate]: + if _repo_path is None: + return [] + + templates_dir = _repo_path / TEMPLATES_DIR_NAME + if not templates_dir.is_dir(): + logger.warning("Templates directory %s not found in repo", TEMPLATES_DIR_NAME) + return [] + + templates: List[UITemplate] = [] + for entry in sorted(templates_dir.iterdir()): + if entry.suffix not in (".yml", ".yaml"): + continue + try: + with open(entry) as f: + data = yaml.safe_load(f) + if not isinstance(data, dict): + logger.warning("Skipping %s: not a valid YAML mapping", entry.name) + continue + if data.get("type") != "ui-template": + logger.debug("Skipping %s: type is not 'ui-template'", entry.name) + continue + template = UITemplate.parse_obj(data) + templates.append(template) + except Exception: + logger.warning("Skipping invalid template %s", entry.name, exc_info=True) + continue + + return templates diff --git a/src/dstack/_internal/server/settings.py b/src/dstack/_internal/server/settings.py index 6e5c8e4bc1..71a43a30b0 100644 --- a/src/dstack/_internal/server/settings.py +++ b/src/dstack/_internal/server/settings.py @@ -131,6 +131,8 @@ SERVER_CODE_UPLOAD_LIMIT = int(os.getenv("DSTACK_SERVER_CODE_UPLOAD_LIMIT", 2 * 2**20)) +SERVER_TEMPLATES_REPO = os.getenv("DSTACK_SERVER_TEMPLATES_REPO") + # Development settings SQL_ECHO_ENABLED = os.getenv("DSTACK_SQL_ECHO_ENABLED") is not None diff --git a/src/tests/_internal/core/models/test_templates.py b/src/tests/_internal/core/models/test_templates.py new file mode 100644 index 0000000000..19acb86460 --- /dev/null +++ b/src/tests/_internal/core/models/test_templates.py @@ -0,0 +1,199 @@ +import pytest +from pydantic import ValidationError + +from dstack._internal.core.models.templates import ( + EnvUITemplateParameter, + IDEUITemplateParameter, + NameUITemplateParameter, + PythonOrDockerUITemplateParameter, + RepoUITemplateParameter, + ResourcesUITemplateParameter, + UITemplate, + WorkingDirUITemplateParameter, +) + + +class TestUITemplateParameter: + def test_parses_name_parameter(self): + data = {"type": "name"} + template = UITemplate.parse_obj( + {"type": "ui-template", "id": "t", "title": "T", "parameters": [data], "template": {}} + ) + assert len(template.parameters) == 1 + assert isinstance(template.parameters[0], NameUITemplateParameter) + + def test_parses_ide_parameter(self): + data = {"type": "ide"} + template = UITemplate.parse_obj( + {"type": "ui-template", "id": "t", "title": "T", "parameters": [data], "template": {}} + ) + assert isinstance(template.parameters[0], IDEUITemplateParameter) + + def test_parses_resources_parameter(self): + data = {"type": "resources"} + template = UITemplate.parse_obj( + {"type": "ui-template", "id": "t", "title": "T", "parameters": [data], "template": {}} + ) + assert isinstance(template.parameters[0], ResourcesUITemplateParameter) + + def test_parses_python_or_docker_parameter(self): + data = {"type": "python_or_docker"} + template = UITemplate.parse_obj( + {"type": "ui-template", "id": "t", "title": "T", "parameters": [data], "template": {}} + ) + assert isinstance(template.parameters[0], PythonOrDockerUITemplateParameter) + + def test_parses_repo_parameter(self): + data = {"type": "repo"} + template = UITemplate.parse_obj( + {"type": "ui-template", "id": "t", "title": "T", "parameters": [data], "template": {}} + ) + assert isinstance(template.parameters[0], RepoUITemplateParameter) + + def test_parses_working_dir_parameter(self): + data = {"type": "working_dir"} + template = UITemplate.parse_obj( + {"type": "ui-template", "id": "t", "title": "T", "parameters": [data], "template": {}} + ) + assert isinstance(template.parameters[0], WorkingDirUITemplateParameter) + + def test_parses_env_parameter_with_all_fields(self): + data = { + "type": "env", + "title": "Password", + "name": "PASSWORD", + "value": "$random-password", + } + template = UITemplate.parse_obj( + {"type": "ui-template", "id": "t", "title": "T", "parameters": [data], "template": {}} + ) + param = template.parameters[0] + assert isinstance(param, EnvUITemplateParameter) + assert param.title == "Password" + assert param.name == "PASSWORD" + assert param.value == "$random-password" + + def test_parses_env_parameter_with_no_optional_fields(self): + data = {"type": "env"} + template = UITemplate.parse_obj( + {"type": "ui-template", "id": "t", "title": "T", "parameters": [data], "template": {}} + ) + param = template.parameters[0] + assert isinstance(param, EnvUITemplateParameter) + assert param.title is None + assert param.name is None + assert param.value is None + + def test_rejects_unknown_parameter_type(self): + data = {"type": "unknown_type"} + with pytest.raises(ValidationError): + UITemplate.parse_obj( + { + "type": "ui-template", + "id": "t", + "title": "T", + "parameters": [data], + "template": {}, + } + ) + + +class TestUITemplate: + def test_parses_desktop_ide_template(self): + data = { + "type": "ui-template", + "id": "desktop-ide", + "title": "Desktop IDE", + "parameters": [ + {"type": "name"}, + {"type": "ide"}, + {"type": "resources"}, + {"type": "python_or_docker"}, + {"type": "repo"}, + {"type": "working_dir"}, + ], + "template": {"type": "dev-environment"}, + } + template = UITemplate.parse_obj(data) + assert template.id == "desktop-ide" + assert template.title == "Desktop IDE" + assert len(template.parameters) == 6 + assert template.template == {"type": "dev-environment"} + + def test_parses_web_based_ide_template(self): + data = { + "type": "ui-template", + "id": "web-based-ide", + "title": "Web-based IDE", + "parameters": [ + {"type": "name"}, + {"type": "resources"}, + {"type": "python_or_docker"}, + {"type": "repo"}, + {"type": "working_dir"}, + { + "type": "env", + "title": "Password", + "name": "PASSWORD", + "value": "$random-password", + }, + ], + "template": { + "type": "service", + "auth": False, + "https": False, + "env": ["BIND_ADDR=0.0.0.0:8080"], + "commands": ["echo hello"], + "port": 8080, + "probes": [{"type": "http", "url": "/healthz"}], + }, + } + template = UITemplate.parse_obj(data) + assert template.id == "web-based-ide" + assert template.title == "Web-based IDE" + assert len(template.parameters) == 6 + assert isinstance(template.parameters[5], EnvUITemplateParameter) + assert template.template["type"] == "service" + assert template.template["port"] == 8080 + + def test_rejects_wrong_type(self): + with pytest.raises(ValidationError): + UITemplate.parse_obj( + { + "type": "not-a-template", + "id": "t", + "title": "T", + "template": {}, + } + ) + + def test_rejects_missing_template(self): + with pytest.raises(ValidationError): + UITemplate.parse_obj( + { + "type": "ui-template", + "id": "t", + "title": "T", + } + ) + + def test_rejects_missing_id(self): + with pytest.raises(ValidationError): + UITemplate.parse_obj( + { + "type": "ui-template", + "title": "T", + "template": {}, + } + ) + + def test_empty_parameters_default(self): + template = UITemplate.parse_obj( + { + "type": "ui-template", + "id": "t", + "title": "T", + "template": {"type": "task"}, + } + ) + assert template.parameters == [] diff --git a/src/tests/_internal/server/routers/test_templates.py b/src/tests/_internal/server/routers/test_templates.py new file mode 100644 index 0000000000..62ee4eeeb7 --- /dev/null +++ b/src/tests/_internal/server/routers/test_templates.py @@ -0,0 +1,116 @@ +from pathlib import Path +from unittest.mock import patch + +import pytest +import yaml +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from dstack._internal.core.models.users import GlobalRole, ProjectRole +from dstack._internal.server.services import templates as templates_service +from dstack._internal.server.services.projects import add_project_member +from dstack._internal.server.testing.common import ( + create_project, + create_user, + get_auth_headers, +) + + +@pytest.fixture(autouse=True) +def _reset_cache(): + """Reset the templates cache before each test.""" + templates_service._templates_cache.clear() + templates_service._repo_path = None + yield + templates_service._templates_cache.clear() + templates_service._repo_path = None + + +class TestListTemplates: + @pytest.mark.asyncio + async def test_returns_403_if_not_authenticated(self, test_db, client: AsyncClient): + response = await client.post("/api/project/test_project/templates/list") + assert response.status_code == 403 + + @pytest.mark.asyncio + async def test_returns_empty_list_when_no_repo( + self, test_db, session: AsyncSession, client: AsyncClient + ): + user = await create_user(session=session, global_role=GlobalRole.USER) + project = await create_project(session=session, owner=user) + await add_project_member( + session=session, project=project, user=user, project_role=ProjectRole.USER + ) + with patch.object(templates_service.settings, "SERVER_TEMPLATES_REPO", None): + response = await client.post( + f"/api/project/{project.name}/templates/list", + headers=get_auth_headers(user.token), + ) + assert response.status_code == 200 + assert response.json() == [] + + @pytest.mark.asyncio + async def test_returns_templates( + self, test_db, session: AsyncSession, client: AsyncClient, tmp_path: Path + ): + user = await create_user(session=session, global_role=GlobalRole.USER) + project = await create_project(session=session, owner=user) + await add_project_member( + session=session, project=project, user=user, project_role=ProjectRole.USER + ) + templates_dir = tmp_path / ".dstack" / "templates" + templates_dir.mkdir(parents=True) + for filename, data in [ + ( + "desktop-ide.yml", + { + "type": "ui-template", + "id": "desktop-ide", + "title": "Desktop IDE", + "parameters": [{"type": "name"}, {"type": "ide"}], + "template": {"type": "dev-environment"}, + }, + ), + ( + "web-based-ide.yml", + { + "type": "ui-template", + "id": "web-based-ide", + "title": "Web-based IDE", + "parameters": [ + {"type": "name"}, + { + "type": "env", + "title": "Password", + "name": "PASSWORD", + "value": "$random-password", + }, + ], + "template": {"type": "service", "port": 8080}, + }, + ), + ]: + with open(templates_dir / filename, "w") as f: + yaml.dump(data, f) + + with ( + patch.object( + templates_service.settings, "SERVER_TEMPLATES_REPO", "https://example.com" + ), + patch.object(templates_service, "_fetch_templates_repo"), + ): + templates_service._repo_path = tmp_path + response = await client.post( + f"/api/project/{project.name}/templates/list", + headers=get_auth_headers(user.token), + ) + + assert response.status_code == 200 + data = response.json() + assert len(data) == 2 + assert data[0]["id"] == "desktop-ide" + assert data[0]["template"]["type"] == "dev-environment" + assert data[1]["id"] == "web-based-ide" + assert data[1]["parameters"][1]["type"] == "env" + assert data[1]["parameters"][1]["name"] == "PASSWORD" + assert data[1]["template"]["port"] == 8080 diff --git a/src/tests/_internal/server/services/test_templates.py b/src/tests/_internal/server/services/test_templates.py new file mode 100644 index 0000000000..0b17d51488 --- /dev/null +++ b/src/tests/_internal/server/services/test_templates.py @@ -0,0 +1,292 @@ +from pathlib import Path +from unittest.mock import patch + +import pytest +import yaml + +from dstack._internal.core.models.templates import ( + EnvUITemplateParameter, + NameUITemplateParameter, +) +from dstack._internal.server.services import templates as templates_service + + +@pytest.fixture(autouse=True) +def _reset_cache(): + """Reset the templates cache before each test.""" + templates_service._templates_cache.clear() + templates_service._repo_path = None + yield + templates_service._templates_cache.clear() + templates_service._repo_path = None + + +def _create_template_file(templates_dir: Path, filename: str, data: dict) -> Path: + filepath = templates_dir / filename + with open(filepath, "w") as f: + yaml.dump(data, f) + return filepath + + +def _create_templates_repo(tmp_path: Path) -> Path: + """Create a fake templates repo directory with .dstack/templates/.""" + templates_dir = tmp_path / ".dstack" / "templates" + templates_dir.mkdir(parents=True) + return templates_dir + + +class TestListTemplates: + @pytest.mark.asyncio + async def test_returns_empty_when_no_repo_configured(self): + with patch.object(templates_service.settings, "SERVER_TEMPLATES_REPO", None): + result = await templates_service.list_templates() + assert result == [] + + +class TestParseTemplates: + def test_returns_empty_when_repo_path_is_none(self): + result = templates_service._parse_templates() + assert result == [] + + def test_returns_empty_when_templates_dir_missing(self, tmp_path: Path): + templates_service._repo_path = tmp_path + result = templates_service._parse_templates() + assert result == [] + + def test_parses_valid_template(self, tmp_path: Path): + templates_dir = _create_templates_repo(tmp_path) + _create_template_file( + templates_dir, + "test.yml", + { + "type": "ui-template", + "id": "test-template", + "title": "Test Template", + "parameters": [{"type": "name"}], + "template": {"type": "dev-environment"}, + }, + ) + templates_service._repo_path = tmp_path + result = templates_service._parse_templates() + assert len(result) == 1 + assert result[0].id == "test-template" + assert isinstance(result[0].parameters[0], NameUITemplateParameter) + + def test_parses_template_with_env_parameter(self, tmp_path: Path): + templates_dir = _create_templates_repo(tmp_path) + _create_template_file( + templates_dir, + "test.yml", + { + "type": "ui-template", + "id": "test", + "title": "Test", + "parameters": [ + {"type": "env", "title": "Password", "name": "PASSWORD", "value": "secret"} + ], + "template": {"type": "service"}, + }, + ) + templates_service._repo_path = tmp_path + result = templates_service._parse_templates() + assert len(result) == 1 + param = result[0].parameters[0] + assert isinstance(param, EnvUITemplateParameter) + assert param.title == "Password" + assert param.name == "PASSWORD" + assert param.value == "secret" + + def test_skips_non_yaml_files(self, tmp_path: Path): + templates_dir = _create_templates_repo(tmp_path) + _create_template_file( + templates_dir, + "valid.yml", + { + "type": "ui-template", + "id": "valid", + "title": "Valid", + "template": {"type": "task"}, + }, + ) + (templates_dir / "readme.txt").write_text("not a template") + templates_service._repo_path = tmp_path + result = templates_service._parse_templates() + assert len(result) == 1 + assert result[0].id == "valid" + + def test_skips_non_ui_template_type(self, tmp_path: Path): + templates_dir = _create_templates_repo(tmp_path) + _create_template_file( + templates_dir, + "other.yml", + {"type": "something-else", "id": "other", "title": "Other"}, + ) + templates_service._repo_path = tmp_path + result = templates_service._parse_templates() + assert result == [] + + def test_skips_invalid_yaml(self, tmp_path: Path): + templates_dir = _create_templates_repo(tmp_path) + (templates_dir / "bad.yml").write_text(": invalid: yaml: [") + _create_template_file( + templates_dir, + "good.yml", + { + "type": "ui-template", + "id": "good", + "title": "Good", + "template": {"type": "task"}, + }, + ) + templates_service._repo_path = tmp_path + result = templates_service._parse_templates() + assert len(result) == 1 + assert result[0].id == "good" + + def test_skips_template_with_unknown_parameter_type(self, tmp_path: Path): + templates_dir = _create_templates_repo(tmp_path) + _create_template_file( + templates_dir, + "bad_param.yml", + { + "type": "ui-template", + "id": "bad-param", + "title": "Bad Param", + "parameters": [{"type": "unknown_type"}], + "template": {"type": "task"}, + }, + ) + _create_template_file( + templates_dir, + "good.yml", + { + "type": "ui-template", + "id": "good", + "title": "Good", + "template": {"type": "task"}, + }, + ) + templates_service._repo_path = tmp_path + result = templates_service._parse_templates() + assert len(result) == 1 + assert result[0].id == "good" + + def test_parses_yaml_extension(self, tmp_path: Path): + templates_dir = _create_templates_repo(tmp_path) + _create_template_file( + templates_dir, + "test.yaml", + { + "type": "ui-template", + "id": "yaml-ext", + "title": "YAML Extension", + "template": {"type": "task"}, + }, + ) + templates_service._repo_path = tmp_path + result = templates_service._parse_templates() + assert len(result) == 1 + assert result[0].id == "yaml-ext" + + def test_returns_templates_sorted_by_filename(self, tmp_path: Path): + templates_dir = _create_templates_repo(tmp_path) + _create_template_file( + templates_dir, + "b.yml", + { + "type": "ui-template", + "id": "b", + "title": "B", + "template": {"type": "task"}, + }, + ) + _create_template_file( + templates_dir, + "a.yml", + { + "type": "ui-template", + "id": "a", + "title": "A", + "template": {"type": "task"}, + }, + ) + templates_service._repo_path = tmp_path + result = templates_service._parse_templates() + assert len(result) == 2 + assert result[0].id == "a" + assert result[1].id == "b" + + +class TestListTemplatesSync: + def test_caches_result(self, tmp_path: Path): + templates_dir = _create_templates_repo(tmp_path) + _create_template_file( + templates_dir, + "test.yml", + { + "type": "ui-template", + "id": "cached", + "title": "Cached", + "template": {"type": "task"}, + }, + ) + + with ( + patch.object( + templates_service.settings, "SERVER_TEMPLATES_REPO", "https://example.com" + ), + patch.object(templates_service, "_fetch_templates_repo"), + ): + templates_service._repo_path = tmp_path + # First call populates cache + result1 = templates_service._list_templates_sync() + assert len(result1) == 1 + + # Remove the file + (templates_dir / "test.yml").unlink() + + # Second call returns cached result + result2 = templates_service._list_templates_sync() + assert len(result2) == 1 + assert result2[0].id == "cached" + + def test_refreshes_after_cache_clear(self, tmp_path: Path): + templates_dir = _create_templates_repo(tmp_path) + _create_template_file( + templates_dir, + "test.yml", + { + "type": "ui-template", + "id": "original", + "title": "Original", + "template": {"type": "task"}, + }, + ) + + with ( + patch.object( + templates_service.settings, "SERVER_TEMPLATES_REPO", "https://example.com" + ), + patch.object(templates_service, "_fetch_templates_repo"), + ): + templates_service._repo_path = tmp_path + # First call + result1 = templates_service._list_templates_sync() + assert result1[0].id == "original" + + # Update the file and clear cache + _create_template_file( + templates_dir, + "test.yml", + { + "type": "ui-template", + "id": "updated", + "title": "Updated", + "template": {"type": "task"}, + }, + ) + templates_service._templates_cache.clear() + + # Next call refreshes + result2 = templates_service._list_templates_sync() + assert result2[0].id == "updated" From b24e0cab1fd2ad16a93394e8d72bdd76af46dd92 Mon Sep 17 00:00:00 2001 From: peterschmidt85 Date: Thu, 12 Feb 2026 11:04:22 +0100 Subject: [PATCH 2/2] Fix auth test to accept both 401 and 403 Match codebase pattern for version-dependent HTTPBearer status codes. Co-authored-by: Cursor --- src/tests/_internal/server/routers/test_templates.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tests/_internal/server/routers/test_templates.py b/src/tests/_internal/server/routers/test_templates.py index 62ee4eeeb7..16f721dabc 100644 --- a/src/tests/_internal/server/routers/test_templates.py +++ b/src/tests/_internal/server/routers/test_templates.py @@ -28,9 +28,9 @@ def _reset_cache(): class TestListTemplates: @pytest.mark.asyncio - async def test_returns_403_if_not_authenticated(self, test_db, client: AsyncClient): + async def test_returns_40x_if_not_authenticated(self, test_db, client: AsyncClient): response = await client.post("/api/project/test_project/templates/list") - assert response.status_code == 403 + assert response.status_code in [401, 403] @pytest.mark.asyncio async def test_returns_empty_list_when_no_repo(