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

1"""Profile route handlers: each runs one app.profiles operation off the event loop. 

2 

3A profile name from a URL only ever reaches the profile lookup, never a file path. 

4""" 

5 

6from __future__ import annotations 

7 

8import asyncio 

9from collections.abc import AsyncGenerator, Callable 

10from typing import TypeVar 

11 

12from litestar.exceptions import HTTPException, NotFoundException, ValidationException 

13from litestar.status_codes import HTTP_409_CONFLICT, HTTP_503_SERVICE_UNAVAILABLE 

14 

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) 

37 

38T = TypeVar("T") 

39 

40 

41def _store() -> ProfileStore: 

42 return get_services().profile_store 

43 

44 

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 

59 

60 

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 ) 

67 

68 

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

72 

73 

74async def show_profile(name: str) -> ProfileEntryResponse: 

75 """The profile *name* picks.""" 

76 return ProfileEntryResponse.from_entry(await _run(lambda: profiles.show(_store(), name))) 

77 

78 

79async def diff_profile(name: str) -> ProfileDiffResponse: 

80 """What applying *name* changes.""" 

81 return ProfileDiffResponse.from_diff(await _run(lambda: profiles.diff(_store(), name))) 

82 

83 

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

87 

88 

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 

96 

97 

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 

107 

108 

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 

119 

120 

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 ) 

126 

127 

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

131 

132 

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

136 

137 

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) 

144 

145 

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) 

150 

151 

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) 

156 

157 

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 ) 

163 

164 

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

168 

169 

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) 

178 

179 

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)