Coverage for flogin/jsonrpc/results.py: 61%

127 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-07-03 22:51 +0000

1from __future__ import annotations 

2 

3import logging 

4import secrets 

5from typing import ( 

6 TYPE_CHECKING, 

7 Any, 

8 ClassVar, 

9 Generic, 

10 NotRequired, 

11 Self, 

12 TypedDict, 

13 TypeVarTuple, 

14 Unpack, 

15 cast, 

16) 

17 

18from .._types.search_handlers import ( 

19 ConvertableToResult, 

20 PluginT, 

21 SearchHandlerCallbackReturns, 

22) 

23from ..utils import MISSING, cached_property, copy_doc 

24from .base_object import Base 

25from .responses import ErrorResponse, ExecuteResponse 

26 

27if TYPE_CHECKING: 

28 from collections.abc import Callable, Iterable 

29 from types import CoroutineType 

30 

31 from .._types.jsonrpc.result import ( # noqa: F401 

32 RawGlyph, 

33 RawPreview, 

34 RawProgressBar, 

35 RawResult, 

36 ) 

37 

38TS = TypeVarTuple("TS") 

39log = logging.getLogger(__name__) 

40 

41__all__ = ("Glyph", "ProgressBar", "Result", "ResultConstructorKwargs", "ResultPreview") 

42 

43 

44class Glyph(Base["RawGlyph"]): 

45 r"""This represents a glyph object with flow launcher, which is an alternative to :class:`~flogin.jsonrpc.results.Result` icons. 

46 

47 Attributes 

48 ---------- 

49 text: :class:`str` 

50 The text to be shown in the glyph 

51 font_family: :class:`str` 

52 The font that the text should be shown in 

53 """ 

54 

55 __slots__ = "font_family", "text" 

56 __jsonrpc_option_names__: ClassVar[dict[str, str]] = { 

57 "text": "Glyph", 

58 "font_family": "FontFamily", 

59 } 

60 

61 def __init__(self, text: str, font_family: str) -> None: 

62 self.text = text 

63 self.font_family = font_family 

64 

65 @classmethod 

66 def from_dict(cls: type[Self], data: RawGlyph) -> Self: 

67 r"""Converts a dictionary into a :class:`Glyph` object. 

68 

69 Parameters 

70 ---------- 

71 data: dict[:class:`str`, Any] 

72 The dictionary 

73 """ 

74 

75 return cls(text=data["glyph"], font_family=data["fontFamily"]) 

76 

77 

78class ProgressBar(Base["RawProgressBar"]): 

79 r"""This represents the progress bar than can be shown on a result. 

80 

81 .. NOTE:: 

82 Visually, the progress bar takes the same spot as the title 

83 

84 Attributes 

85 ---------- 

86 percentage: :class:`int` 

87 The percentage of the progress bar that should be filled. must be 0-100. 

88 color: :class:`str` 

89 The color that the progress bar should be in hex code form. Defaults to #26a0da. 

90 """ 

91 

92 __slots__ = "color", "percentage" 

93 __jsonrpc_option_names__: ClassVar[dict[str, str]] = { 

94 "percentage": "ProgressBar", 

95 "color": "ProgressBarColor", 

96 } 

97 

98 def __init__(self, percentage: int, color: str = MISSING) -> None: 

99 self.percentage = percentage 

100 self.color = color or "#26a0da" 

101 

102 

103class ResultPreview(Base["RawPreview"]): 

104 r"""Represents a result's preview. 

105 

106 .. NOTE:: 

107 Previews are finicky, and may not work 100% of the time 

108 

109 Attributes 

110 ---------- 

111 image_path: :class:`str` 

112 The path to the image to be shown 

113 description: Optional[:class:`str`] 

114 The description to be shown 

115 is_media: Optional[:class:`bool`] 

116 Whther the preview should be treated as media or not 

117 """ 

118 

119 __slots__ = "description", "image_path", "is_media", "preview_deligate" 

120 __jsonrpc_option_names__: ClassVar[dict[str, str]] = { 

121 "image_path": "previewImagePath", 

122 "is_media": "isMedia", 

123 "description": "description", 

124 } 

125 

126 def __init__( 

127 self, 

128 image_path: str, 

129 *, 

130 description: str | None = None, 

131 is_media: bool = True, 

132 ) -> None: 

133 self.image_path = image_path 

134 self.description = description 

135 self.is_media = is_media 

136 

137 

138class ResultConstructorKwargs(TypedDict, total=False): 

