From fbe3d69ff0ecaf8b1ffd9b5dd071dfbba4c308c4 Mon Sep 17 00:00:00 2001 From: Garulf <535299+Garulf@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:17:09 +0000 Subject: [PATCH 1/4] Add handle_response to normalize @on_method return values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin methods can now yield/return Result, List[Result], or use generators without calling send_results() explicitly. Introduces types.py to break the result.py→plugin.py circular import, and response.py with handle_response() applied transparently in the on_method wrapper. Async generators are handled in EventHandler. --- pyflowlauncher/__init__.py | 2 + pyflowlauncher/event.py | 11 +++ pyflowlauncher/plugin.py | 7 +- pyflowlauncher/response.py | 33 +++++++++ pyflowlauncher/result.py | 2 +- pyflowlauncher/types.py | 3 + tests/test_response.py | 139 +++++++++++++++++++++++++++++++++++++ 7 files changed, 193 insertions(+), 4 deletions(-) create mode 100644 pyflowlauncher/response.py create mode 100644 pyflowlauncher/types.py create mode 100644 tests/test_response.py diff --git a/pyflowlauncher/__init__.py b/pyflowlauncher/__init__.py index 346b7ca..bff0204 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 .response import handle_response logger = logging.getLogger(__name__) @@ -13,4 +14,5 @@ "send_results", "Result", "Method", + "handle_response", ] diff --git a/pyflowlauncher/event.py b/pyflowlauncher/event.py index 48453d7..7e8a06a 100644 --- a/pyflowlauncher/event.py +++ b/pyflowlauncher/event.py @@ -1,6 +1,9 @@ import asyncio +import inspect from typing import Any, Callable, Iterable, Type, Union +from .result import Result, send_results + class EventNotFound(Exception): @@ -39,6 +42,14 @@ def get_event(self, event: str) -> Callable[..., Any]: async def _await_maybe(self, result: Any) -> Any: if asyncio.iscoroutine(result): return await result + if inspect.isasyncgen(result): + results = [] + async for item in result: + if isinstance(item, Result): + results.append(item) + elif isinstance(item, list): + results.extend(r for r in item if isinstance(r, Result)) + return send_results(results) return result async def trigger_exception_handler(self, exception: Exception) -> Any: diff --git a/pyflowlauncher/plugin.py b/pyflowlauncher/plugin.py index 09458e5..8ee2c4c 100644 --- a/pyflowlauncher/plugin.py +++ b/pyflowlauncher/plugin.py @@ -2,19 +2,20 @@ import sys from functools import cached_property, wraps -from typing import Any, Callable, Iterable, Optional, Type, List +from typing import Any, Iterable, Optional, Type, List from pathlib import Path import asyncio from .base import pyFlowLauncherObject from .event import EventHandler +from .response import handle_response from .jsonrpc import JsonRPCClient, JsonRPCRequest from .models.plugin_manifest import FILE_NAME from .manifest import Manifest -Method = Callable[..., Any] +from .types import Method class Plugin(pyFlowLauncherObject): @@ -38,7 +39,7 @@ def add_methods(self, methods: Iterable[Method]) -> None: def on_method(self, method: Method) -> Method: @wraps(method) def wrapper(*args, **kwargs): - return method(*args, **kwargs) + return handle_response(method(*args, **kwargs)) self.add_method(wrapper) return wrapper diff --git a/pyflowlauncher/response.py b/pyflowlauncher/response.py new file mode 100644 index 0000000..3287196 --- /dev/null +++ b/pyflowlauncher/response.py @@ -0,0 +1,33 @@ +import inspect +from typing import Any, Generator, Union + +from .result import Result, send_results +from .models.json_rpc import JsonRPCRequest, JsonRPCResponse + + +def handle_response(result: Any) -> Union[JsonRPCResponse, JsonRPCRequest, None]: + """Normalize a method's return value into a JSON-RPC response. + + Accepts: Result, list of Result, generator of Result/list, JsonRPCRequest, + JsonRPCResponse, coroutine, async generator, or None. + Coroutines and async generators are returned as-is for the event loop to handle. + """ + if result is None: + return None + if isinstance(result, Result): + return send_results([result]) + if isinstance(result, list): + return send_results(result) + if inspect.isgenerator(result): + return _collect_generator(result) + return result + + +def _collect_generator(gen: Generator) -> JsonRPCResponse: + results = [] + for item in gen: + if isinstance(item, Result): + results.append(item) + elif isinstance(item, list): + results.extend(r for r in item if isinstance(r, Result)) + return send_results(results) diff --git a/pyflowlauncher/result.py b/pyflowlauncher/result.py index cd34800..e664b0b 100644 --- a/pyflowlauncher/result.py +++ b/pyflowlauncher/result.py @@ -4,7 +4,7 @@ from pathlib import Path from typing import Any, Callable, Dict, Iterable, List, Optional, Union, cast -from .plugin import Method +from .types import Method from .models.result import Glyph, PreviewInfo from .models.json_rpc import JsonRPCResult, JsonRPCRequest, JsonRPCResponse diff --git a/pyflowlauncher/types.py b/pyflowlauncher/types.py new file mode 100644 index 0000000..4975965 --- /dev/null +++ b/pyflowlauncher/types.py @@ -0,0 +1,3 @@ +from typing import Any, Callable + +Method = Callable[..., Any] diff --git a/tests/test_response.py b/tests/test_response.py new file mode 100644 index 0000000..a701be0 --- /dev/null +++ b/tests/test_response.py @@ -0,0 +1,139 @@ +import pytest +from pyflowlauncher import Plugin, Result, handle_response +from pyflowlauncher.result import send_results + + +# --- Unit tests for handle_response --- + +def test_none(): + assert handle_response(None) is None + + +def test_single_result(): + r = Result(title="hello") + assert handle_response(r) == send_results([r]) + + +def test_list_of_results(): + results = [Result(title="a"), Result(title="b")] + assert handle_response(results) == send_results(results) + + +def test_generator_single_result(): + def gen(): + yield Result(title="a") + + assert handle_response(gen()) == send_results([Result(title="a")]) + + +def test_generator_multiple_results(): + def gen(): + yield Result(title="a") + yield Result(title="b") + + assert handle_response(gen()) == send_results([Result(title="a"), Result(title="b")]) + + +def test_generator_yields_list(): + def gen(): + yield [Result(title="a"), Result(title="b")] + + assert handle_response(gen()) == send_results([Result(title="a"), Result(title="b")]) + + +def test_generator_mixed_yields(): + def gen(): + yield Result(title="a") + yield [Result(title="b"), Result(title="c")] + + assert handle_response(gen()) == send_results([ + Result(title="a"), Result(title="b"), Result(title="c") + ]) + + +def test_jsonrpc_request_passthrough(): + req = {"Method": "Flow.Launcher.HideApp", "Parameters": []} + assert handle_response(req) is req + + +def test_jsonrpc_response_passthrough(): + resp = send_results([Result(title="x")]) + assert handle_response(resp) is resp + + +# --- Integration: @plugin.on_method with generator --- + +def test_on_method_generator_integration(): + plugin = Plugin() + + @plugin.on_method + def query(q: str): + yield Result(title="foo") + yield Result(title="bar") + + import asyncio + result = asyncio.run(plugin._event_handler.trigger_event("query", "test")) + assert result == send_results([Result(title="foo"), Result(title="bar")]) + + +def test_on_method_single_result_integration(): + plugin = Plugin() + + @plugin.on_method + def query(q: str): + return Result(title="only") + + import asyncio + result = asyncio.run(plugin._event_handler.trigger_event("query", "test")) + assert result == send_results([Result(title="only")]) + + +def test_on_method_list_integration(): + plugin = Plugin() + + @plugin.on_method + def query(q: str): + return [Result(title="a"), Result(title="b")] + + import asyncio + result = asyncio.run(plugin._event_handler.trigger_event("query", "test")) + assert result == send_results([Result(title="a"), Result(title="b")]) + + +# --- Async generator support --- + +@pytest.mark.asyncio +async def test_async_generator_single(): + plugin = Plugin() + + @plugin.on_method + async def query(q: str): + yield Result(title="async-foo") + + result = await plugin._event_handler.trigger_event("query", "test") + assert result == send_results([Result(title="async-foo")]) + + +@pytest.mark.asyncio +async def test_async_generator_multiple(): + plugin = Plugin() + + @plugin.on_method + async def query(q: str): + yield Result(title="a") + yield Result(title="b") + + result = await plugin._event_handler.trigger_event("query", "test") + assert result == send_results([Result(title="a"), Result(title="b")]) + + +@pytest.mark.asyncio +async def test_async_generator_yields_list(): + plugin = Plugin() + + @plugin.on_method + async def query(q: str): + yield [Result(title="x"), Result(title="y")] + + result = await plugin._event_handler.trigger_event("query", "test") + assert result == send_results([Result(title="x"), Result(title="y")]) From 05391615d083184e3dbb4fb411882a7f5732ec6d Mon Sep 17 00:00:00 2001 From: Garulf <535299+Garulf@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:21:11 +0000 Subject: [PATCH 2/4] Fix missing Callable import in plugin.py after Method alias moved to types.py --- pyflowlauncher/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyflowlauncher/plugin.py b/pyflowlauncher/plugin.py index 8ee2c4c..3e75b3c 100644 --- a/pyflowlauncher/plugin.py +++ b/pyflowlauncher/plugin.py @@ -2,7 +2,7 @@ import sys from functools import cached_property, wraps -from typing import Any, Iterable, Optional, Type, List +from typing import Any, Callable, Iterable, Optional, Type, List from pathlib import Path import asyncio From 29e62d2dba51fabcdef029736b3ea4c4f2fd6ad3 Mon Sep 17 00:00:00 2001 From: Garulf <535299+Garulf@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:46:22 +0000 Subject: [PATCH 3/4] Update docs and examples to use yield-based result pattern --- README.md | 10 +++--- .../examples/guide/plugin_methods/example1.py | 8 ++--- .../examples/guide/plugin_methods/example2.py | 7 ++--- .../guide/scoring_results/example1.py | 15 +++++---- docs/guide/Plugin methods.md | 31 +++++++++++++++++-- 5 files changed, 47 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 656a07c..f7cfb26 100644 --- a/README.md +++ b/README.md @@ -28,25 +28,25 @@ python -m pip install pyflowlauncher[all] A basic plugin using a function as the query method. ```py -from pyflowlauncher import Plugin, Result, send_results -from .models.json_rpc import JsonRPCResponse +from pyflowlauncher import Plugin, Result plugin = Plugin() @plugin.on_method -def query(query: str) -> JsonRPCResponse: - r = Result( +def query(query: str): + yield Result( title="This is a title!", subtitle="This is the subtitle!", icon="icon.png" ) - return send_results([r]) plugin.run() ``` +Methods decorated with `@plugin.on_method` can `yield` one or more `Result` objects, return a list of `Result` objects, or return a single `Result` — the framework normalizes all forms into the correct response automatically. + ### Advanced plugin A more advanced usage using a `Method` class as the query method. diff --git a/docs/examples/guide/plugin_methods/example1.py b/docs/examples/guide/plugin_methods/example1.py index 5328afc..5eb3a2d 100644 --- a/docs/examples/guide/plugin_methods/example1.py +++ b/docs/examples/guide/plugin_methods/example1.py @@ -1,17 +1,15 @@ -from pyflowlauncher import Plugin, Result, send_results -from pyflowlauncher.models.json_rpc import JsonRPCResponse +from pyflowlauncher import Plugin, Result plugin = Plugin() @plugin.on_method -def query(query: str) -> JsonRPCResponse: - r = Result( +def query(query: str): + yield Result( title="This is a title!", subtitle="This is the subtitle!", json_rpc_action={"Method": "action", "Parameters": []} ) - return send_results([r]) @plugin.on_method diff --git a/docs/examples/guide/plugin_methods/example2.py b/docs/examples/guide/plugin_methods/example2.py index 6e7dcb1..90758dc 100644 --- a/docs/examples/guide/plugin_methods/example2.py +++ b/docs/examples/guide/plugin_methods/example2.py @@ -1,17 +1,16 @@ -from pyflowlauncher import Plugin, Result, send_results -from pyflowlauncher.models.json_rpc import JsonRPCResponse +from pyflowlauncher import Plugin, Result plugin = Plugin() @plugin.on_method -def query(query: str) -> JsonRPCResponse: +def query(query: str): r = Result( title="This is a title!", subtitle="This is the subtitle!", ) r.add_action(action, ["stuff"]) - return send_results([r]) + yield r def action(params: list[str]): diff --git a/docs/examples/guide/scoring_results/example1.py b/docs/examples/guide/scoring_results/example1.py index 2b1255c..8f072ca 100644 --- a/docs/examples/guide/scoring_results/example1.py +++ b/docs/examples/guide/scoring_results/example1.py @@ -1,20 +1,19 @@ -from pyflowlauncher import Plugin, Result, send_results -from pyflowlauncher.models.json_rpc import JsonRPCResponse +from pyflowlauncher import Plugin, Result from pyflowlauncher.utils import score_results plugin = Plugin() @plugin.on_method -def query(query: str) -> JsonRPCResponse: - results = [] - for _ in range(100): - r = Result( +def query(query: str): + results = [ + Result( title="This is a title!", subtitle="This is the subtitle!", ) - results.append(r) - return send_results(score_results(query, results)) + for _ in range(100) + ] + yield from score_results(query, results) plugin.run() diff --git a/docs/guide/Plugin methods.md b/docs/guide/Plugin methods.md index 009f231..68ced1a 100644 --- a/docs/guide/Plugin methods.md +++ b/docs/guide/Plugin methods.md @@ -2,7 +2,34 @@ Flow Launcher can call custom methods created by your plugin as well. To do so simply register the method with your plugin. -You can register any Function by using the `on_method` decorator or by using the `add_method` method from `Plugin`. +You can register any function by using the `on_method` decorator or by using the `add_method` method from `Plugin`. + +## Returning results + +Methods decorated with `@plugin.on_method` support several return styles — the framework normalizes all of them automatically: + +```py +# yield a single result +@plugin.on_method +def query(query: str): + yield Result(title="Hello!") + +# yield multiple results +@plugin.on_method +def query(query: str): + for item in data: + yield Result(title=item) + +# return a list of results +@plugin.on_method +def query(query: str): + return [Result(title="a"), Result(title="b")] + +# return a single result +@plugin.on_method +def query(query: str): + return Result(title="Hello!") +``` ## Example 1 @@ -10,7 +37,7 @@ You can register any Function by using the `on_method` decorator or by using the --8<-- "docs/examples/guide/plugin_methods/example1.py" ``` -Alternativley you can register and add the Method to a Result in one line by using `action` method. +Alternatively you can register and add the Method to a Result in one line by using the `action` method. ## Example 2 From 57a9fb482afa4bb9a3554f598fb327ec46366024 Mon Sep 17 00:00:00 2001 From: Garulf <535299+Garulf@users.noreply.github.com> Date: Fri, 26 Jun 2026 04:35:47 +0000 Subject: [PATCH 4/4] Refactor handle_response and event handling to share _collect_item logic - Extract _collect_item helper to deduplicate Result/list collection in both sync generators and async generators - Fix _await_maybe to handle async functions returning Result or list by routing through handle_response-equivalent logic - Move handle_response wrapping into add_method so on_method returns the original unwrapped function (preserving _is_registered_method on it) - Filter non-Result items from list branches consistently - Update tests to check event registration by key presence and add regression tests for async return, flag preservation, and list filtering --- pyflowlauncher/event.py | 12 ++++--- pyflowlauncher/plugin.py | 16 +++++---- pyflowlauncher/response.py | 15 ++++++--- tests/test_plugin.py | 5 +-- tests/test_response.py | 66 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 95 insertions(+), 19 deletions(-) diff --git a/pyflowlauncher/event.py b/pyflowlauncher/event.py index 7e8a06a..3446011 100644 --- a/pyflowlauncher/event.py +++ b/pyflowlauncher/event.py @@ -3,6 +3,7 @@ from typing import Any, Callable, Iterable, Type, Union from .result import Result, send_results +from .response import _collect_item class EventNotFound(Exception): @@ -41,15 +42,16 @@ def get_event(self, event: str) -> Callable[..., Any]: async def _await_maybe(self, result: Any) -> Any: if asyncio.iscoroutine(result): - return await result + return await self._await_maybe(await result) if inspect.isasyncgen(result): results = [] async for item in result: - if isinstance(item, Result): - results.append(item) - elif isinstance(item, list): - results.extend(r for r in item if isinstance(r, Result)) + results.extend(_collect_item(item)) return send_results(results) + if isinstance(result, Result): + return send_results([result]) + if isinstance(result, list): + return send_results([r for r in result if isinstance(r, Result)]) return result async def trigger_exception_handler(self, exception: Exception) -> Any: diff --git a/pyflowlauncher/plugin.py b/pyflowlauncher/plugin.py index 3e75b3c..0e2b16e 100644 --- a/pyflowlauncher/plugin.py +++ b/pyflowlauncher/plugin.py @@ -30,18 +30,20 @@ def __init__(self, methods: list[Method] | None = None) -> None: def add_method(self, method: Method) -> str: """Add a method to the event handler.""" + @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(method) + return self._event_handler.add_event(wrapper) def add_methods(self, methods: Iterable[Method]) -> None: - self._event_handler.add_events(methods) + for method in methods: + self.add_method(method) def on_method(self, method: Method) -> Method: - @wraps(method) - def wrapper(*args, **kwargs): - return handle_response(method(*args, **kwargs)) - self.add_method(wrapper) - return wrapper + self.add_method(method) + return method def method(self, method: Method) -> Method: """Register a method to be called when the plugin is run.""" diff --git a/pyflowlauncher/response.py b/pyflowlauncher/response.py index 3287196..8625037 100644 --- a/pyflowlauncher/response.py +++ b/pyflowlauncher/response.py @@ -17,17 +17,22 @@ def handle_response(result: Any) -> Union[JsonRPCResponse, JsonRPCRequest, None] if isinstance(result, Result): return send_results([result]) if isinstance(result, list): - return send_results(result) + return send_results([r for r in result if isinstance(r, Result)]) if inspect.isgenerator(result): return _collect_generator(result) return result +def _collect_item(item: Any) -> list[Result]: + if isinstance(item, Result): + return [item] + if isinstance(item, list): + return [r for r in item if isinstance(r, Result)] + return [] + + def _collect_generator(gen: Generator) -> JsonRPCResponse: results = [] for item in gen: - if isinstance(item, Result): - results.append(item) - elif isinstance(item, list): - results.extend(r for r in item if isinstance(r, Result)) + results.extend(_collect_item(item)) return send_results(results) diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 7f0ec1b..4cc8d37 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -17,13 +17,14 @@ def query(query: str): def test_add_method(): plugin = Plugin() plugin.add_method(temp_method1) - assert plugin._event_handler._events == {'temp_method1': temp_method1} + assert 'temp_method1' in plugin._event_handler._events def test_add_methods(): plugin = Plugin() plugin.add_methods([temp_method1, temp_method2]) - assert plugin._event_handler._events == {'temp_method1': temp_method1, 'temp_method2': temp_method2} + assert 'temp_method1' in plugin._event_handler._events + assert 'temp_method2' in plugin._event_handler._events def test_settings(): diff --git a/tests/test_response.py b/tests/test_response.py index a701be0..be7024d 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -137,3 +137,69 @@ async def query(q: str): result = await plugin._event_handler.trigger_event("query", "test") assert result == send_results([Result(title="x"), Result(title="y")]) + + +# --- Regression: _is_registered_method on on_method return value --- + +def test_on_method_result_has_registered_flag(): + plugin = Plugin() + + @plugin.on_method + def query(q: str): + return Result(title="x") + + assert getattr(query, '_is_registered_method', False) is True + + +def test_add_method_result_has_registered_flag(): + plugin = Plugin() + + def query(q: str): + return Result(title="x") + + plugin.add_method(query) + assert getattr(query, '_is_registered_method', False) is True + + +def test_on_method_add_action_does_not_raise(): + plugin = Plugin() + + @plugin.on_method + def action_handler(): + pass + + r = Result(title="x") + r.add_action(action_handler) # should not raise MethodNotRegisteredError + + +# --- Regression: async def returning Result/list --- + +@pytest.mark.asyncio +async def test_async_return_single_result(): + plugin = Plugin() + + @plugin.on_method + async def query(q: str): + return Result(title="async-return") + + result = await plugin._event_handler.trigger_event("query", "test") + assert result == send_results([Result(title="async-return")]) + + +@pytest.mark.asyncio +async def test_async_return_list_of_results(): + plugin = Plugin() + + @plugin.on_method + async def query(q: str): + return [Result(title="a"), Result(title="b")] + + result = await plugin._event_handler.trigger_event("query", "test") + assert result == send_results([Result(title="a"), Result(title="b")]) + + +# --- Regression: list branch filters non-Result items --- + +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")])