Coverage for flogin/testing/plugin_tester.py: 75%

71 statements  

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

1from __future__ import annotations 

2 

3import json 

4import os 

5import sys 

6import uuid 

7from typing import TYPE_CHECKING, Any, Generic 

8 

9from .._types.search_handlers import PluginT 

10from ..flow.plugin_metadata import PluginMetadata 

11from ..query import Query 

12from ..settings import Settings 

13from ..utils import MISSING 

14from .filler import FillerObject 

15 

16if TYPE_CHECKING: 

17 from pathlib import Path 

18 

19 from .._types.settings import RawSettings 

20 from ..jsonrpc.responses import QueryResponse 

21 from ..jsonrpc.results import Result 

22 

23API_FILLER_TEXT = "FlowLauncherAPI is unavailable during testing. Consider passing the 'flow_api_client' arg into PluginTester to implement your own flow api client." 

24CHARACTERS = "qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLLZXCVBNM1234567890" 

25 

26__all__ = ("PluginTester",) 

27 

28 

29class PluginTester(Generic[PluginT]): 

30 r"""This can be used to write tests for your plugins. 

31 

32 See the :doc:`testing` guide for more information on writing tests. 

33 

34 Parameters 

35 ---------- 

36 plugin: :class:`~flogin.plugin.Plugin` 

37 Your plugin 

38 metadata: :class:`~flogin.flow.plugin_metadata.PluginMetadata` | dict[str, Any] | None 

39 Your plugin's metadata. If ``None`` is passed, flogin will attempt to get the metadata from your ``plugin.json`` file. The :func:`PluginTester.create_plugin_metadata` and :func:`PluginTester.create_bogus_plugin_metadata` classmethods have been provided for creating :class:`~flogin.flow.plugin_metadata.PluginMetadata` objects. 

40 flow_api_client: Optional[Any] 

41 If not passed, flogin will use a filler class which will raise a runtime error whenever an attribute is accessed. If passed, you should be passing an instance of a class which will replace :class:`~flogin.flow.api.FlowLauncherAPI`, so make sure to implement the methods you need and handle them accordingly. 

42 flow_version: Optional[:class:`str`] 

43 This is an optional positional keyword that if set, will automatically set the enviroment variable ``FLOW_VERSION`` to the value. This is useful if your code uses the :attr:`~flogin.plugin.Plugin.flow_version` property. 

44 

45 .. versionadded: 1.1.0 

46 flow_application_dir: Optional[:class:`str` | :class:`~pathlib.Path`] 

47 This is an optional positional keyword that if set, will automatically set the enviroment variable ``FLOW_APPLICATION_DIRECTORY`` to the value. This is useful if your code uses the :attr:`~flogin.plugin.Plugin.flow_application_dir` property. 

48 

49 .. versionadded: 1.1.0 

50 flow_program_dir: Optional[:class:`str` | :class:`~pathlib.Path`] 

51 This is an optional positional keyword that if set, will automatically set the enviroment variable ``FLOW_PROGRAM_DIRECTORY`` to the value. This is useful if your code uses the :attr:`~flogin.plugin.Plugin.flow_program_dir` property. 

52 

53 .. versionadded: 1.1.0 

54 

55 Attributes 

56 ---------- 

57 plugin: :class:`~flogin.plugin.Plugin` 

58 Your plugin 

59 """ 

60 

61 plugin: PluginT 

62 

63 def __init__( 

64 self, 

65 plugin: PluginT, 

66 *, 

67 metadata: PluginMetadata | dict[str, Any] | None, 

68 flow_api_client: Any = MISSING, 

69 flow_version: str = MISSING, 

70 flow_application_dir: Path | str = MISSING, 

71 flow_program_dir: Path | str = MISSING, 

72 ) -> None: 

73 self.plugin = plugin 

74 

75 if metadata is None: 

76 if not os.path.exists("plugin.json"): 

77 raise ValueError( 

78 "plugin.json file can not be located, consider passing the metadata instead" 

79 ) 

80 with open("plugin.json") as f: 

81 metadata = json.load(f) 

82 

83 if isinstance(metadata, dict): 

84 metadata = PluginMetadata(metadata, self.plugin.api) 

85 