139 r"""This represents the possible kwargs that can be passed to :class:`Result`. 

140 

141 This can be useful when overriding :class:`Result` to create a basic implementation of something for your project, but still want the ability to pass kwargs with proper typing. 

142 

143 .. NOTE:: 

144 See :class:`Result` for more information about each key and value. 

145 

146 Example 

147 -------- 

148 This is an example of how you might use this to create a url result 

149 

150 .. code-block:: py3 

151 

152 from typing import Unpack 

153 from flogin import Result, ResultConstructorKwargs 

154 

155 class MyRes(Result): 

156 def __init__(self, url: str, **kwargs: Unpack[ResultConstructorKwargs]) -> None: 

157 self.url = url 

158 super().__init__(**kwargs) 

159 

160 async def callback(self): 

161 await self.plugin.api.open_url(self.url) 

162 """ 

163 

164 title: NotRequired[str] 

165 sub: NotRequired[str] 

166 icon: NotRequired[str] 

167 title_highlight_data: NotRequired[list[int]] 

168 title_tooltip: NotRequired[str] 

169 sub_tooltip: NotRequired[str] 

170 copy_text: NotRequired[str] 

171 score: NotRequired[int] 

172 rounded_icon: NotRequired[bool] 

173 glyph: NotRequired[Glyph] 

174 auto_complete_text: NotRequired[str] 

175 preview: NotRequired[ResultPreview] 

176 progress_bar: NotRequired[ProgressBar] 

177 

178 

179class Result(Base["RawResult"], Generic[PluginT]): 

180 r"""This represents a result that would be returned as a result for a query or context menu. 

181 

182 For simple useage: create instances of this class as-is. 

183 

184 For advanced useage (handling clicks and custom context menus), it is recommended to subclass the result object to create your own subclass. 

185 

186 Subclassing 

187 ------------ 

188 Subclassing lets you override the following methods: :func:`~flogin.jsonrpc.results.Result.callback` and :func:`~flogin.jsonrpc.results.Result.context_menu`. It also lets you create "universal" result properties (eg: same icon). Example: 

189 

190 .. code-block:: python3 

191 

192 class MyResult(Result): 

193 def __init__(self, title: str) -> None: 

194 super().__init__(self, title, icon="Images/app.png") 

195 

196 async def callback(self): 

197 # handle what happens when the result gets clicked 

198 

199 async def context_menu(self): 

200 # add context menu options to this result's context menu 

201 

202 Attributes 

203 ---------- 

204 title: :class:`str` 

205 The title/content of the result 

206 sub: Optional[:class:`str`] 

207 The subtitle to be shown. 

208 icon: Optional[:class:`str`] 

209 A path to the icon to be shown with the result. If this and :attr:`~flogin.jsonrpc.results.Result.glyph` are passed, the user's ``Use Segoe Fluent Icons`` setting will determine which is used. 

210 title_highlight_data: Optional[Iterable[:class:`int`]] 

211 The highlight data for the title. See the :ref:`FAQ section on highlights <highlights>` for more info. 

212 title_tooltip: Optional[:class:`str`] 

213 The text to be displayed when the user hovers over the result's title 

214 sub_tooltip: Optional[:class:`str`] 

215 The text to be displayed when the user hovers over the result's subtitle 

216 copy_text: Optional[:class:`str`] 

217 This is the text that will be copied when the user does ``CTRL+C`` on the result. If the text is a file/directory path, flow will copy the actual file/folder instead of just the path text. 

218 plugin: :class:`~flogin.plugin.Plugin` | None 

219 Your plugin instance. This is filled before :func:`~flogin.jsonrpc.results.Result.callback` or :func:`~flogin.jsonrpc.results.Result.context_menu` are triggered. 

220 preview: Optional[:class:`~flogin.jsonrpc.results.ResultPreview`] 

221 Customize the preview that is shown for the result. By default, the preview shows the result's title, subtitle, and icon 

222 progress_bar: Optional[:class:`~flogin.jsonrpc.results.ProgressBar`] 

223 The progress bar that could be shown in the place of the title 

224 auto_complete_text: Optional[:class:`str`] 

225 The text that will replace the :attr:`~flogin.query.Query.raw_text` in the flow menu when the autocomplete hotkey is used on the result. Defaults to the result's title. 

226 rounded_icon: Optional[:class:`bool`] 

227 Whether to have round the icon or not. 

228 glyph: Optional[:class:`~flogin.jsonrpc.results.Glyph`] 

229 The :class:`~flogin.jsonrpc.results.Glyph` object that will serve as the result's icon. If this and :attr:`~flogin.jsonrpc.results.Result.icon` are passed, the user's ``Use Segoe Fluent Icons`` setting will determine which is used. 

230 """ 

231 

