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

1"""Profile routes: list, show, diff, apply, and the profile file operations.""" 

2 

3from __future__ import annotations 

4 

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 

10 

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) 

32 

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 

36 

37 

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() 

42 

43 

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() 

48 

49 

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) 

54 

55 

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. 

73 

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)) 

83 

84 

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) 

89 

90 

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) 

95 

96 

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) 

103 

104 

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) 

109 

110 

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) 

115 

116 

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. 

134 

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)) 

144 

145 

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) 

150 

151 

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) 

158 

159 

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) 

166 

167 

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) 

172 

173 

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 ) 

183 

184 

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)