86 self.plugin._metadata = metadata 

87 

88 self.set_flow_api_client(flow_api_client) 

89 

90 if flow_version is not MISSING: 

91 os.environ["FLOW_VERSION"] = flow_version 

92 if flow_application_dir is not MISSING: 

93 os.environ["FLOW_APPLICATION_DIRECTORY"] = str(flow_application_dir) 

94 if flow_program_dir is not MISSING: 

95 os.environ["FLOW_PROGRAM_DIRECTORY"] = str(flow_program_dir) 

96 

97 def set_flow_api_client(self, flow_api_client: Any = MISSING) -> None: 

98 r"""This sets the flow api client that the tests should use. 

99 

100 Parameters 

101 ---------- 

102 flow_api_client: Optional[Any] 

103 If not passed, flogin will use a filler class which will raise a runtime error whenever an attribute is accessed. If passed, you should be passing an instance of a class which will replace :class:`~flogin.flow.api.FlowLauncherAPI`, so make sure to implement the methods you need and handle them accordingly. 

104 """ 

105 if flow_api_client is MISSING: 

106 flow_api_client = FillerObject(API_FILLER_TEXT) 

107 

108 self.plugin.api = flow_api_client 

109 self.plugin.metadata._flow_api = flow_api_client 

110 

111 async def test_query( 

112 self, 

113 text: str, 

114 *, 

115 keyword: str = "*", 

116 is_requery: bool = False, 

117 settings: Settings | RawSettings | None = MISSING, 

118 ) -> QueryResponse: 

119 r"""|coro| 

120 

121 This coroutine can be used to send your plugin a query, and get the response. 

122 

123 Parameters 

124 ---------- 

125 query: :class:`~flogin.query.Query` 

126 The query object that should be passed to your search handlers. 

127 settings: Optional[:class:`~flogin.settings.Settings` | dict[:class:`str`, :class:`~flogin.Jsonable`] | ``None``] 

128 This will represent the settings that flogin will use when executing your search handlers. If not passed, flogin will not use any settings. If ``None`` is passed, flogin will get the settings from the settings file (this is incompatible with :func:`PluginTester.create_bogus_plugin_metadata`). If a dict or :class:`~flogin.settings.Settings` object is passed, those are the settings that will be put in :attr:`~flogin.plugin.Plugin.settings` before executing your search handlers. 

129 

130 Returns 

131 ------- 

132 :class:`~flogin.jsonrpc.responses.QueryResponse` 

133 The query response object that would normally be sent to flow. 

134 """ 

135 

136 if isinstance(settings, dict): 

137 settings = Settings(settings) 

138 if settings is MISSING: 

139 settings = Settings({}) 

140 

141 if isinstance(settings, Settings): 

142 self.plugin.settings = settings 

143 self.plugin._settings_are_populated = True 

144 

145 query = Query( 

146 { 

147 "rawQuery": f"{keyword} {text}", 

148 "search": text, 

149 "actionKeyword": keyword, 

150 "isReQuery": is_requery, 

151 }, 

152 self.plugin, 

153 ) 

154 

155 coro = self.plugin.process_search_handlers(query) 

156 

157 return await coro # type: ignore 

158 

159 async def test_context_menu( 

160 self, result: Result, *, bypass_registration: bool = False 

161 ) -> QueryResponse: 

162 r"""|coro| 

163 

164 This coroutine can be used to send a result's context menu, and get the response. 

165 

166 .. NOTE:: 

167 You should use this to test your context menus instead of invoking them directly because this method implements the post-processing that flogin puts onto your context menu and query methods. 

168 

169 Parameters 

170 ---------- 

171 result: :class:`~flogin.jsonrpc.results.Result` 

172 The result you want to invoke the context menu for. 

173 bypass_registration: :class:`bool` 

174 Whether or not to bypass the ``Result has not been registered`` error. 

175 

176 Raises 

177 ------ 

178 ValueError 

179 This will be raised when ``bypass_registration`` is set to ``False``, and the given result has not been registered. 

180 

181 Returns 

182 ------- 

183 :class:`~flogin.jsonrpc.responses.QueryResponse` 

184 The query response object that would normally be sent to flow. 

185 """ 

186 