232 def __init__( 

233 self, 

234 title: str | None = None, 

235 sub: str | None = None, 

236 icon: str | None = None, 

237 title_highlight_data: Iterable[int] | None = None, 

238 title_tooltip: str | None = None, 

239 sub_tooltip: str | None = None, 

240 copy_text: str | None = None, 

241 score: int | None = None, 

242 auto_complete_text: str | None = None, 

243 preview: ResultPreview | None = None, 

244 progress_bar: ProgressBar | None = None, 

245 rounded_icon: bool | None = None, 

246 glyph: Glyph | None = None, 

247 ) -> None: 

248 self.title = title 

249 self.sub = sub 

250 self.icon = icon 

251 self.title_highlight_data = title_highlight_data 

252 self.title_tooltip = title_tooltip 

253 self.sub_tooltip = sub_tooltip 

254 self.copy_text = copy_text 

255 self.score = score 

256 self.auto_complete_text = auto_complete_text 

257 self.preview = preview 

258 self.progress_bar = progress_bar 

259 self.rounded_icon = rounded_icon 

260 self.glyph = glyph 

261 self.plugin: PluginT | None = None 

262 

263 async def on_error(self, error: Exception) -> ErrorResponse | ExecuteResponse: 

264 r"""|coro| 

265 

266 Override this function to add an error response behavior to this result's callback. 

267 

268 Parameters 

269 ---------- 

270 error: :class:`Exception` 

271 The error that occured 

272 """ 

273 log.exception("Ignoring exception in result callback (%r)", exc_info=error) 

274 return ErrorResponse.internal_error(error) 

275 

276 async def callback(self) -> ExecuteResponse | bool | None: 

277 r"""|coro| 

278 

279 Override this function to add a callback behavior to your result. This method will run when the user clicks on your result. 

280 

281 .. versionchanged:: 2.0.0 

282 A result callback can not return :class:`bool` or ``None`` 

283 

284 Returns 

285 ------- 

286 :class:`~flogin.jsonrpc.responses.ExecuteResponse` | :class:`bool` | ``None`` 

287 A response to flow determining whether or not to hide flow's menu, or a bool that will be turned into a response. ``None`` will be converted into ``True`` to align with the default value for :attr:`~flogin.jsonrpc.responses.ExecuteResponse.hide` 

288 """ 

289 

290 return ExecuteResponse(False) 

291 

292 def context_menu(self) -> SearchHandlerCallbackReturns: 

293 r"""|coro| 

294 

295 Override this function to add a context menu behavior to your result. This method will run when the user gets the context menu to your result. 

296 

297 This method can return/yield almost anything, and flogin will convert it into a list of :class:`~flogin.jsonrpc.results.Result` objects before sending it to flow. 

298 

299 Returns 

300 ------- 

301 list[:class:`~flogin.jsonrpc.results.Result`] | :class:`~flogin.jsonrpc.results.Result` | str | Any 

302 A list of results, an results, or something that can be converted into a list of results. 

303 

304 Yields 

305 ------ 

306 :class:`~flogin.jsonrpc.results.Result` | str | Any 

307 A result object or something that can be converted into a result object. 

308 """ 

309 ... 

310 

311 def on_context_menu_error(self, error: Exception) -> SearchHandlerCallbackReturns: 

312 r"""|coro| 

313 

314 Override this function to add an error response behavior to this result's context menu callback. 

315 

316 If the error was handled: 

317 You can return/yield almost anything, and flogin will convert it into a list of :class:`~flogin.jsonrpc.results.Result` objects before sending it to flow. 

318 

319 If the error was not handled: 

320 Return a :class:`~flogin.jsonrpc.responses.ErrorResponse` object 

321 

322 Parameters 

323 ---------- 

324 error: :class:`Exception` 

325 The error that occured 

326 

327 Returns 

328 ------- 

329 :class:`~flogin.jsonrpc.responses.ErrorResponse` | list[:class:`~flogin.jsonrpc.results.Result`] | :class:`~flogin.jsonrpc.results.Result` | str | Any 

330 A list of results, an results, or something that can be converted into a list of results. 

331 

332 Yields 

333 ------ 

334 :class:`~flogin.jsonrpc.results.Result` | str | Any 

335 A result object or something that can be converted into a result object. 

336 """ 

337 ... 

338 

339 if not TYPE_CHECKING: 

340 

341 @copy_doc(context_menu) 

342 async def context_menu(self) -> Any: 

343 return [] 

344 

345 @copy_doc(on_context_menu_error) 

346 async def on_context_menu_error(self, error: Exception) -> Any: 

347 log.exception( 

348 "Ignoring exception in result's context menu callback (%r)", 

349 self, 

350 exc_info=error, 

351 ) 

352 return ErrorResponse.internal_error(error) 

353 

354 def to_dict(self) -> RawResult: 

355 r"""This converts the result into a json serializable dictionary 

356 

357 Returns 

358 ------- 

359 dict[:class:`str`, Any] 

360 """ 

