Coverage for src/lilbee/server/handlers/profiles.py: 100%
77 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
1"""Profile route handlers: each runs one app.profiles operation off the event loop.
3A profile name from a URL only ever reaches the profile lookup, never a file path.
4"""
6from __future__ import annotations
8import asyncio
9from collections.abc import AsyncGenerator, Callable
10from typing import TypeVar
12from litestar.exceptions import HTTPException, NotFoundException, ValidationException
13from litestar.status_codes import HTTP_409_CONFLICT, HTTP_503_SERVICE_UNAVAILABLE
15from lilbee.app import profiles
16from lilbee.app.profiles import ExportedProfile, ProfileNotFoundError, file_failure_message
17from lilbee.app.services import get_services
18from lilbee.core.profile_files import ProfileNameClashError, ProfileStore
19from lilbee.server.handlers.ingest import sync_stream
20from lilbee.server.handlers.sse import sse_done
21from lilbee.server.models import (
22 ActiveProfileResponse,
23 ProfileApplyResponse,
24 ProfileDiffResponse,
25 ProfileDiscardResponse,
26 ProfileDuplicateRequest,
27 ProfileEntryResponse,
28 ProfileImportRequest,
29 ProfileListResponse,
30 ProfileLocationResponse,
31 ProfileNewRequest,
32 ProfileSaveRequest,
33 ProfileSaveResponse,
34 ProfileValidateRequest,
35 ProfileValidationResponse,
36)
38T = TypeVar("T")
41def _store() -> ProfileStore:
42 return get_services().profile_store
45async def _run(operation: Callable[[], T]) -> T:
46 """Run *operation* on a thread: missing is a 404, a name clash 409, other refusals 400."""
47 try:
48 return await asyncio.to_thread(operation)
49 except ProfileNotFoundError as exc:
50 raise NotFoundException(detail=str(exc)) from exc
51 except ProfileNameClashError as exc:
52 raise HTTPException(status_code=HTTP_409_CONFLICT, detail=str(exc)) from exc
53 except ValueError as exc:
54 raise ValidationException(detail=str(exc)) from exc
55 except OSError as exc:
56 raise HTTPException(
57 status_code=HTTP_503_SERVICE_UNAVAILABLE, detail=file_failure_message(exc)
58 ) from exc
61async def list_profiles() -> ProfileListResponse:
62 """Every profile file, Default first, then the other built-ins, then the rest."""
63 catalog = await _run(lambda: profiles.list_profiles(_store()))
64 return ProfileListResponse(
65 profiles=[ProfileEntryResponse.from_entry(e) for e in catalog.entries]
66 )
69async def active_profile() -> ActiveProfileResponse:
70 """The project's applied profile and the state of its file."""
71 return ActiveProfileResponse.from_active(await _run(lambda: profiles.active(_store())))
74async def show_profile(name: str) -> ProfileEntryResponse:
75 """The profile *name* picks."""
76 return ProfileEntryResponse.from_entry(await _run(lambda: profiles.show(_store(), name)))
79async def diff_profile(name: str) -> ProfileDiffResponse:
80 """What applying *name* changes."""
81 return ProfileDiffResponse.from_diff(await _run(lambda: profiles.diff(_store(), name)))
84async def apply_profile(name: str) -> ProfileApplyResponse:
85 """Make *name* the project's profile."""
86 return ProfileApplyResponse.from_result(await _run(lambda: profiles.apply(_store(), name)))
89async def rebuild_after_apply_stream(applied: ProfileApplyResponse) -> AsyncGenerator[str, None]:
90 """The rebuild an apply needs as sync SSE events; done carries the apply and ``rebuild``."""
91 needed = await asyncio.to_thread(
92 profiles.apply_needs_rebuild, _store(), applied.name, applied.reindex_required
93 )
94 async for frame in _rebuild_stream(applied.model_dump(mode="json"), needed):
95 yield frame
98async def rebuild_after_discard_stream(
99 discarded: ProfileDiscardResponse,
100) -> AsyncGenerator[str, None]:
101 """The rebuild a discard needs as sync SSE events; done carries the discard and ``rebuild``."""
102 reason = await asyncio.to_thread(
103 profiles.rebuild_reason, discarded.dropped, discarded.reindex_required
104 )
105 async for frame in _rebuild_stream(discarded.model_dump(mode="json"), reason is not None):
106 yield frame
109async def _rebuild_stream(payload: dict[str, object], needed: bool) -> AsyncGenerator[str, None]:
110 """A forced sync's SSE events when *needed*; done carries *payload* and ``rebuild``."""
111 if not needed:
112 yield sse_done({**payload, "rebuild": None})
113 return
114 async for frame in sync_stream(
115 force_rebuild=True,
116 done_payload=lambda result: {**payload, "rebuild": result.model_dump()},
117 ):
118 yield frame
121async def save_profile(data: ProfileSaveRequest) -> ProfileSaveResponse:
122 """Save the project's settings as a new profile and switch to it."""
123 return ProfileSaveResponse.from_save(
124 await _run(lambda: profiles.save_as(data.name, data.target))
125 )
128async def update_profile(name: str) -> ProfileSaveResponse:
129 """Write the project's settings into its profile, which *name* must pick."""
130 return ProfileSaveResponse.from_save(await _run(lambda: profiles.update(_store(), name)))
133async def discard_changes() -> ProfileDiscardResponse:
134 """Remove your settings of profile keys so the profile's values show through."""
135 return ProfileDiscardResponse.from_result(await _run(profiles.discard))
138async def new_profile(data: ProfileNewRequest) -> ProfileLocationResponse:
139 """Write a template profile, optionally from another profile's values."""
140 location = await _run(
141 lambda: profiles.new(_store(), data.name, data.target, from_name=data.from_profile)
142 )
143 return ProfileLocationResponse.from_location(location)
146async def duplicate_profile(name: str, data: ProfileDuplicateRequest) -> ProfileLocationResponse:
147 """Copy *name* under a new name."""
148 location = await _run(lambda: profiles.duplicate(_store(), name, data.new_name, data.target))
149 return ProfileLocationResponse.from_location(location)
152async def rename_profile(name: str, new_name: str) -> ProfileLocationResponse:
153 """Rename a project or global profile."""
154 location = await _run(lambda: profiles.rename(_store(), name, new_name))
155 return ProfileLocationResponse.from_location(location)
158async def delete_profile(name: str) -> ProfileLocationResponse:
159 """Remove a project or global profile file."""
160 return ProfileLocationResponse.from_location(
161 await _run(lambda: profiles.delete(_store(), name))
162 )
165async def export_profile(name: str) -> ExportedProfile:
166 """The profile *name* picks as the text of a clean file."""
167 return await _run(lambda: profiles.export_text(_store(), name))
170async def import_profile(data: ProfileImportRequest) -> ProfileLocationResponse:
171 """Validate an uploaded profile file and copy it into a profile folder."""
172 location = await _run(
173 lambda: profiles.import_text(
174 _store(), data.content, data.filename, data.target, overwrite=data.overwrite
175 )
176 )
177 return ProfileLocationResponse.from_location(location)
180async def validate_profile(data: ProfileValidateRequest) -> ProfileValidationResponse:
181 """Every problem with an uploaded profile file."""
182 result = await _run(lambda: profiles.validate_content(data.content, data.filename, data.folder))
183 return ProfileValidationResponse.from_validation(result)