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
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-03 22:51 +0000
1from __future__ import annotations
3import json
4import os
5import sys
6import uuid
7from typing import TYPE_CHECKING, Any, Generic
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
16if TYPE_CHECKING:
17 from pathlib import Path
19 from .._types.settings import RawSettings
20 from ..jsonrpc.responses import QueryResponse
21 from ..jsonrpc.results import Result
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"
26__all__ = ("PluginTester",)
29class PluginTester(Generic[PluginT]):
30 r"""This can be used to write tests for your plugins.
32 See the :doc:`testing` guide for more information on writing tests.
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.
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.
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.
53 .. versionadded: 1.1.0
55 Attributes
56 ----------
57 plugin: :class:`~flogin.plugin.Plugin`
58 Your plugin
59 """
61 plugin: PluginT
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
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)
83 if isinstance(metadata, dict):
84 metadata = PluginMetadata(metadata, self.plugin.api)
86 self.plugin._metadata = metadata
88 self.set_flow_api_client(flow_api_client)
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)
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.
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)
108 self.plugin.api = flow_api_client
109 self.plugin.metadata._flow_api = flow_api_client
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|
121 This coroutine can be used to send your plugin a query, and get the response.
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.
130 Returns
131 -------
132 :class:`~flogin.jsonrpc.responses.QueryResponse`
133 The query response object that would normally be sent to flow.
134 """
136 if isinstance(settings, dict):
137 settings = Settings(settings)
138 if settings is MISSING:
139 settings = Settings({})
141 if isinstance(settings, Settings):
142 self.plugin.settings = settings
143 self.plugin._settings_are_populated = True
145 query = Query(
146 {
147 "rawQuery": f"{keyword} {text}",
148 "search": text,
149 "actionKeyword": keyword,
150 "isReQuery": is_requery,
151 },
152 self.plugin,
153 )
155 coro = self.plugin.process_search_handlers(query)
157 return await coro # type: ignore
159 async def test_context_menu(
160 self, result: Result, *, bypass_registration: bool = False
161 ) -> QueryResponse:
162 r"""|coro|
164 This coroutine can be used to send a result's context menu, and get the response.
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.
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.
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.
181 Returns
182 -------
183 :class:`~flogin.jsonrpc.responses.QueryResponse`
184 The query response object that would normally be sent to flow.
185 """
187 coro = self.plugin.dispatch("context_menu", [result.slug])
189 if coro is None:
190 if bypass_registration:
191 self.plugin._results[result.slug] = result
192 return await self.test_context_menu(result)
194 raise ValueError("Result has not been registered.")
196 return await coro # type: ignore[returnTypeError]
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.
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.
205 Returns
206 --------
207 :class:`~flogin.flow.plugin_metadata.PluginMetadata`
208 The :class:`~flogin.flow.plugin_metadata.PluginMetadata` instance with your bogus information.
209 """
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 )
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.
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.
262 Returns
263 --------
264 :class:`~flogin.flow.plugin_metadata.PluginMetadata`
265 Your new metadata class.
266 """
268 action_keywords: list[str] = keywords or ["*"]
269 try:
270 main_keyword = main_keyword or action_keywords[0]
271 except IndexError:
272 main_keyword = "*"
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 }
290 filler_object = FillerObject(API_FILLER_TEXT)
291 return PluginMetadata(data, filler_object) # type: ignore[reportArgumentType]
293 def __repr__(self) -> str:
294 return f"<PluginTester id={id(self)} {self.plugin=}>"