361 

362 x: RawResult = { 

363 "jsonRPCAction": { 

364 "method": f"flogin.action.{self.slug}", 

365 }, 

366 "contextData": [ 

367 self.slug, 

368 ], 

369 } 

370 

371 if self.title is not None: 

372 x["title"] = self.title 

373 if self.sub is not None: 

374 x["subTitle"] = self.sub 

375 if self.icon is not None: 

376 x["icoPath"] = self.icon 

377 if self.title_highlight_data is not None: 

378 x["titleHighlightData"] = self.title_highlight_data 

379 if self.title_tooltip is not None: 

380 x["titleTooltip"] = self.title_tooltip 

381 if self.sub_tooltip is not None: 

382 x["subtitleTooltip"] = self.sub_tooltip 

383 if self.copy_text is not None: 

384 x["copyText"] = self.copy_text 

385 if self.score is not None: 

386 x["score"] = self.score 

387 if self.preview is not None: 

388 x["preview"] = self.preview.to_dict() 

389 if self.auto_complete_text is not None: 

390 x["autoCompleteText"] = self.auto_complete_text 

391 if self.progress_bar is not None: 

392 x.update(self.progress_bar.to_dict()) # type: ignore 

393 if self.rounded_icon is not None: 

394 x["roundedIcon"] = self.rounded_icon 

395 if self.glyph is not None: 

396 x["glyph"] = self.glyph.to_dict() 

397 return x 

398 

399 @classmethod 

400 def from_dict(cls: type[Self], data: RawResult) -> Self: 

401 r"""Creates a Result from a dictionary 

402 

403 .. NOTE:: 

404 This method does NOT fill the :func:`~flogin.jsonrpc.results.Result.callback` or :func:`~flogin.jsonrpc.results.Result.context_menu` attributes. 

405 

406 Parameters 

407 ---------- 

408 data: dict[:class:`str`, Any] 

409 The valid dictionary that includes the result data 

410 

411 Raises 

412 ------ 

413 :class:`KeyError` 

414 The dictionary did not include the only required field, ``title``. 

415 

416 Returns 

417 -------- 

418 :class:`Result` 

419 """ 

420 

421 return cls( 

422 title=data.get("title"), 

423 sub=data.get("subTitle"), 

424 icon=data.get("icoPath"), 

425 title_highlight_data=data.get("titleHighlightData"), 

426 title_tooltip=data.get("titleTooltip"), 

427 sub_tooltip=data.get("subtitleTooltip"), 

428 copy_text=data.get("copyText"), 

429 ) 

430 

431 @classmethod 

432 def from_anything(cls: type[Result], item: ConvertableToResult[Any]) -> Result[Any]: 

433 if isinstance(item, dict): 

434 return cls.from_dict(cast("RawResult", item)) 

435 if isinstance(item, Result): 

436 return item # type: ignore 

437 return cls(str(item)) 

438 

439 @classmethod 

440 def create_with_partial( 

441 cls: type[Result], 

442 partial_callback: Callable[ 

443 [], CoroutineType[Any, Any, ExecuteResponse | bool | None] 

444 ], 

445 **kwargs: Unpack[ResultConstructorKwargs], 

446 ) -> Result: 

447 r"""A quick and easy way to create a result with a callback without subclassing. 

448 

449 .. NOTE:: 

450 This is meant to be used with :class:`~flogin.flow.api.FlowLauncherAPI` methods 

451 

452 Example 

453 -------- 

454 .. code-block:: python3 

455 

456 result = Result.create_with_partial( 

457 functools.partial( 

458 plugin.api.show_notification, 

459 "notification title", 

460 "notification content" 

461 ), 

462 title="Result title", 

463 sub="Result subtitle" 

464 ) 

465 

466 Parameters 

467 ---------- 

468 partial_callback: partial :ref:`coroutine <coroutine>` 

469 The callback wrapped in :obj:`functools.partial` 

470 kwargs: See allowed kwargs here: :class:`~flogin.jsonrpc.results.Result` 

471 The args that will be passed to the :class:`~flogin.jsonrpc.results.Result` constructor 

472 """ 

473 

474 self = cls(**kwargs) 

475 self.callback = partial_callback 

476 return self 

477 

478 @cached_property 

479 def slug(self) -> str: 

480 return secrets.token_hex(15) 

481 

482 def __repr__(self) -> str: 

483 return f"<{self.__class__.__name__} {self.title=} {self.sub=} {self.icon=} {self.title_highlight_data=} {self.title_tooltip=} {self.sub_tooltip=} {self.copy_text=} {self.score=} {self.auto_complete_text=} {self.preview=} {self.progress_bar=} {self.rounded_icon=} {self.glyph=}>"