diff --git a/README.md b/README.md index f7cfb26..6d64d8e 100644 --- a/README.md +++ b/README.md @@ -71,3 +71,42 @@ class Query(Method): plugin.add_method(Query()) plugin.run() ``` + +### Context menus + +`Plugin` ships with a built-in `context_menu` method: put `Result` objects in a +result's `context_data` and they become that result's context menu — no handler +code required. + +```py +from pyflowlauncher import Plugin, Result + +plugin = Plugin() + + +@plugin.on_method +def query(query: str): + yield Result( + title="This is a title!", + subtitle="Right-click me for a context menu.", + context_data=[ + Result(title="This is a context menu item!"), + Result(title="So is this!"), + ], + ) + + +plugin.run() +``` + +Menu items are full `Result` objects, so they can carry actions via +`add_action()` just like query results. + +If you need more control (for example, building the menu lazily when it is +opened), register your own `context_menu` — it replaces the built-in one: + +```py +@plugin.on_method +def context_menu(context_data): + yield Result(title=f"Menu for {context_data[0]}") +``` diff --git a/docs/examples/guide/plugin_methods/example3.py b/docs/examples/guide/plugin_methods/example3.py new file mode 100644 index 0000000..c5bdda4 --- /dev/null +++ b/docs/examples/guide/plugin_methods/example3.py @@ -0,0 +1,18 @@ +from pyflowlauncher import Plugin, Result + +plugin = Plugin() + + +@plugin.on_method +def query(query: str): + yield Result( + title="This is a title!", + subtitle="Right-click me for a context menu.", + context_data=[ + Result(title="This is a context menu item!"), + Result(title="So is this!"), + ], + ) + + +plugin.run() diff --git a/pyflowlauncher/__init__.py b/pyflowlauncher/__init__.py index cea85fa..db019a1 100644 --- a/pyflowlauncher/__init__.py +++ b/pyflowlauncher/__init__.py @@ -3,6 +3,7 @@ from .plugin import Plugin from .result import Result, send_results from .method import Method +from .command import Command from .response import handle_response from .launcher import FlowLauncherV1, FlowLauncherV2 @@ -15,6 +16,7 @@ "send_results", "Result", "Method", + "Command", "handle_response", "FlowLauncherV1", "FlowLauncherV2", diff --git a/pyflowlauncher/api.py b/pyflowlauncher/api.py index 697aa10..4f7b28c 100644 --- a/pyflowlauncher/api.py +++ b/pyflowlauncher/api.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING, Any, Callable, Coroutine, Optional -from .models.json_rpc import JsonRPCRequest +from .command import Command if TYPE_CHECKING: from .string_matcher import MatchData @@ -10,104 +10,91 @@ NAME_SPACE = 'Flow.Launcher' -def _send_action(method: str, *parameters) -> JsonRPCRequest: - return {"Method": f"{NAME_SPACE}.{method}", "Parameters": list(parameters)} +class NotSupportedError(Exception): + """Raised when an operation is not supported by the active protocol version.""" -def change_query(query: str, requery: bool = False) -> JsonRPCRequest: - """Change the query in Flow Launcher.""" - return _send_action("ChangeQuery", query, requery) +def _send_action(method: str, *parameters) -> Command: + return Command({"Method": f"{NAME_SPACE}.{method}", "Parameters": list(parameters)}) -def shell_run(command: str, filename: str = 'cmd.exe') -> JsonRPCRequest: - """Run a shell command.""" - return _send_action("ShellRun", command, filename) - - -def close_app() -> JsonRPCRequest: - """Close Flow Launcher.""" - return _send_action("CloseApp") - - -def hide_app() -> JsonRPCRequest: - """Hide Flow Launcher.""" - return _send_action("HideApp") - - -def show_app() -> JsonRPCRequest: - """Show Flow Launcher.""" - return _send_action("ShowApp") - - -def show_msg(title: str, sub_title: str, ico_path: str = "") -> JsonRPCRequest: - """Show a message in Flow Launcher.""" - return _send_action("ShowMsg", title, sub_title, ico_path) - - -def open_setting_dialog() -> JsonRPCRequest: - """Open the settings window in Flow Launcher.""" - return _send_action("OpenSettingDialog") +_FuzzySearchFn = Callable[[str, str], Coroutine[Any, Any, "MatchData"]] -def start_loading_bar() -> JsonRPCRequest: - """Start the loading bar in Flow Launcher.""" - return _send_action("StartLoadingBar") +class Api: + """Flow Launcher API, accessible via ``plugin.launcher.api``. + Builder methods return inert ``Command`` values; ``fuzzy_search`` and + ``invoke`` are the launcher-bound calls. + """ -def stop_loading_bar() -> JsonRPCRequest: - """Stop the loading bar in Flow Launcher.""" - return _send_action("StopLoadingBar") + def __init__( + self, + fuzzy_search_fn: Optional[_FuzzySearchFn] = None, + invoke_fn: Optional[Callable[["Command"], Coroutine[Any, Any, Any]]] = None, + ) -> None: + self._fuzzy_search_fn = fuzzy_search_fn + self._invoke_fn = invoke_fn + def change_query(self, query: str, requery: bool = False) -> Command: + """Change the query in Flow Launcher.""" + return _send_action("ChangeQuery", query, requery) -def reload_plugins() -> JsonRPCRequest: - """Reload the plugins in Flow Launcher.""" - return _send_action("ReloadPlugins") + def shell_run(self, command: str, filename: str = 'cmd.exe') -> Command: + """Run a shell command.""" + return _send_action("ShellRun", command, filename) + def close_app(self) -> Command: + """Close Flow Launcher.""" + return _send_action("CloseApp") -def copy_to_clipboard(text: str, direct_copy: bool = False, show_default_notification=True) -> JsonRPCRequest: - """Copy text to the clipboard.""" - return _send_action("CopyToClipboard", text, direct_copy, show_default_notification) + def hide_app(self) -> Command: + """Hide Flow Launcher.""" + return _send_action("HideApp") + def show_app(self) -> Command: + """Show Flow Launcher.""" + return _send_action("ShowApp") -def open_directory(directory_path: str, filename_or_filepath: Optional[str] = None) -> JsonRPCRequest: - """Open a directory.""" - return _send_action("OpenDirectory", directory_path, filename_or_filepath) + def show_msg(self, title: str, sub_title: str, ico_path: str = "") -> Command: + """Show a message in Flow Launcher.""" + return _send_action("ShowMsg", title, sub_title, ico_path) + def open_setting_dialog(self) -> Command: + """Open the settings window in Flow Launcher.""" + return _send_action("OpenSettingDialog") -def open_url(url: str, in_private: bool = False) -> JsonRPCRequest: - """Open a URL.""" - return _send_action("OpenUrl", url, in_private) + def start_loading_bar(self) -> Command: + """Start the loading bar in Flow Launcher.""" + return _send_action("StartLoadingBar") + def stop_loading_bar(self) -> Command: + """Stop the loading bar in Flow Launcher.""" + return _send_action("StopLoadingBar") -def open_uri(uri: str) -> JsonRPCRequest: - """Open a URI.""" - return _send_action("OpenAppUri", uri) + def reload_plugins(self) -> Command: + """Reload the plugins in Flow Launcher.""" + return _send_action("ReloadPlugins") + def copy_to_clipboard(self, text: str, direct_copy: bool = False, + show_default_notification: bool = True) -> Command: + """Copy text to the clipboard.""" + return _send_action("CopyToClipboard", text, direct_copy, show_default_notification) -_FuzzySearchFn = Callable[[str, str], Coroutine[Any, Any, "MatchData"]] + def open_directory(self, directory_path: str, + filename_or_filepath: Optional[str] = None) -> Command: + """Open a directory.""" + return _send_action("OpenDirectory", directory_path, filename_or_filepath) + def open_url(self, url: str, in_private: bool = False) -> Command: + """Open a URL.""" + return _send_action("OpenUrl", url, in_private) -class Api: - """Flow Launcher API calls, accessible via plugin.launcher.api.""" - change_query = staticmethod(change_query) - shell_run = staticmethod(shell_run) - close_app = staticmethod(close_app) - hide_app = staticmethod(hide_app) - show_app = staticmethod(show_app) - show_msg = staticmethod(show_msg) - open_setting_dialog = staticmethod(open_setting_dialog) - start_loading_bar = staticmethod(start_loading_bar) - stop_loading_bar = staticmethod(stop_loading_bar) - reload_plugins = staticmethod(reload_plugins) - copy_to_clipboard = staticmethod(copy_to_clipboard) - open_directory = staticmethod(open_directory) - open_url = staticmethod(open_url) - open_uri = staticmethod(open_uri) - - def __init__(self, fuzzy_search_fn: Optional[_FuzzySearchFn] = None) -> None: - self._fuzzy_search_fn = fuzzy_search_fn + def open_uri(self, uri: str) -> Command: + """Open a URI.""" + return _send_action("OpenAppUri", uri) - async def fuzzy_search(self, query: str, text: str) -> MatchData: + async def fuzzy_search(self, query: str, text: str) -> "MatchData": """Match query against text. On V2 delegates to Flow Launcher's own FuzzySearch over JSON-RPC so @@ -121,3 +108,36 @@ async def fuzzy_search(self, query: str, text: str) -> MatchData: "plugin.launcher.api instead of constructing Api() directly." ) return await self._fuzzy_search_fn(query, text) + + async def invoke(self, command: Command) -> Any: + """Send a command to Flow Launcher now and return its response. + + Supported on Flow Launcher V2 only; on V1 the launcher-provided + ``invoke_fn`` raises ``NotSupportedError``. + """ + if self._invoke_fn is None: + raise RuntimeError( + "invoke is unavailable: this Api was created without an " + "invoke_fn. Use the launcher-provided instance via " + "plugin.launcher.api instead of constructing Api() directly." + ) + return await self._invoke_fn(command) + + +# Backwards-compatible module-level builders (pyflowlauncher.api.change_query, ...). +# Bound to a default Api instance; builders are pure and ignore instance state. +_default_api = Api() +change_query = _default_api.change_query +shell_run = _default_api.shell_run +close_app = _default_api.close_app +hide_app = _default_api.hide_app +show_app = _default_api.show_app +show_msg = _default_api.show_msg +open_setting_dialog = _default_api.open_setting_dialog +start_loading_bar = _default_api.start_loading_bar +stop_loading_bar = _default_api.stop_loading_bar +reload_plugins = _default_api.reload_plugins +copy_to_clipboard = _default_api.copy_to_clipboard +open_directory = _default_api.open_directory +open_url = _default_api.open_url +open_uri = _default_api.open_uri diff --git a/pyflowlauncher/command.py b/pyflowlauncher/command.py new file mode 100644 index 0000000..83d1221 --- /dev/null +++ b/pyflowlauncher/command.py @@ -0,0 +1,11 @@ +from __future__ import annotations + + +class Command(dict): + """An inert Flow Launcher action (Method + Parameters). + + Built by ``pyflowlauncher.api`` builders. Subclasses ``dict`` so it + serializes directly to the JsonRPCAction shape and compares equal to the + plain dict, while remaining distinguishable via ``isinstance`` for + ``Result.add_action`` and the response collectors. + """ diff --git a/pyflowlauncher/event.py b/pyflowlauncher/event.py index 3446011..75194c3 100644 --- a/pyflowlauncher/event.py +++ b/pyflowlauncher/event.py @@ -2,6 +2,7 @@ import inspect from typing import Any, Callable, Iterable, Type, Union +from .command import Command from .result import Result, send_results from .response import _collect_item @@ -45,8 +46,14 @@ async def _await_maybe(self, result: Any) -> Any: return await self._await_maybe(await result) if inspect.isasyncgen(result): results = [] + command = None async for item in result: + if isinstance(item, Command): + command = item + continue results.extend(_collect_item(item)) + if command is not None: + return command return send_results(results) if isinstance(result, Result): return send_results([result]) diff --git a/pyflowlauncher/jsonrpc.py b/pyflowlauncher/jsonrpc.py index a9aaeb3..fa8e477 100644 --- a/pyflowlauncher/jsonrpc.py +++ b/pyflowlauncher/jsonrpc.py @@ -14,6 +14,13 @@ _logger = logging.getLogger(__name__) +def _json_default(o: Any) -> Any: + to_json = getattr(o, 'to_json', None) + if callable(to_json): + return to_json() + raise TypeError(f"Object of type {type(o).__name__} is not JSON serializable") + + class JsonRPCRequest(TypedDict): method: str parameters: list @@ -23,7 +30,7 @@ class JsonRPCRequest(TypedDict): class JsonRPCClient: def send(self, data: Mapping) -> None: - json.dump(data, sys.stdout) + json.dump(data, sys.stdout, default=_json_default) def recieve(self) -> JsonRPCRequest: try: @@ -98,5 +105,5 @@ async def request( self._pending.pop(req_id, None) def send(self, data: dict) -> None: - sys.stdout.write(json.dumps(data) + '\n') + sys.stdout.write(json.dumps(data, default=_json_default) + '\n') sys.stdout.flush() diff --git a/pyflowlauncher/launcher.py b/pyflowlauncher/launcher.py index e1930a0..dcbf27b 100644 --- a/pyflowlauncher/launcher.py +++ b/pyflowlauncher/launcher.py @@ -6,20 +6,28 @@ from pathlib import Path from typing import Any, Awaitable, Callable, Dict, Optional -from .api import NAME_SPACE, Api +from .api import NAME_SPACE, Api, NotSupportedError from .base import pyFlowLauncherObject +from .command import Command from .icons import Icons from .jsonrpc import JsonRPCClient, JsonRPCV2Client from .models.json_rpc import MatchResult from .string_matcher import MatchData, string_matcher as _local_string_matcher +def _host_method(method: str) -> str: + # The host registers JsonRPCPublicAPI under bare CLR method names + # (OpenAppUri, not Flow.Launcher.OpenAppUri), so strip the namespace. + prefix = f'{NAME_SPACE}.' + return method[len(prefix):] if method.startswith(prefix) else method + + class Launcher(pyFlowLauncherObject, ABC): def __init__(self) -> None: super().__init__() self._settings: dict = {} - self.api = Api(fuzzy_search_fn=self._fuzzy_search) + self.api = Api(fuzzy_search_fn=self._fuzzy_search, invoke_fn=self._invoke) self._program_dir: Optional[Path] = self._find_program_dir() self.icons = Icons(self._program_dir) @@ -27,6 +35,13 @@ async def _fuzzy_search(self, query: str, text: str) -> MatchData: """Local fallback matcher; subclasses may delegate to the host.""" return _local_string_matcher(query, text) + async def _invoke(self, command: Command) -> Any: + """Send a command to the host; V1 cannot push, so this is unsupported.""" + raise NotSupportedError( + "invoke requires Flow Launcher V2; attach the command to a Result " + "with add_action(), or return it, instead." + ) + @property def settings(self) -> dict: return self._settings @@ -93,6 +108,11 @@ async def _fuzzy_search(self, query: str, text: str) -> MatchData: score=result.get('score', 0), ) + async def _invoke(self, command: Command) -> Any: + """Send the command over JSON-RPC and return Flow Launcher's response.""" + return await self._client.request( + _host_method(command['Method']), command['Parameters']) + async def run(self, dispatch: Callable[[str, list], Awaitable[Any]]) -> None: tasks: set = set() in_flight: Dict[Any, asyncio.Task] = {} @@ -189,23 +209,41 @@ async def _handle_request( 'result': [], 'debugMessage': 'Internal error', 'settingsChange': None, }) return + if isinstance(result, dict) and 'Method' in result: + # An action method returned an api Command (or the raw request + # dict older releases produced). V1 hosts execute a request the + # action writes to stdout, but a V2 host only reads the result as + # JsonRPCExecuteResponse, so the command must be sent as our own + # request instead of being dropped. + try: + await self._forward_to_host(result['Method'], list(result.get('Parameters', []))) + except asyncio.CancelledError: + self._client.send({'id': request_id, 'result': None, 'error': { + 'code': -32800, 'message': 'Request cancelled', + }}) + raise + self._respond(request_id, {'hide': True}) + return self._send_response(request_id, method, result) async def _handle_builtin_action(self, request_id: Any, method: str, params: list) -> None: - # The host registers JsonRPCPublicAPI under bare CLR method names - # (OpenAppUri, not Flow.Launcher.OpenAppUri), so strip the namespace. - host_method = method[len(NAME_SPACE) + 1:] try: - await self._client.request(host_method, params) + await self._forward_to_host(method, params) except asyncio.CancelledError: self._client.send({'id': request_id, 'result': None, 'error': { 'code': -32800, 'message': 'Request cancelled', }}) raise - except Exception: - self.logger.exception("Failed to forward built-in action %r to the host", method) self._respond(request_id, {'hide': True}) + async def _forward_to_host(self, method: str, params: list) -> None: + try: + await self._client.request(_host_method(method), params) + except asyncio.CancelledError: + raise + except Exception: + self.logger.exception("Failed to forward action %r to the host", method) + def _respond(self, request_id: Any, result: Any) -> None: """Send a response in the uniform {id, result, error} envelope.""" self._client.send({'id': request_id, 'result': result, 'error': None}) diff --git a/pyflowlauncher/plugin.py b/pyflowlauncher/plugin.py index 8c775db..b1d1b14 100644 --- a/pyflowlauncher/plugin.py +++ b/pyflowlauncher/plugin.py @@ -2,6 +2,7 @@ import json import sys +from collections.abc import Iterable as IterableABC from functools import cached_property, wraps from typing import Any, Callable, Iterable, Optional, Type, List from pathlib import Path @@ -13,6 +14,7 @@ from .launcher import Launcher, FlowLauncherV1, FlowLauncherV2 from .jsonrpc import JsonRPCRequest from .response import handle_response +from .result import Result from .models.plugin_manifest import FILE_NAME from .manifest import Manifest @@ -20,12 +22,32 @@ from .types import Method +def _default_context_menu(context_data): + """Rebuild the menu Results a plugin front-loaded into a result's context_data. + + Flow Launcher echoes ContextData back as parsed JSON, so serialized Results + arrive as dicts; anything else in context_data is skipped. + """ + if isinstance(context_data, Result): + return [context_data] + if isinstance(context_data, (str, bytes, dict)) or not isinstance(context_data, IterableABC): + return None + results = [] + for item in context_data: + if isinstance(item, Result): + results.append(item) + elif isinstance(item, dict) and 'Title' in item: + results.append(Result.from_json(item)) + return results + + class Plugin(pyFlowLauncherObject): def __init__(self, methods: list[Method] | None = None, launcher: Optional[Launcher] = None) -> None: super().__init__() self._launcher: Launcher = launcher if launcher is not None else self._detect_launcher() self._event_handler = EventHandler() + self.add_method(_default_context_menu, name='context_menu') if methods: self.add_methods(methods) @@ -40,14 +62,14 @@ def _detect_launcher(self) -> Launcher: "Malformed plugin manifest; defaulting to V1 launcher.", exc_info=True) return FlowLauncherV1() - def add_method(self, method: Method) -> str: - """Add a method to the event handler.""" + def add_method(self, method: Method, *, name: Optional[str] = None) -> str: + """Add a method to the event handler, optionally under an explicit RPC name.""" @wraps(method) def wrapper(*args, **kwargs): return handle_response(method(*args, **kwargs)) setattr(wrapper, '_is_registered_method', True) setattr(method, '_is_registered_method', True) - return self._event_handler.add_event(wrapper) + return self._event_handler.add_event(wrapper, name=name) def add_methods(self, methods: Iterable[Method]) -> None: for method in methods: diff --git a/pyflowlauncher/response.py b/pyflowlauncher/response.py index 560d0a1..127c225 100644 --- a/pyflowlauncher/response.py +++ b/pyflowlauncher/response.py @@ -1,13 +1,14 @@ from __future__ import annotations import inspect -from typing import Any, Generator, Union +from typing import Any, Generator, Optional, Union +from .command import Command from .result import Result, send_results from .models.json_rpc import JsonRPCRequest, JsonRPCResponse -def handle_response(result: Any) -> Union[JsonRPCResponse, JsonRPCRequest, None]: +def handle_response(result: Any) -> Union[JsonRPCResponse, JsonRPCRequest, Command, None]: """Normalize a method's return value into a JSON-RPC response. Accepts: Result, list of Result, generator of Result/list, JsonRPCRequest, @@ -33,8 +34,14 @@ def _collect_item(item: Any) -> list[Result]: return [] -def _collect_generator(gen: Generator) -> JsonRPCResponse: +def _collect_generator(gen: Generator[Any, Any, Any]) -> Union[JsonRPCResponse, Command]: results = [] + command: Optional[Command] = None for item in gen: + if isinstance(item, Command): + command = item + continue results.extend(_collect_item(item)) + if command is not None: + return command return send_results(results) diff --git a/pyflowlauncher/result.py b/pyflowlauncher/result.py index 4f6c1fb..8bce56c 100644 --- a/pyflowlauncher/result.py +++ b/pyflowlauncher/result.py @@ -11,6 +11,7 @@ from typing import Self from .types import Method +from .command import Command from .models.result import Glyph, PreviewInfo from .models.json_rpc import JsonRPCResult, JsonRPCRequest, JsonRPCResponse @@ -42,13 +43,26 @@ class Result: title_highlight_data: Optional[List[int]] = None def add_action( - self, method: Method, parameters: Optional[Iterable[Any]] = None, dont_hide_after_action: bool = False + self, action: Union[Method, Command], + parameters: Optional[Iterable[Any]] = None, dont_hide_after_action: bool = False ) -> Self: - """Adds a JsonRPC action to the result.""" - if not getattr(method, '_is_registered_method', False): - raise MethodNotRegisteredError(method) + """Adds a JsonRPC action to the result. + + ``action`` may be a registered plugin ``Method`` (its ``Parameters`` + come from ``parameters``) or an inert ``Command`` built from + ``plugin.launcher.api`` (its ``Method``/``Parameters`` are used as-is). + """ + if isinstance(action, Command): + self.json_rpc_action = { + 'Method': action['Method'], + 'Parameters': list(action.get('Parameters', [])), + 'DontHideAfterAction': dont_hide_after_action, + } + return self + if not getattr(action, '_is_registered_method', False): + raise MethodNotRegisteredError(action) self.json_rpc_action = { - 'Method': method.__name__, + 'Method': action.__name__, 'Parameters': list(parameters) if parameters else [], 'DontHideAfterAction': dont_hide_after_action, } @@ -62,6 +76,7 @@ def from_json(json_result: JsonRPCResult) -> Result: """Creates a Result instance from a JsonRPCResult dictionary.""" if 'Title' not in json_result: raise ValueError("JsonRPCResult must have a 'Title' field") + title_highlight_data = json_result.get('TitleHighlightData') return Result( title=json_result['Title'], subtitle=json_result.get('SubTitle'), @@ -74,7 +89,7 @@ def from_json(json_result: JsonRPCResult) -> Result: auto_complete_text=json_result.get('AutoCompleteText'), rounded_icon=json_result.get('RoundedIcon', False), preview=json_result.get('Preview'), - title_highlight_data=list(json_result.get('TitleHighlightData', [])) + title_highlight_data=list(title_highlight_data) if title_highlight_data else None ) def to_json(self) -> JsonRPCResult: diff --git a/tests/integration/test_v1_protocol.py b/tests/integration/test_v1_protocol.py index 0341ad8..411b6cc 100644 --- a/tests/integration/test_v1_protocol.py +++ b/tests/integration/test_v1_protocol.py @@ -83,6 +83,36 @@ def query(q: str): assert response is None +class TestV1ReturnedCommands: + + def test_returned_command_sent_as_raw_request(self): + """V1 executes a Flow.Launcher.* request the action writes to stdout, + so a returned api Command must go out unwrapped (issue #41).""" + from pyflowlauncher import api + plugin = Plugin(launcher=FlowLauncherV1()) + + @plugin.on_method + def change_query(): + return api.change_query("new query!") + + response = run(plugin, {'method': 'change_query', 'parameters': []}) + assert response == {'Method': 'Flow.Launcher.ChangeQuery', + 'Parameters': ['new query!', False]} + + +class TestV1BuiltInContextMenu: + + def test_built_in_context_menu_rebuilds_stored_results(self): + """V1 restarts the process per call, so the built-in context_menu must + rebuild menu Results purely from the ContextData Flow sends back.""" + plugin = Plugin(launcher=FlowLauncherV1()) + context_data = [Result(title="ctx item", subtitle="ctx sub").to_json()] + response = run(plugin, {'method': 'context_menu', 'parameters': [context_data]}) + assert response is not None + assert response['Result'][0]['Title'] == 'ctx item' + assert response['Result'][0]['SubTitle'] == 'ctx sub' + + class TestV1Settings: def test_settings_available_after_run(self): diff --git a/tests/integration/test_v2_protocol.py b/tests/integration/test_v2_protocol.py index 94f6c9b..c244961 100644 --- a/tests/integration/test_v2_protocol.py +++ b/tests/integration/test_v2_protocol.py @@ -17,7 +17,7 @@ from typing import Any from unittest.mock import patch -from pyflowlauncher import Plugin, Result +from pyflowlauncher import Plugin, Result, api from pyflowlauncher.launcher import FlowLauncherV2 @@ -499,6 +499,93 @@ async def _inner(): assert resp.get('result', {}).get('debugMessage') != 'Internal error' +class TestV2ReturnedCommands: + """A registered action method that returns an api Command (issue #41). + + Result.add_action(method) wires the result to a custom method; when Flow + invokes it and the method returns api.change_query(...), the command must + be forwarded to the host, not swallowed by the JsonRPCExecuteResponse + envelope.""" + + def _plugin(self, handler) -> Plugin: + plugin = Plugin(launcher=FlowLauncherV2()) + plugin.add_method(handler, name='change_query') + return plugin + + def _run_action(self, plugin: Plugin) -> list: + return run(plugin, [ + {'id': 7, 'method': 'change_query', 'params': [[]]}, + {'id': 1, 'result': None}, + {'id': 8, 'method': 'close', 'params': []}, + ]) + + def test_returned_command_forwarded_to_host(self): + responses = self._run_action(self._plugin( + lambda: api.change_query("new query!"))) + forwarded = next(r for r in responses if r.get('method') == 'ChangeQuery') + assert forwarded['params'] == ['new query!', False] + assert query_response(responses, 7)['result'] == {'hide': True} + assert query_response(responses, 7).get('error') is None + + def test_yielded_command_forwarded_to_host(self): + def handler(): + yield api.change_query("new query!") + responses = self._run_action(self._plugin(handler)) + forwarded = next(r for r in responses if r.get('method') == 'ChangeQuery') + assert forwarded['params'] == ['new query!', False] + + def test_returned_plain_request_dict_forwarded_to_host(self): + """Plugins written against older releases return the raw + {'Method': 'Flow.Launcher.X', 'Parameters': [...]} dict.""" + responses = self._run_action(self._plugin( + lambda: {'Method': 'Flow.Launcher.ChangeQuery', + 'Parameters': ['new query!', False]})) + forwarded = next(r for r in responses if r.get('method') == 'ChangeQuery') + assert forwarded['params'] == ['new query!', False] + + +class TestV2BuiltInContextMenu: + + def test_context_data_results_round_trip_without_user_handler(self): + """Results front-loaded into context_data serialize out with the query + response, and the built-in context_menu rebuilds them when Flow sends + the stored ContextData back — no user-defined handler required.""" + plugin = Plugin(launcher=FlowLauncherV2()) + + @plugin.on_method + def query(q: str): + yield Result(title="parent", context_data=[ + Result(title="ctx item", subtitle="ctx sub"), + ]) + + responses = run(plugin, [ + {'id': 1, 'method': 'query', 'params': [{'search': 'x'}, {}]}, + {'id': 2, 'method': 'close', 'params': []}, + ]) + context_data = query_response(responses, 1)['result']['result'][0]['ContextData'] + assert context_data[0]['Title'] == 'ctx item' + + responses = run(plugin, [ + {'id': 3, 'method': 'context_menu', 'params': [context_data]}, + {'id': 4, 'method': 'close', 'params': []}, + ]) + menu = query_response(responses, 3)['result']['result'] + assert menu[0]['Title'] == 'ctx item' + assert menu[0]['SubTitle'] == 'ctx sub' + + def test_built_in_context_menu_with_plain_context_data(self): + """Legacy plugins store plain tokens in context_data; the built-in + handler must return an empty menu, not crash.""" + plugin = Plugin(launcher=FlowLauncherV2()) + responses = run(plugin, [ + {'id': 1, 'method': 'context_menu', 'params': [['ctx1', 'ctx2']]}, + {'id': 2, 'method': 'close', 'params': []}, + ]) + result = query_response(responses, 1)['result'] + assert result['result'] == [] + assert result.get('debugMessage') != 'Internal error' + + class TestV2Settings: def test_settings_stored_from_query_params(self): @@ -548,6 +635,48 @@ def query(q: str): assert plugin._launcher.settings == {'key': 'val'} +class TestV2Invoke: + + def test_invoke_sends_bare_method_name_on_the_wire(self): + """api.invoke must strip the Flow.Launcher. namespace like the action + loopback does; the host only knows the bare CLR name.""" + plugin = Plugin(launcher=FlowLauncherV2()) + invoke_results = [] + + @plugin.on_method + async def query(q: str): + invoke_results.append( + await plugin.launcher.api.invoke(api.show_msg("hi", "there"))) + yield Result(title="done") + + stdin_text = ( + json.dumps({'id': 10, 'method': 'query', + 'params': [{'search': 'x'}]}) + '\n' + + json.dumps({'id': 1, 'result': None}) + '\n' + + json.dumps({'id': 11, 'method': 'close', 'params': []}) + '\n' + ) + responses = [] + + async def dispatch(method: str, params: list) -> Any: + return await plugin._event_handler.trigger_event(method, *params) + + async def _inner(): + with patch('sys.stdin', StringIO(stdin_text)), \ + patch('sys.stdout', StringIO()) as out: + await plugin._launcher.run(dispatch) + out.seek(0) + for line in out.read().splitlines(): + if line.strip(): + responses.append(json.loads(line)) + + asyncio.run(_inner()) + + outbound = next(r for r in responses if 'method' in r and r.get('id') == 1) + assert outbound['method'] == 'ShowMsg' + assert outbound['params'] == ['hi', 'there', ''] + assert invoke_results == [None] + + class TestV2FuzzySearch: def test_fuzzy_search_round_trip(self): diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py index 50c6b70..401cc44 100644 --- a/tests/unit/test_api.py +++ b/tests/unit/test_api.py @@ -1,9 +1,19 @@ import asyncio -import inspect import pytest from pyflowlauncher import api +from pyflowlauncher.command import Command + + +def test_builder_returns_command_equal_to_plain_dict(): + cmd = api.change_query("Test", False) + assert isinstance(cmd, Command) + assert cmd == {"Method": "Flow.Launcher.ChangeQuery", "Parameters": ["Test", False]} + + +def test_api_instance_builder_matches_module_builder(): + assert api.Api().change_query("Test") == api.change_query("Test") def test_send_action(): @@ -72,13 +82,19 @@ def test_fuzzy_search_without_backend_raises_clear_error(): asyncio.run(bare_api.fuzzy_search("query", "text")) -def test_api_class_exposes_every_module_level_action(): - """Guard: each public module-level API function must exist on Api.""" - module_functions = [ - name for name, obj in vars(api).items() - if inspect.isfunction(obj) - and not name.startswith('_') - and obj.__module__ == api.__name__ +def test_command_exported_from_package_root(): + import pyflowlauncher + from pyflowlauncher.command import Command as _Command + assert pyflowlauncher.Command is _Command + + +def test_module_level_builders_resolve_to_api_methods(): + """Guard: every BC module-level builder is the Api method of the same name.""" + names = [ + "change_query", "shell_run", "close_app", "hide_app", "show_app", + "show_msg", "open_setting_dialog", "start_loading_bar", "stop_loading_bar", + "reload_plugins", "copy_to_clipboard", "open_directory", "open_url", "open_uri", ] - missing = [name for name in module_functions if not hasattr(api.Api, name)] - assert not missing, f"Api class is missing module-level actions: {missing}" + for name in names: + assert hasattr(api.Api, name), f"Api is missing builder: {name}" + assert getattr(api, name).__func__ is getattr(api.Api, name) diff --git a/tests/unit/test_jsonrpc.py b/tests/unit/test_jsonrpc.py index 77df69c..6079868 100644 --- a/tests/unit/test_jsonrpc.py +++ b/tests/unit/test_jsonrpc.py @@ -1,4 +1,5 @@ import asyncio +import json import logging import sys from io import StringIO @@ -7,6 +8,7 @@ import pytest from pyflowlauncher.jsonrpc import JsonRPCClient, JsonRPCV2Client +from pyflowlauncher.result import Result @pytest.fixture @@ -32,6 +34,22 @@ def test_send(capture_stdout): assert capture_stdout["stdout"] == '{"method": "Test", "parameters": []}' +def test_send_serializes_result_in_context_data(capture_stdout): + jsonrpc = JsonRPCClient() + result = Result(title="outer", context_data=[Result(title="menu item")]) + jsonrpc.send({"Result": [result.to_json()], "SettingsChange": None}) + + payload = json.loads(capture_stdout["stdout"]) + assert payload["Result"][0]["ContextData"][0]["Title"] == "menu item" + + +def test_send_unserializable_object_raises_type_error(): + jsonrpc = JsonRPCClient() + with patch('sys.stdout', StringIO()): + with pytest.raises(TypeError): + jsonrpc.send({"Result": object()}) + + def test_recieve(monkeypatch): jsonrpc = JsonRPCClient() @@ -63,6 +81,22 @@ def test_v2_send_newline_delimited(capture_stdout): assert capture_stdout["stdout"] == '{"id": 1, "result": {}}\n' +def test_v2_send_serializes_result_in_context_data(capture_stdout): + client = JsonRPCV2Client() + result = Result(title="outer", context_data=[Result(title="menu item")]) + client.send({"id": 1, "result": {"result": [result.to_json()]}}) + + payload = json.loads(capture_stdout["stdout"]) + assert payload["result"]["result"][0]["ContextData"][0]["Title"] == "menu item" + + +def test_v2_send_unserializable_object_raises_type_error(): + client = JsonRPCV2Client() + with patch('sys.stdout', StringIO()): + with pytest.raises(TypeError): + client.send({"id": 1, "result": object()}) + + def test_v2_messages_yields_parsed_dicts(): msgs = _collect_messages('{"method": "query"}\n{"method": "close"}\n') assert msgs == [{"method": "query"}, {"method": "close"}] diff --git a/tests/unit/test_launcher.py b/tests/unit/test_launcher.py index 29177ae..f94771c 100644 --- a/tests/unit/test_launcher.py +++ b/tests/unit/test_launcher.py @@ -6,12 +6,39 @@ import pytest +from pyflowlauncher import api +from pyflowlauncher.api import NotSupportedError from pyflowlauncher.launcher import FlowLauncherV1, FlowLauncherV2, Launcher from pyflowlauncher.plugin import Plugin from pyflowlauncher.result import Result, send_results from pyflowlauncher.string_matcher import MatchData +def test_v1_invoke_raises_not_supported(): + launcher = FlowLauncherV1() + with pytest.raises(NotSupportedError): + asyncio.run(launcher.api.invoke(api.hide_app())) + + +def test_v2_invoke_sends_over_client_and_returns_response(): + """The host registers its API under bare CLR names (ChangeQuery, not + Flow.Launcher.ChangeQuery), so invoke must strip the namespace.""" + launcher = FlowLauncherV2() + + class FakeClient: + def __init__(self): + self.calls = [] + + async def request(self, method, params): + self.calls.append((method, params)) + return {"ok": True} + + launcher._client = FakeClient() + result = asyncio.run(launcher.api.invoke(api.change_query("g "))) + assert result == {"ok": True} + assert launcher._client.calls == [("ChangeQuery", ["g ", False])] + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- diff --git a/tests/unit/test_plugin.py b/tests/unit/test_plugin.py index 0807b8e..1132b5b 100644 --- a/tests/unit/test_plugin.py +++ b/tests/unit/test_plugin.py @@ -1,6 +1,9 @@ +import asyncio + import pytest from pyflowlauncher.plugin import Plugin from pyflowlauncher.launcher import Launcher +from pyflowlauncher.result import Result, send_results def temp_method1(): @@ -67,6 +70,61 @@ def test_action(): assert action == {'method': 'query', 'parameters': []} +def _trigger_context_menu(plugin, context_data): + return asyncio.run(plugin._event_handler.trigger_event('context_menu', context_data)) + + +def test_default_context_menu_is_registered(): + plugin = Plugin() + assert 'context_menu' in plugin._event_handler._events + + +def test_default_context_menu_rebuilds_results_from_wire_dicts(): + plugin = Plugin() + context_data = [Result(title='menu item', subtitle='sub').to_json()] + response = _trigger_context_menu(plugin, context_data) + assert response == send_results([Result(title='menu item', subtitle='sub')]) + + +def test_default_context_menu_accepts_result_instances(): + plugin = Plugin() + menu = Result(title='menu item') + assert _trigger_context_menu(plugin, [menu]) == send_results([menu]) + assert _trigger_context_menu(plugin, menu) == send_results([menu]) + + +def test_default_context_menu_skips_non_result_items(): + plugin = Plugin() + response = _trigger_context_menu(plugin, ['token', 42, {'no': 'title'}]) + assert response == send_results([]) + + +def test_default_context_menu_handles_non_iterable_data(): + plugin = Plugin() + assert _trigger_context_menu(plugin, None) is None + assert _trigger_context_menu(plugin, 'token') is None + + +def test_user_context_menu_overrides_default(): + plugin = Plugin() + + @plugin.on_method + def context_menu(data): + return Result(title='custom') + + response = _trigger_context_menu(plugin, [Result(title='ignored').to_json()]) + assert response == send_results([Result(title='custom')]) + + +def test_init_methods_context_menu_overrides_default(): + def context_menu(data): + return Result(title='custom') + + plugin = Plugin(methods=[context_menu]) + response = _trigger_context_menu(plugin, ['anything']) + assert response == send_results([Result(title='custom')]) + + def test_exception_handler(): plugin = Plugin() diff --git a/tests/unit/test_response.py b/tests/unit/test_response.py index 767f69e..9bc6d95 100644 --- a/tests/unit/test_response.py +++ b/tests/unit/test_response.py @@ -1,4 +1,8 @@ -from pyflowlauncher import Result, handle_response +import asyncio + +from pyflowlauncher import Result, api, handle_response +from pyflowlauncher.command import Command +from pyflowlauncher.event import EventHandler from pyflowlauncher.result import send_results @@ -61,3 +65,28 @@ def test_jsonrpc_response_passthrough(): def test_list_with_non_result_items_filtered(): results = [Result(title="a"), "not a result", 42] assert handle_response(results) == send_results([Result(title="a")]) + + +def test_returned_command_passes_through(): + assert handle_response(api.change_query("g ")) == { + "Method": "Flow.Launcher.ChangeQuery", "Parameters": ["g ", False] + } + + +def test_yielded_command_becomes_response(): + def gen(): + yield api.hide_app() + out = handle_response(gen()) + assert isinstance(out, Command) + assert out == {"Method": "Flow.Launcher.HideApp", "Parameters": []} + + +def test_async_yielded_command_becomes_response(): + handler = EventHandler() + + async def gen(): + yield api.show_app() + + out = asyncio.run(handler._await_maybe(gen())) + assert isinstance(out, Command) + assert out == {"Method": "Flow.Launcher.ShowApp", "Parameters": []} diff --git a/tests/unit/test_result.py b/tests/unit/test_result.py index 66ebb52..786c9a9 100644 --- a/tests/unit/test_result.py +++ b/tests/unit/test_result.py @@ -1,3 +1,4 @@ +from pyflowlauncher import api from pyflowlauncher.result import Result @@ -52,6 +53,16 @@ def test_asdict(): } +def test_from_json_round_trips_minimal_result(): + original = Result(title="Test") + assert Result.from_json(original.to_json()) == original + + +def test_from_json_handles_null_title_highlight_data(): + restored = Result.from_json({"Title": "Test", "TitleHighlightData": None}) + assert restored.title_highlight_data is None + + def test_add_action(): r = Result(title="Test") method = lambda: None @@ -69,3 +80,18 @@ def test_add_action_return(): method._is_registered_method = True r = Result(title="Test").add_action(method) assert isinstance(r, Result) + + +def test_add_action_accepts_command(): + r = Result(title="Test").add_action(api.change_query("g ")) + assert r.json_rpc_action == { + "Method": "Flow.Launcher.ChangeQuery", + "Parameters": ["g ", False], + "DontHideAfterAction": False, + } + + +def test_add_action_command_honors_dont_hide(): + r = Result(title="Test").add_action(api.copy_to_clipboard("x"), dont_hide_after_action=True) + assert r.json_rpc_action["DontHideAfterAction"] is True + assert r.json_rpc_action["Method"] == "Flow.Launcher.CopyToClipboard"