From f3e05fff4ae4164b2e4ff682a54cd41dd0497a58 Mon Sep 17 00:00:00 2001 From: Garulf <535299+Garulf@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:24:52 +0000 Subject: [PATCH 01/10] feat: builders return inert Command values on Api --- pyflowlauncher/api.py | 152 +++++++++++++++++++------------------- pyflowlauncher/command.py | 11 +++ tests/unit/test_api.py | 30 +++++--- 3 files changed, 106 insertions(+), 87 deletions(-) create mode 100644 pyflowlauncher/command.py diff --git a/pyflowlauncher/api.py b/pyflowlauncher/api.py index 697aa10..d4caa93 100644 --- a/pyflowlauncher/api.py +++ b/pyflowlauncher/api.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING, Any, Callable, Coroutine, Optional +from .command import Command from .models.json_rpc import JsonRPCRequest if TYPE_CHECKING: @@ -10,104 +11,82 @@ NAME_SPACE = 'Flow.Launcher' -def _send_action(method: str, *parameters) -> JsonRPCRequest: - return {"Method": f"{NAME_SPACE}.{method}", "Parameters": list(parameters)} +def _send_action(method: str, *parameters) -> Command: + return Command({"Method": f"{NAME_SPACE}.{method}", "Parameters": list(parameters)}) -def change_query(query: str, requery: bool = False) -> JsonRPCRequest: - """Change the query in Flow Launcher.""" - return _send_action("ChangeQuery", query, requery) - - -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`` (added in a later task) 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) -> None: + self._fuzzy_search_fn = fuzzy_search_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 open_uri(self, uri: str) -> Command: + """Open a URI.""" + return _send_action("OpenAppUri", uri) - def __init__(self, fuzzy_search_fn: Optional[_FuzzySearchFn] = None) -> None: - self._fuzzy_search_fn = fuzzy_search_fn - - 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 +100,22 @@ 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) + + +# 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/tests/unit/test_api.py b/tests/unit/test_api.py index 50c6b70..7cdab57 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,13 @@ 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_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) From eefb86fac0218839654be46ce67dc4f52177bad5 Mon Sep 17 00:00:00 2001 From: Garulf <535299+Garulf@users.noreply.github.com> Date: Sat, 18 Jul 2026 23:55:44 +0000 Subject: [PATCH 02/10] feat: Result.add_action accepts a Command --- pyflowlauncher/result.py | 24 +++++++++++++++++++----- tests/unit/test_result.py | 16 ++++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/pyflowlauncher/result.py b/pyflowlauncher/result.py index 4f6c1fb..0b30d5b 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, } diff --git a/tests/unit/test_result.py b/tests/unit/test_result.py index 66ebb52..b823200 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 @@ -69,3 +70,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" From 8d24826dd6710def5cb8f674ca77c91e19915434 Mon Sep 17 00:00:00 2001 From: Garulf <535299+Garulf@users.noreply.github.com> Date: Sat, 18 Jul 2026 23:58:07 +0000 Subject: [PATCH 03/10] feat: yielded Command is emitted as the response action --- pyflowlauncher/event.py | 7 +++++++ pyflowlauncher/response.py | 11 +++++++++-- tests/unit/test_response.py | 32 +++++++++++++++++++++++++++++++- 3 files changed, 47 insertions(+), 3 deletions(-) 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/response.py b/pyflowlauncher/response.py index 560d0a1..80d3bcc 100644 --- a/pyflowlauncher/response.py +++ b/pyflowlauncher/response.py @@ -1,8 +1,9 @@ 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 @@ -33,8 +34,14 @@ def _collect_item(item: Any) -> list[Result]: return [] -def _collect_generator(gen: Generator) -> JsonRPCResponse: +def _collect_generator(gen: Generator) -> 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/tests/unit/test_response.py b/tests/unit/test_response.py index 767f69e..f770144 100644 --- a/tests/unit/test_response.py +++ b/tests/unit/test_response.py @@ -1,4 +1,9 @@ -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.response import handle_response from pyflowlauncher.result import send_results @@ -61,3 +66,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": []} From 40840728a68da9b136d4b365a88e431f7c05bb39 Mon Sep 17 00:00:00 2001 From: Garulf <535299+Garulf@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:00:59 +0000 Subject: [PATCH 04/10] feat: Api.invoke sends commands on V2, raises NotSupportedError on V1 --- pyflowlauncher/api.py | 25 ++++++++++++++++++++++++- pyflowlauncher/launcher.py | 16 ++++++++++++++-- tests/unit/test_launcher.py | 25 +++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/pyflowlauncher/api.py b/pyflowlauncher/api.py index d4caa93..01b20ab 100644 --- a/pyflowlauncher/api.py +++ b/pyflowlauncher/api.py @@ -11,6 +11,10 @@ NAME_SPACE = 'Flow.Launcher' +class NotSupportedError(Exception): + """Raised when an operation is not supported by the active protocol version.""" + + def _send_action(method: str, *parameters) -> Command: return Command({"Method": f"{NAME_SPACE}.{method}", "Parameters": list(parameters)}) @@ -25,8 +29,13 @@ class Api: ``invoke`` (added in a later task) are the launcher-bound calls. """ - def __init__(self, fuzzy_search_fn: Optional[_FuzzySearchFn] = None) -> None: + 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.""" @@ -101,6 +110,20 @@ async def fuzzy_search(self, query: str, text: str) -> "MatchData": ) 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. diff --git a/pyflowlauncher/launcher.py b/pyflowlauncher/launcher.py index e1930a0..36ad664 100644 --- a/pyflowlauncher/launcher.py +++ b/pyflowlauncher/launcher.py @@ -6,8 +6,9 @@ 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 @@ -19,7 +20,7 @@ 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 +28,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 +101,10 @@ 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(command['Method'], command['Parameters']) + async def run(self, dispatch: Callable[[str, list], Awaitable[Any]]) -> None: tasks: set = set() in_flight: Dict[Any, asyncio.Task] = {} diff --git a/tests/unit/test_launcher.py b/tests/unit/test_launcher.py index 29177ae..b4d4ddc 100644 --- a/tests/unit/test_launcher.py +++ b/tests/unit/test_launcher.py @@ -6,12 +6,37 @@ 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(): + 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 == [("Flow.Launcher.ChangeQuery", ["g ", False])] + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- From 6fe9dab393406871dc7a5fedc30352510a071ca7 Mon Sep 17 00:00:00 2001 From: Garulf <535299+Garulf@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:04:04 +0000 Subject: [PATCH 05/10] feat: export Command from package root --- pyflowlauncher/__init__.py | 2 ++ pyflowlauncher/api.py | 3 +-- pyflowlauncher/response.py | 2 +- tests/unit/test_api.py | 6 ++++++ tests/unit/test_response.py | 1 - 5 files changed, 10 insertions(+), 4 deletions(-) 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 01b20ab..4f7b28c 100644 --- a/pyflowlauncher/api.py +++ b/pyflowlauncher/api.py @@ -3,7 +3,6 @@ from typing import TYPE_CHECKING, Any, Callable, Coroutine, Optional from .command import Command -from .models.json_rpc import JsonRPCRequest if TYPE_CHECKING: from .string_matcher import MatchData @@ -26,7 +25,7 @@ class Api: """Flow Launcher API, accessible via ``plugin.launcher.api``. Builder methods return inert ``Command`` values; ``fuzzy_search`` and - ``invoke`` (added in a later task) are the launcher-bound calls. + ``invoke`` are the launcher-bound calls. """ def __init__( diff --git a/pyflowlauncher/response.py b/pyflowlauncher/response.py index 80d3bcc..445c504 100644 --- a/pyflowlauncher/response.py +++ b/pyflowlauncher/response.py @@ -8,7 +8,7 @@ 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, diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py index 7cdab57..401cc44 100644 --- a/tests/unit/test_api.py +++ b/tests/unit/test_api.py @@ -82,6 +82,12 @@ def test_fuzzy_search_without_backend_raises_clear_error(): asyncio.run(bare_api.fuzzy_search("query", "text")) +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 = [ diff --git a/tests/unit/test_response.py b/tests/unit/test_response.py index f770144..9bc6d95 100644 --- a/tests/unit/test_response.py +++ b/tests/unit/test_response.py @@ -3,7 +3,6 @@ from pyflowlauncher import Result, api, handle_response from pyflowlauncher.command import Command from pyflowlauncher.event import EventHandler -from pyflowlauncher.response import handle_response from pyflowlauncher.result import send_results From c30d37ca36653562b7473dc1afa722846eca75f6 Mon Sep 17 00:00:00 2001 From: Garulf <535299+Garulf@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:39:07 +0000 Subject: [PATCH 06/10] fix: serialize objects exposing to_json in JSON-RPC output --- pyflowlauncher/jsonrpc.py | 11 +++++++++-- tests/unit/test_jsonrpc.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) 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/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"}] From f40c1380d818b32c860733cb43da88ef14b9fd77 Mon Sep 17 00:00:00 2001 From: Garulf <535299+Garulf@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:39:07 +0000 Subject: [PATCH 07/10] feat: built-in context_menu rebuilds Results stored in context_data --- README.md | 39 +++++++++++++ .../examples/guide/plugin_methods/example3.py | 18 ++++++ pyflowlauncher/plugin.py | 28 ++++++++- pyflowlauncher/result.py | 3 +- tests/integration/test_v1_protocol.py | 13 +++++ tests/integration/test_v2_protocol.py | 42 ++++++++++++++ tests/unit/test_plugin.py | 58 +++++++++++++++++++ tests/unit/test_result.py | 10 ++++ 8 files changed, 207 insertions(+), 4 deletions(-) create mode 100644 docs/examples/guide/plugin_methods/example3.py 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/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/result.py b/pyflowlauncher/result.py index 0b30d5b..8bce56c 100644 --- a/pyflowlauncher/result.py +++ b/pyflowlauncher/result.py @@ -76,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'), @@ -88,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..8b3c29d 100644 --- a/tests/integration/test_v1_protocol.py +++ b/tests/integration/test_v1_protocol.py @@ -83,6 +83,19 @@ def query(q: str): assert response is None +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..878ee2b 100644 --- a/tests/integration/test_v2_protocol.py +++ b/tests/integration/test_v2_protocol.py @@ -499,6 +499,48 @@ async def _inner(): assert resp.get('result', {}).get('debugMessage') != 'Internal error' +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): 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_result.py b/tests/unit/test_result.py index b823200..786c9a9 100644 --- a/tests/unit/test_result.py +++ b/tests/unit/test_result.py @@ -53,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 From 4306df1617bbe86df36bc400232eec75b01cb245 Mon Sep 17 00:00:00 2001 From: Garulf <535299+Garulf@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:43:36 +0000 Subject: [PATCH 08/10] chore: satisfy mypy 2.x Generator parameter defaults in _collect_generator --- pyflowlauncher/response.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyflowlauncher/response.py b/pyflowlauncher/response.py index 445c504..127c225 100644 --- a/pyflowlauncher/response.py +++ b/pyflowlauncher/response.py @@ -34,7 +34,7 @@ def _collect_item(item: Any) -> list[Result]: return [] -def _collect_generator(gen: Generator) -> Union[JsonRPCResponse, Command]: +def _collect_generator(gen: Generator[Any, Any, Any]) -> Union[JsonRPCResponse, Command]: results = [] command: Optional[Command] = None for item in gen: From 94f3794037c52e636406ae4d6b845989f0ee0cb2 Mon Sep 17 00:00:00 2001 From: Garulf <535299+Garulf@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:43:36 +0000 Subject: [PATCH 09/10] fix: forward api commands returned from action methods on V2 An action wired with Result.add_action(method) that returns an api request (api.change_query(...) and friends) was answered with the bare JsonRPCExecuteResponse envelope on V2, silently dropping the command. V1 already worked because the returned request is written to stdout and executed by the host. Send the returned Command (or the raw request dict older releases produced) to the host as a plugin-initiated request, reusing the namespace stripping from the built-in action loopback. Fixes #41 --- pyflowlauncher/launcher.py | 34 +++++++++++++++---- tests/integration/test_v1_protocol.py | 17 ++++++++++ tests/integration/test_v2_protocol.py | 47 ++++++++++++++++++++++++++- 3 files changed, 91 insertions(+), 7 deletions(-) diff --git a/pyflowlauncher/launcher.py b/pyflowlauncher/launcher.py index 36ad664..679bf38 100644 --- a/pyflowlauncher/launcher.py +++ b/pyflowlauncher/launcher.py @@ -201,23 +201,45 @@ 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: + # The host registers JsonRPCPublicAPI under bare CLR method names + # (OpenAppUri, not Flow.Launcher.OpenAppUri), so strip the namespace. + prefix = f'{NAME_SPACE}.' + host_method = method[len(prefix):] if method.startswith(prefix) else method + try: + await self._client.request(host_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/tests/integration/test_v1_protocol.py b/tests/integration/test_v1_protocol.py index 8b3c29d..411b6cc 100644 --- a/tests/integration/test_v1_protocol.py +++ b/tests/integration/test_v1_protocol.py @@ -83,6 +83,23 @@ 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): diff --git a/tests/integration/test_v2_protocol.py b/tests/integration/test_v2_protocol.py index 878ee2b..9eb4e59 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,51 @@ 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): From c1df3c3c461f5a97c79b5e76761726cd0bda7324 Mon Sep 17 00:00:00 2001 From: Garulf <535299+Garulf@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:44:44 +0000 Subject: [PATCH 10/10] fix: strip the Flow.Launcher. namespace in Api.invoke The host registers its API under bare CLR method names, so invoke sent with the full namespace died with RemoteMethodNotFoundException. Share the stripping between invoke and the action forwarding path. --- pyflowlauncher/launcher.py | 16 ++++++---- tests/integration/test_v2_protocol.py | 42 +++++++++++++++++++++++++++ tests/unit/test_launcher.py | 4 ++- 3 files changed, 55 insertions(+), 7 deletions(-) diff --git a/pyflowlauncher/launcher.py b/pyflowlauncher/launcher.py index 679bf38..dcbf27b 100644 --- a/pyflowlauncher/launcher.py +++ b/pyflowlauncher/launcher.py @@ -15,6 +15,13 @@ 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: @@ -103,7 +110,8 @@ async def _fuzzy_search(self, query: str, text: str) -> MatchData: async def _invoke(self, command: Command) -> Any: """Send the command over JSON-RPC and return Flow Launcher's response.""" - return await self._client.request(command['Method'], command['Parameters']) + 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() @@ -229,12 +237,8 @@ async def _handle_builtin_action(self, request_id: Any, method: str, params: lis self._respond(request_id, {'hide': True}) async def _forward_to_host(self, method: str, params: list) -> None: - # The host registers JsonRPCPublicAPI under bare CLR method names - # (OpenAppUri, not Flow.Launcher.OpenAppUri), so strip the namespace. - prefix = f'{NAME_SPACE}.' - host_method = method[len(prefix):] if method.startswith(prefix) else method try: - await self._client.request(host_method, params) + await self._client.request(_host_method(method), params) except asyncio.CancelledError: raise except Exception: diff --git a/tests/integration/test_v2_protocol.py b/tests/integration/test_v2_protocol.py index 9eb4e59..c244961 100644 --- a/tests/integration/test_v2_protocol.py +++ b/tests/integration/test_v2_protocol.py @@ -635,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_launcher.py b/tests/unit/test_launcher.py index b4d4ddc..f94771c 100644 --- a/tests/unit/test_launcher.py +++ b/tests/unit/test_launcher.py @@ -21,6 +21,8 @@ def test_v1_invoke_raises_not_supported(): 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: @@ -34,7 +36,7 @@ async def request(self, method, params): launcher._client = FakeClient() result = asyncio.run(launcher.api.invoke(api.change_query("g "))) assert result == {"ok": True} - assert launcher._client.calls == [("Flow.Launcher.ChangeQuery", ["g ", False])] + assert launcher._client.calls == [("ChangeQuery", ["g ", False])] # ---------------------------------------------------------------------------