Coverage for src/lilbee/server/routes/profiles.py: 100%
66 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 routes: list, show, diff, apply, and the profile file operations."""
3from __future__ import annotations
5from litestar import MediaType, Response, Router, delete, get, patch, post, put
6from litestar.openapi import ResponseSpec
7from litestar.params import FromPath, FromQuery
8from litestar.response import Stream
9from litestar.status_codes import HTTP_200_OK
11from lilbee.core.profile_files import MAX_PROFILE_BYTES
12from lilbee.server.content_disposition import CONTENT_DISPOSITION, attachment_disposition
13from lilbee.server.handlers import profiles as handlers
14from lilbee.server.handlers.sse import SSE_MEDIA_TYPE
15from lilbee.server.models import (
16 ActiveProfileResponse,
17 ProfileApplyResponse,
18 ProfileDiffResponse,
19 ProfileDiscardResponse,
20 ProfileDuplicateRequest,
21 ProfileEntryResponse,
22 ProfileImportRequest,
23 ProfileListResponse,
24 ProfileLocationResponse,
25 ProfileNewRequest,
26 ProfileRenameRequest,
27 ProfileSaveRequest,
28 ProfileSaveResponse,
29 ProfileValidateRequest,
30 ProfileValidationResponse,
31)
33PROFILE_MEDIA_TYPE = "application/toml"
34# JSON escapes one byte of a file to at most six characters; the rest covers the other fields
35PROFILE_BODY_MAX_BYTES = 6 * MAX_PROFILE_BYTES + 64 * 1024
38@get("/api/profiles")
39async def profiles_list_route() -> ProfileListResponse:
40 """Every profile file, Default first, built-ins next; broken files carry their reason."""
41 return await handlers.list_profiles()
44@get("/api/profiles/active")
45async def profiles_active_route() -> ActiveProfileResponse:
46 """The project's applied profile and whether its file is current, changed, gone or broken."""
47 return await handlers.active_profile()
50@post("/api/profiles", status_code=HTTP_200_OK)
51async def profiles_save_route(data: ProfileSaveRequest) -> ProfileSaveResponse:
52 """Save the project's settings as a new profile and switch the project to it."""
53 return await handlers.save_profile(data)
56@post(
57 "/api/profiles/discard",
58 status_code=HTTP_200_OK,
59 media_type=SSE_MEDIA_TYPE,
60 responses={
61 HTTP_200_OK: ResponseSpec(
62 data_container=ProfileDiscardResponse,
63 media_type=MediaType.JSON,
64 description="The discard; with ?reindex=true, the /api/sync SSE events instead",
65 generate_examples=False,
66 )
67 },
68)
69async def profiles_discard_route(
70 reindex: FromQuery[bool] = False,
71) -> Response[ProfileDiscardResponse] | Stream:
72 """Remove your settings of profile keys so the profile's values show through.
74 ``?reindex=true`` also rebuilds the index when the discard needs it, as
75 ``lilbee profile discard --reindex`` does, and answers with the ``/api/sync``
76 SSE events. The done event carries the discard and ``rebuild``, the sync
77 result or null when no rebuild was needed.
78 """
79 discarded = await handlers.discard_changes()
80 if not reindex:
81 return Response(discarded, media_type=MediaType.JSON)
82 return Stream(handlers.rebuild_after_discard_stream(discarded))
85@post("/api/profiles/new", status_code=HTTP_200_OK)
86async def profiles_new_route(data: ProfileNewRequest) -> ProfileLocationResponse:
87 """Write a template listing every profile setting, optionally from a profile's values."""
88 return await handlers.new_profile(data)
91@post("/api/profiles/import", status_code=HTTP_200_OK, request_max_body_size=PROFILE_BODY_MAX_BYTES)
92async def profiles_import_route(data: ProfileImportRequest) -> ProfileLocationResponse:
93 """Validate an uploaded profile file and copy it into a profile folder."""
94 return await handlers.import_profile(data)
97@post(
98 "/api/profiles/validate", status_code=HTTP_200_OK, request_max_body_size=PROFILE_BODY_MAX_BYTES
99)
100async def profiles_validate_route(data: ProfileValidateRequest) -> ProfileValidationResponse:
101 """Every problem with an uploaded profile file."""
102 return await handlers.validate_profile(data)
105@get("/api/profiles/{name:str}")
106async def profiles_show_route(name: FromPath[str]) -> ProfileEntryResponse:
107 """The profile *name* picks."""
108 return await handlers.show_profile(name)
111@get("/api/profiles/{name:str}/diff")
112async def profiles_diff_route(name: FromPath[str]) -> ProfileDiffResponse:
113 """What applying the profile changes and which values of yours it keeps."""
114 return await handlers.diff_profile(name)
117@post(
118 "/api/profiles/{name:str}/apply",
119 status_code=HTTP_200_OK,
120 media_type=SSE_MEDIA_TYPE,
121 responses={
122 HTTP_200_OK: ResponseSpec(
123 data_container=ProfileApplyResponse,
124 media_type=MediaType.JSON,
125 description="The apply; with ?reindex=true, the /api/sync SSE events instead",
126 generate_examples=False,
127 )
128 },
129)
130async def profiles_apply_route(
131 name: FromPath[str], reindex: FromQuery[bool] = False
132) -> Response[ProfileApplyResponse] | Stream:
133 """Make the profile the project's; ``reindex_required`` says whether to rebuild.
135 ``?reindex=true`` also rebuilds the index when the profile needs it, as
136 ``lilbee profile apply --reindex`` does, and answers with the ``/api/sync``
137 SSE events. The done event carries the apply and ``rebuild``, the sync
138 result or null when no rebuild was needed.
139 """
140 applied = await handlers.apply_profile(name)
141 if not reindex:
142 return Response(applied, media_type=MediaType.JSON)
143 return Stream(handlers.rebuild_after_apply_stream(applied))
146@put("/api/profiles/{name:str}")
147async def profiles_update_route(name: FromPath[str]) -> ProfileSaveResponse:
148 """Write the project's settings into its profile, which the name must pick."""
149 return await handlers.update_profile(name)
152@post("/api/profiles/{name:str}/duplicate", status_code=HTTP_200_OK)
153async def profiles_duplicate_route(
154 name: FromPath[str], data: ProfileDuplicateRequest
155) -> ProfileLocationResponse:
156 """Copy the profile under a new name."""
157 return await handlers.duplicate_profile(name, data)
160@patch("/api/profiles/{name:str}")
161async def profiles_rename_route(
162 name: FromPath[str], data: ProfileRenameRequest
163) -> ProfileLocationResponse:
164 """Rename a project or global profile."""
165 return await handlers.rename_profile(name, data.new_name)
168@delete("/api/profiles/{name:str}", status_code=HTTP_200_OK)
169async def profiles_delete_route(name: FromPath[str]) -> ProfileLocationResponse:
170 """Remove a project or global profile file; projects keep their recorded copy."""
171 return await handlers.delete_profile(name)
174@get("/api/profiles/{name:str}/export", media_type=PROFILE_MEDIA_TYPE)
175async def profiles_export_route(name: FromPath[str]) -> Response[str]:
176 """Download the profile as a clean file."""
177 exported = await handlers.export_profile(name)
178 return Response(
179 content=exported.text,
180 media_type=PROFILE_MEDIA_TYPE,
181 headers={CONTENT_DISPOSITION: attachment_disposition(exported.filename)},
182 )
185profiles_router = Router(
186 path="/",
187 route_handlers=[
188 profiles_list_route,
189 profiles_active_route,
190 profiles_save_route,
191 profiles_discard_route,
192 profiles_new_route,
193 profiles_import_route,
194 profiles_validate_route,
195 profiles_show_route,
196 profiles_diff_route,
197 profiles_apply_route,
198 profiles_update_route,
199 profiles_duplicate_route,
200 profiles_rename_route,
201 profiles_delete_route,
202 profiles_export_route,
203 ],
204)