187 coro = self.plugin.dispatch("context_menu", [result.slug]) 

188 

189 if coro is None: 

190 if bypass_registration: 

191 self.plugin._results[result.slug] = result 

192 return await self.test_context_menu(result) 

193 

194 raise ValueError("Result has not been registered.") 

195 

196 return await coro # type: ignore[returnTypeError] 

197 

198 @classmethod 

199 def create_bogus_plugin_metadata(cls: type[PluginTester]) -> PluginMetadata: 

200 r"""This classmethod can be used to easily and quickly create a :class:`~flogin.flow.plugin_metadata.PluginMetadata` object that can be used for testing. 

201 

202 .. NOTE:: 

203 Since the information that this classmethod generates is bogus, it is not recommended to use this when your plugin relies on the plugin metadata. Consider using :func:`PluginTester.create_plugin_metadata` instead. 

204 

205 Returns 

206 -------- 

207 :class:`~flogin.flow.plugin_metadata.PluginMetadata` 

208 The :class:`~flogin.flow.plugin_metadata.PluginMetadata` instance with your bogus information. 

209 """ 

210 

211 return cls.create_plugin_metadata( 

212 id=str(uuid.uuid4()), 

213 name="Test Plugin", 

214 author="flogin", 

215 version="1.0.0", 

216 description="A plugin with bogus metadata to test", 

217 ) 

218 

219 @classmethod 

220 def create_plugin_metadata( 

221 cls: type[PluginTester], 

222 *, 

223 id: str, 

224 name: str, 

225 author: str, 

226 version: str, 

227 description: str, 

228 website: str | None = None, 

229 disabled: bool = False, 

230 directory: str | None = None, 

231 keywords: list[str] | None = None, 

232 main_keyword: str | None = None, 

233 icon_path: str | None = None, 

234 ) -> PluginMetadata: 

235 r"""This classmethod can be used to easily create a valid :class:`~flogin.flow.plugin_metadata.PluginMetadata` object that has correct data. 

236 

237 Parameters 

238 ----------- 

239 id: :class:`str` 

240 The plugin's id 

241 name: :class:`str` 

242 The plugin's name 

243 author: :class:`str` 

244 The plugin's author 

245 version: :class:`str` 

246 The plugin's version 

247 description: :class:`str` 

248 The plugin's description 

249 website: Optional[:class:`str`] 

250 The plugin's website. If not given, the following fstring will be used instead: ``f"https://github.com/{author}/{name}"`` 

251 disabled: Optional[:class:`bool`] 

252 Whether or not to mark the plugin as disabled. Defaults to ``False`` 

253 directory: Optional[:class:`str`] 

254 The plugin's directory. Defaults to the current working directory. 

255 keywords: Optional[list[:class:`str`]] 

256 The plugin's keywords. Defaults to ``["*"]`` 

257 main_keyword: Optional[:class:`str`] 

258 The plugin's main keyword. Defaults to the first keyword in the keywords parameter 

259 icon_path: Optional[:class:`str`] 

260 The plugin's icon. Defaults to an invalid icon path. 

261 

262 Returns 

263 -------- 

264 :class:`~flogin.flow.plugin_metadata.PluginMetadata` 

265 Your new metadata class. 

266 """ 

267 

268 action_keywords: list[str] = keywords or ["*"] 

269 try: 

270 main_keyword = main_keyword or action_keywords[0] 

271 except IndexError: 

272 main_keyword = "*" 

273 

274 data = { 

275 "id": id, 

276 "name": name, 

277 "author": author, 

278 "version": version, 

279 "language": "python_v2", 

280 "description": description, 

281 "website": website or f"https://github.com/{author}/{name}", 

282 "disabled": disabled, 

283 "pluginDirectory": directory or os.getcwd(), 

284 "actionKeywords": action_keywords, 

285 "main_keyword": main_keyword, 

286 "executeFilePath": sys.argv[0], 

287 "icoPath": icon_path or "", 

288 } 

289 

290 filler_object = FillerObject(API_FILLER_TEXT) 

291 return PluginMetadata(data, filler_object) # type: ignore[reportArgumentType] 

292 

293 def __repr__(self) -> str: 

294 return f"<PluginTester id={id(self)} {self.plugin=}>"