From 494830326d123abda58721d502459472d9159365 Mon Sep 17 00:00:00 2001 From: Qubasa Date: Tue, 8 Jul 2025 15:45:32 +0700 Subject: [PATCH 1/7] clan-app: Add plug and play middleware interface --- pkgs/clan-app/clan_app/api/file_gtk.py | 3 + pkgs/clan-app/clan_app/app.py | 36 +- .../clan-app/clan_app/deps/webview/webview.py | 316 ++++++------------ pkgs/clan-cli/clan_lib/api/tasks.py | 25 +- .../clan-cli/clan_lib/log_manager/__init__.py | 7 +- 5 files changed, 151 insertions(+), 236 deletions(-) diff --git a/pkgs/clan-app/clan_app/api/file_gtk.py b/pkgs/clan-app/clan_app/api/file_gtk.py index b7a9ea983..0bba3f4c2 100644 --- a/pkgs/clan-app/clan_app/api/file_gtk.py +++ b/pkgs/clan-app/clan_app/api/file_gtk.py @@ -4,6 +4,9 @@ from pathlib import Path from typing import Any import gi + +gi.require_version("Gtk", "4.0") + from clan_lib.api import ApiError, ErrorDataClass, SuccessDataClass from clan_lib.api.directory import FileRequest from gi.repository import Gio, GLib, Gtk diff --git a/pkgs/clan-app/clan_app/app.py b/pkgs/clan-app/clan_app/app.py index eef27a844..6c4ece18e 100644 --- a/pkgs/clan-app/clan_app/app.py +++ b/pkgs/clan-app/clan_app/app.py @@ -1,13 +1,9 @@ import logging - -from clan_cli.profiler import profile - -log = logging.getLogger(__name__) import os from dataclasses import dataclass from pathlib import Path -import clan_lib.machines.actions # noqa: F401 +from clan_cli.profiler import profile from clan_lib.api import API, load_in_all_api_functions, tasks from clan_lib.custom_logger import setup_logging from clan_lib.dirs import user_data_dir @@ -15,8 +11,15 @@ from clan_lib.log_manager import LogGroupConfig, LogManager from clan_lib.log_manager import api as log_manager_api from clan_app.api.file_gtk import open_file +from clan_app.deps.webview.middleware import ( + ArgumentParsingMiddleware, + LoggingMiddleware, + MethodExecutionMiddleware, +) from clan_app.deps.webview.webview import Size, SizeHint, Webview +log = logging.getLogger(__name__) + @dataclass class ClanAppOptions: @@ -39,9 +42,6 @@ def app_run(app_opts: ClanAppOptions) -> int: site_index: Path = Path(os.getenv("WEBUI_PATH", ".")).resolve() / "index.html" content_uri = f"file://{site_index}" - webview = Webview(debug=app_opts.debug) - webview.title = "Clan App" - # Add a log group ["clans", , "machines", ] log_manager = LogManager(base_dir=user_data_dir() / "clan-app" / "logs") clan_log_group = LogGroupConfig("clans", "Clans").add_child( @@ -51,15 +51,23 @@ def app_run(app_opts: ClanAppOptions) -> int: # Init LogManager global in log_manager_api module log_manager_api.LOG_MANAGER_INSTANCE = log_manager + # Populate the API global with all functions + load_in_all_api_functions() + API.overwrite_fn(open_file) + + webview = Webview( + debug=app_opts.debug, title="Clan App", size=Size(1280, 1024, SizeHint.NONE) + ) + + # Add middleware to the webview + webview.add_middleware(ArgumentParsingMiddleware(api=API)) + webview.add_middleware(LoggingMiddleware(log_manager=log_manager)) + webview.add_middleware(MethodExecutionMiddleware(api=API)) + # Init BAKEND_THREADS global in tasks module tasks.BAKEND_THREADS = webview.threads - # Populate the API global with all functions - load_in_all_api_functions() - - API.overwrite_fn(open_file) - webview.bind_jsonschema_api(API, log_manager=log_manager_api.LOG_MANAGER_INSTANCE) - webview.size = Size(1280, 1024, SizeHint.NONE) + webview.bind_jsonschema_api(API, log_manager=log_manager) webview.navigate(content_uri) webview.run() return 0 diff --git a/pkgs/clan-app/clan_app/deps/webview/webview.py b/pkgs/clan-app/clan_app/deps/webview/webview.py index 2c6ad5be0..a121e54ad 100644 --- a/pkgs/clan-app/clan_app/deps/webview/webview.py +++ b/pkgs/clan-app/clan_app/deps/webview/webview.py @@ -1,27 +1,21 @@ -# ruff: noqa: TRY301 import functools -import io import json import logging -import threading from collections.abc import Callable +from dataclasses import dataclass, field from enum import IntEnum -from typing import Any +from typing import TYPE_CHECKING, Any -from clan_lib.api import ( - ApiError, - ErrorDataClass, - MethodRegistry, - dataclass_to_dict, - from_dict, -) +from clan_lib.api import MethodRegistry from clan_lib.api.tasks import WebThread -from clan_lib.async_run import AsyncContext, get_async_ctx, set_async_ctx -from clan_lib.custom_logger import setup_logging from clan_lib.log_manager import LogManager from ._webview_ffi import _encode_c_string, _webview_lib +if TYPE_CHECKING: + from .middleware import Middleware + from .webview_bridge import WebviewBridge + log = logging.getLogger(__name__) @@ -37,226 +31,120 @@ class FuncStatus(IntEnum): FAILURE = 1 +@dataclass(frozen=True) class Size: - def __init__(self, width: int, height: int, hint: SizeHint) -> None: - self.width = width - self.height = height - self.hint = hint + width: int + height: int + hint: SizeHint +@dataclass class Webview: - def __init__( - self, debug: bool = False, size: Size | None = None, window: int | None = None - ) -> None: - self._handle = _webview_lib.webview_create(int(debug), window) - self._callbacks: dict[str, Callable[..., Any]] = {} - self.threads: dict[str, WebThread] = {} + title: str + debug: bool = False + size: Size | None = None + window: int | None = None - if size: - self.size = size + # initialized later + _bridge: "WebviewBridge | None" = None + _handle: Any | None = None + _callbacks: dict[str, Callable[..., Any]] = field(default_factory=dict) + _middleware: list["Middleware"] = field(default_factory=list) + + def _create_handle(self) -> None: + # Initialize the webview handle + handle = _webview_lib.webview_create(int(self.debug), self.window) + callbacks: dict[str, Callable[..., Any]] = {} + + # Since we can't use object.__setattr__, we'll initialize differently + # by storing in __dict__ directly (this works for init=False fields) + self._handle = handle + self._callbacks = callbacks + + if self.title: + self.set_title(self.title) + + if self.size: + self.set_size(self.size) + + @property + def handle(self) -> Any: + """Get the webview handle, creating it if necessary.""" + if self._handle is None: + self._create_handle() + return self._handle + + @property + def bridge(self) -> "WebviewBridge": + """Get the bridge, creating it if necessary.""" + if self._bridge is None: + self.create_bridge() + assert self._bridge is not None, "Bridge should be created" + return self._bridge def api_wrapper( self, - log_manager: LogManager, - api: MethodRegistry, method_name: str, wrap_method: Callable[..., Any], op_key_bytes: bytes, request_data: bytes, arg: int, ) -> None: - op_key = op_key_bytes.decode() - args = json.loads(request_data.decode()) - log.debug(f"Calling {method_name}({json.dumps(args, indent=4)})") - header: dict[str, Any] - - try: - # Initialize dataclasses from the payload - reconciled_arguments = {} - if len(args) == 1: - request = args[0] - header = request.get("header", {}) - msg = f"Expected header to be a dict, got {type(header)}" - if not isinstance(header, dict): - raise TypeError(msg) - body = request.get("body", {}) - msg = f"Expected body to be a dict, got {type(body)}" - if not isinstance(body, dict): - raise TypeError(msg) - - for k, v in body.items(): - # Some functions expect to be called with dataclass instances - # But the js api returns dictionaries. - # Introspect the function and create the expected dataclass from dict dynamically - # Depending on the introspected argument_type - arg_class = api.get_method_argtype(method_name, k) - - # TODO: rename from_dict into something like construct_checked_value - # from_dict really takes Anything and returns an instance of the type/class - reconciled_arguments[k] = from_dict(arg_class, v) - elif len(args) > 1: - msg = ( - "Expected a single argument, got multiple arguments to api_wrapper" - ) - raise ValueError(msg) - - reconciled_arguments["op_key"] = op_key - except Exception as e: - log.exception(f"Error while parsing arguments for {method_name}") - result = ErrorDataClass( - op_key=op_key, - status="error", - errors=[ - ApiError( - message="An internal error occured", - description=str(e), - location=["bind_jsonschema_api", method_name], - ) - ], - ) - serialized = json.dumps( - dataclass_to_dict(result), indent=4, ensure_ascii=False - ) - self.return_(op_key, FuncStatus.SUCCESS, serialized) - return - - def thread_task(stop_event: threading.Event) -> None: - ctx: AsyncContext = get_async_ctx() - ctx.should_cancel = lambda: stop_event.is_set() - - try: - # If the API call has set log_group in metadata, - # create the log file under that group. - log_group: list[str] = header.get("logging", {}).get("group_path", None) - if log_group is not None: - if not isinstance(log_group, list): - msg = f"Expected log_group to be a list, got {type(log_group)}" - raise TypeError(msg) - log.warning( - f"Using log group {log_group} for {method_name} with op_key {op_key}" - ) - - log_file = log_manager.create_log_file( - wrap_method, op_key=op_key, group_path=log_group - ).get_file_path() - except Exception as e: - log.exception(f"Error while handling request header of {method_name}") - result = ErrorDataClass( - op_key=op_key, - status="error", - errors=[ - ApiError( - message="An internal error occured", - description=str(e), - location=["header_middleware", method_name], - ) - ], - ) - serialized = json.dumps( - dataclass_to_dict(result), indent=4, ensure_ascii=False - ) - self.return_(op_key, FuncStatus.SUCCESS, serialized) - - with log_file.open("ab") as log_f: - # Redirect all cmd.run logs to this file. - ctx.stderr = log_f - ctx.stdout = log_f - set_async_ctx(ctx) - - # Add a new handler to the root logger that writes to log_f - handler_stream = io.TextIOWrapper( - log_f, encoding="utf-8", write_through=True, line_buffering=True - ) - handler = setup_logging( - log.getEffectiveLevel(), log_file=handler_stream - ) - - try: - # Original logic: call the wrapped API method. - result = wrap_method(**reconciled_arguments) - wrapped_result = {"body": dataclass_to_dict(result), "header": {}} - - # Serialize the result to JSON. - serialized = json.dumps( - dataclass_to_dict(wrapped_result), indent=4, ensure_ascii=False - ) - - # This log message will now also be written to log_f - # through the thread_log_handler. - log.debug(f"Result for {method_name}: {serialized}") - - # Return the successful result. - self.return_(op_key, FuncStatus.SUCCESS, serialized) - except Exception as e: - log.exception(f"Error while handling result of {method_name}") - result = ErrorDataClass( - op_key=op_key, - status="error", - errors=[ - ApiError( - message="An internal error occured", - description=str(e), - location=["bind_jsonschema_api", method_name], - ) - ], - ) - serialized = json.dumps( - dataclass_to_dict(result), indent=4, ensure_ascii=False - ) - self.return_(op_key, FuncStatus.SUCCESS, serialized) - finally: - # Crucial cleanup: remove the handler from the root logger. - # This stops redirecting logs for this thread to log_f and prevents - # the handler from being used after log_f is closed. - handler.root_logger.removeHandler(handler.new_handler) - # Close the handler. For a StreamHandler using a stream it doesn't - # own (log_f is managed by the 'with' statement), this typically - # flushes the stream. - handler.new_handler.close() - del self.threads[op_key] - - stop_event = threading.Event() - thread = threading.Thread( - target=thread_task, args=(stop_event,), name="WebviewThread" + """Legacy API wrapper - delegates to the bridge.""" + self.bridge.handle_webview_call( + method_name=method_name, + op_key_bytes=op_key_bytes, + request_data=request_data, + arg=arg, ) - thread.start() - self.threads[op_key] = WebThread(thread=thread, stop_event=stop_event) - - def __enter__(self) -> "Webview": - return self @property - def size(self) -> Size: - return self._size + def threads(self) -> dict[str, WebThread]: + """Access threads from the bridge for compatibility.""" + return self.bridge.threads - @size.setter - def size(self, value: Size) -> None: + def add_middleware(self, middleware: "Middleware") -> None: + """Add middleware to the middleware chain.""" + if self._bridge is not None: + msg = "Cannot add middleware after bridge creation." + raise RuntimeError(msg) + + self._middleware.append(middleware) + + def create_bridge(self) -> "WebviewBridge": + """Create and initialize the WebviewBridge with current middleware.""" + from .webview_bridge import WebviewBridge + + bridge = WebviewBridge(webview=self, middleware_chain=tuple(self._middleware)) + self._bridge = bridge + return bridge + + # Legacy methods for compatibility + def set_size(self, value: Size) -> None: + """Set the webview size (legacy compatibility).""" _webview_lib.webview_set_size( - self._handle, value.width, value.height, value.hint + self.handle, value.width, value.height, value.hint ) - self._size = value - @property - def title(self) -> str: - return self._title - - @title.setter - def title(self, value: str) -> None: - _webview_lib.webview_set_title(self._handle, _encode_c_string(value)) - self._title = value + def set_title(self, value: str) -> None: + """Set the webview title (legacy compatibility).""" + _webview_lib.webview_set_title(self.handle, _encode_c_string(value)) def destroy(self) -> None: + """Destroy the webview.""" for name in list(self._callbacks.keys()): self.unbind(name) - _webview_lib.webview_terminate(self._handle) - _webview_lib.webview_destroy(self._handle) - self._handle = None + _webview_lib.webview_terminate(self.handle) + _webview_lib.webview_destroy(self.handle) + # Can't set _handle to None on frozen dataclass def navigate(self, url: str) -> None: - _webview_lib.webview_navigate(self._handle, _encode_c_string(url)) + """Navigate to a URL.""" + _webview_lib.webview_navigate(self.handle, _encode_c_string(url)) def run(self) -> None: - _webview_lib.webview_run(self._handle) + """Run the webview.""" + _webview_lib.webview_run(self.handle) log.info("Shutting down webview...") self.destroy() @@ -264,8 +152,6 @@ class Webview: for name, method in api.functions.items(): wrapper = functools.partial( self.api_wrapper, - log_manager, - api, name, method, ) @@ -277,7 +163,7 @@ class Webview: self._callbacks[name] = c_callback _webview_lib.webview_bind( - self._handle, _encode_c_string(name), c_callback, None + self.handle, _encode_c_string(name), c_callback, None ) def bind(self, name: str, callback: Callable[..., Any]) -> None: @@ -293,29 +179,23 @@ class Webview: c_callback = _webview_lib.binding_callback_t(wrapper) self._callbacks[name] = c_callback - _webview_lib.webview_bind( - self._handle, _encode_c_string(name), c_callback, None - ) + _webview_lib.webview_bind(self.handle, _encode_c_string(name), c_callback, None) def unbind(self, name: str) -> None: if name in self._callbacks: - _webview_lib.webview_unbind(self._handle, _encode_c_string(name)) + _webview_lib.webview_unbind(self.handle, _encode_c_string(name)) del self._callbacks[name] def return_(self, seq: str, status: int, result: str) -> None: _webview_lib.webview_return( - self._handle, _encode_c_string(seq), status, _encode_c_string(result) + self.handle, _encode_c_string(seq), status, _encode_c_string(result) ) def eval(self, source: str) -> None: - _webview_lib.webview_eval(self._handle, _encode_c_string(source)) - - def init(self, source: str) -> None: - _webview_lib.webview_init(self._handle, _encode_c_string(source)) + _webview_lib.webview_eval(self.handle, _encode_c_string(source)) if __name__ == "__main__": - wv = Webview() - wv.title = "Hello, World!" + wv = Webview(title="Hello, World!") wv.navigate("https://www.google.com") wv.run() diff --git a/pkgs/clan-cli/clan_lib/api/tasks.py b/pkgs/clan-cli/clan_lib/api/tasks.py index 7ce48f049..3d0258cb1 100644 --- a/pkgs/clan-cli/clan_lib/api/tasks.py +++ b/pkgs/clan-cli/clan_lib/api/tasks.py @@ -1,8 +1,10 @@ import logging import threading +import time from dataclasses import dataclass from clan_lib.api import API +from clan_lib.async_run import get_async_ctx, is_async_cancelled log = logging.getLogger(__name__) @@ -16,11 +18,12 @@ class WebThread: BAKEND_THREADS: dict[str, WebThread] | None = None -@API.register_abstract -def delete_task(task_id: str) -> None: +@API.register +def cancel_task(task_id: str) -> None: """Cancel a task by its op_key.""" assert BAKEND_THREADS is not None, "Backend threads not initialized" future = BAKEND_THREADS.get(task_id) + log.debug(f"Thread ID: {threading.get_ident()}") if future: future.stop_event.set() log.debug(f"Task with id {task_id} has been cancelled.") @@ -29,6 +32,24 @@ def delete_task(task_id: str) -> None: raise ValueError(msg) +@API.register +def long_blocking_task(somearg: str) -> str: + """A long blocking task that simulates a long-running operation.""" + time.sleep(1) + ctx = get_async_ctx() + log.debug(f"Thread ID: {threading.get_ident()}") + + for i in range(30): + if is_async_cancelled(): + log.debug("Task was cancelled") + return "Task was cancelled" + log.debug( + f"Processing {i} for {somearg}. ctx.should_cancel={ctx.should_cancel()}" + ) + time.sleep(1) + return f"Task completed with argument: {somearg}" + + @API.register def list_tasks() -> list[str]: """List all tasks.""" diff --git a/pkgs/clan-cli/clan_lib/log_manager/__init__.py b/pkgs/clan-cli/clan_lib/log_manager/__init__.py index 3e2ab5d18..9aed55efa 100644 --- a/pkgs/clan-cli/clan_lib/log_manager/__init__.py +++ b/pkgs/clan-cli/clan_lib/log_manager/__init__.py @@ -301,7 +301,7 @@ class LogManager: return current_config def create_log_file( - self, func: Callable, op_key: str, group_path: list[str] | None = None + self, func: Callable | str, op_key: str, group_path: list[str] | None = None ) -> LogFile: """Create a new log file for the given function and operation. @@ -339,12 +339,15 @@ class LogManager: # Convert encoded path to string for LogFile group_str = "/".join(encoded_group_path) + # Use function name or string directly + func_name = func.__name__ if callable(func) else func + log_file = LogFile( op_key=op_key, date_day=now_utc.strftime("%Y-%m-%d"), group=group_str, date_second=now_utc.strftime("%H-%M-%S"), # Corrected original's %H-$M-%S - func_name=func.__name__, + func_name=func_name, _base_dir=self.base_dir, ) From 6d8fd42faaae8c18c4996820a350e7b6c7c68384 Mon Sep 17 00:00:00 2001 From: Qubasa Date: Tue, 8 Jul 2025 15:45:40 +0700 Subject: [PATCH 2/7] clan-app: Add plug and play middleware interface --- .../clan_app/deps/webview/api_bridge.py | 86 ++++++++ .../clan_app/deps/webview/middleware.py | 201 ++++++++++++++++++ .../clan_app/deps/webview/webview_bridge.py | 105 +++++++++ 3 files changed, 392 insertions(+) create mode 100644 pkgs/clan-app/clan_app/deps/webview/api_bridge.py create mode 100644 pkgs/clan-app/clan_app/deps/webview/middleware.py create mode 100644 pkgs/clan-app/clan_app/deps/webview/webview_bridge.py diff --git a/pkgs/clan-app/clan_app/deps/webview/api_bridge.py b/pkgs/clan-app/clan_app/deps/webview/api_bridge.py new file mode 100644 index 000000000..e9479edc7 --- /dev/null +++ b/pkgs/clan-app/clan_app/deps/webview/api_bridge.py @@ -0,0 +1,86 @@ +import logging +from abc import ABC, abstractmethod +from contextlib import ExitStack +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from .middleware import Middleware + +log = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class ApiRequest: + method_name: str + args: dict[str, Any] + header: dict[str, Any] + op_key: str + + +@dataclass(frozen=True) +class ApiResponse: + op_key: str + success: bool + data: Any + error: str | None = None + + +@dataclass +class ApiBridge(ABC): + """Generic interface for API bridges that can handle method calls from different sources.""" + + middleware_chain: tuple["Middleware", ...] + + @abstractmethod + def send_response(self, response: ApiResponse) -> None: + """Send response back to the client.""" + + def process_request(self, request: ApiRequest) -> None: + """Process an API request through the middleware chain.""" + from .middleware import MiddlewareContext + + with ExitStack() as stack: + context = MiddlewareContext( + request=request, + bridge=self, + exit_stack=stack, + ) + + # Process through middleware chain + for middleware in self.middleware_chain: + try: + log.debug( + f"{middleware.__class__.__name__} => {request.method_name}" + ) + middleware.process(context) + except Exception as e: + # If middleware fails, handle error + self.send_error_response( + request.op_key, str(e), ["middleware_error"] + ) + return + + def send_error_response( + self, op_key: str, error_message: str, location: list[str] + ) -> None: + """Send an error response.""" + from clan_lib.api import ApiError, ErrorDataClass + + error_data = ErrorDataClass( + op_key=op_key, + status="error", + errors=[ + ApiError( + message="An internal error occured", + description=error_message, + location=location, + ) + ], + ) + + response = ApiResponse( + op_key=op_key, success=False, data=error_data, error=error_message + ) + + self.send_response(response) diff --git a/pkgs/clan-app/clan_app/deps/webview/middleware.py b/pkgs/clan-app/clan_app/deps/webview/middleware.py new file mode 100644 index 000000000..3a5832a0a --- /dev/null +++ b/pkgs/clan-app/clan_app/deps/webview/middleware.py @@ -0,0 +1,201 @@ +import io +import json +import logging +from abc import ABC, abstractmethod +from contextlib import ExitStack +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, ContextManager + +from clan_lib.api import MethodRegistry, dataclass_to_dict, from_dict +from clan_lib.async_run import AsyncContext, get_async_ctx, set_async_ctx +from clan_lib.custom_logger import setup_logging +from clan_lib.log_manager import LogManager + +from .api_bridge import ApiRequest, ApiResponse + +if TYPE_CHECKING: + from .api_bridge import ApiBridge, ApiRequest + +log = logging.getLogger(__name__) + + +@dataclass +class MiddlewareContext: + request: "ApiRequest" + bridge: "ApiBridge" + exit_stack: ExitStack + + +@dataclass(frozen=True) +class Middleware(ABC): + """Abstract base class for middleware components.""" + + @abstractmethod + def process(self, context: MiddlewareContext) -> None: + """Process the request through this middleware.""" + + def register_context_manager( + self, context: MiddlewareContext, cm: ContextManager[Any] + ) -> Any: + """Register a context manager with the exit stack.""" + return context.exit_stack.enter_context(cm) + + +@dataclass(frozen=True) +class ArgumentParsingMiddleware(Middleware): + """Middleware that handles argument parsing and dataclass construction.""" + + api: MethodRegistry + + def process(self, context: MiddlewareContext) -> None: + try: + # Convert dictionary arguments to dataclass instances + reconciled_arguments = {} + for k, v in context.request.args.items(): + if k == "op_key": + continue + + # Get the expected argument type from the API + arg_class = self.api.get_method_argtype(context.request.method_name, k) + + # Convert dictionary to dataclass instance + reconciled_arguments[k] = from_dict(arg_class, v) + + # Add op_key to arguments + reconciled_arguments["op_key"] = context.request.op_key + + # Create a new request with reconciled arguments + + updated_request = ApiRequest( + method_name=context.request.method_name, + args=reconciled_arguments, + header=context.request.header, + op_key=context.request.op_key, + ) + context.request = updated_request + + except Exception as e: + log.exception( + f"Error while parsing arguments for {context.request.method_name}" + ) + context.bridge.send_error_response( + context.request.op_key, + str(e), + ["argument_parsing", context.request.method_name], + ) + raise + + +@dataclass(frozen=True) +class LoggingMiddleware(Middleware): + """Middleware that sets up logging context without executing methods.""" + + log_manager: LogManager + + def process(self, context: MiddlewareContext) -> None: + method = context.request.method_name + + try: + # Handle log group configuration + log_group: list[str] | None = context.request.header.get("logging", {}).get( + "group_path", None + ) + if log_group is not None: + if not isinstance(log_group, list): + msg = f"Expected log_group to be a list, got {type(log_group)}" + raise TypeError(msg) + log.warning( + f"Using log group {log_group} for {context.request.method_name} with op_key {context.request.op_key}" + ) + # Create log file + log_file = self.log_manager.create_log_file( + method, op_key=context.request.op_key, group_path=log_group + ).get_file_path() + + except Exception as e: + log.exception( + f"Error while handling request header of {context.request.method_name}" + ) + context.bridge.send_error_response( + context.request.op_key, + str(e), + ["header_middleware", context.request.method_name], + ) + return + + # Register logging context manager + class LoggingContextManager: + def __init__(self, log_file) -> None: + self.log_file = log_file + self.log_f = None + self.handler = None + self.original_ctx = None + + def __enter__(self): + self.log_f = self.log_file.open("ab") + self.original_ctx = get_async_ctx() + + # Set up async context for logging + ctx = AsyncContext(**self.original_ctx.__dict__) + ctx.stderr = self.log_f + ctx.stdout = self.log_f + set_async_ctx(ctx) + + # Set up logging handler + handler_stream = io.TextIOWrapper( + self.log_f, + encoding="utf-8", + write_through=True, + line_buffering=True, + ) + self.handler = setup_logging( + log.getEffectiveLevel(), log_file=handler_stream + ) + + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + if self.handler: + self.handler.root_logger.removeHandler(self.handler.new_handler) + self.handler.new_handler.close() + if self.log_f: + self.log_f.close() + if self.original_ctx: + set_async_ctx(self.original_ctx) + + # Register the logging context manager + self.register_context_manager(context, LoggingContextManager(log_file)) + + +@dataclass(frozen=True) +class MethodExecutionMiddleware(Middleware): + """Middleware that handles actual method execution.""" + + api: MethodRegistry + + def process(self, context: MiddlewareContext) -> None: + method = self.api.functions[context.request.method_name] + + try: + # Execute the actual method + result = method(**context.request.args) + wrapped_result = {"body": dataclass_to_dict(result), "header": {}} + + log.debug( + f"Result for {context.request.method_name}: {json.dumps(dataclass_to_dict(wrapped_result), indent=4)}" + ) + + response = ApiResponse( + op_key=context.request.op_key, success=True, data=wrapped_result + ) + context.bridge.send_response(response) + + except Exception as e: + log.exception( + f"Error while handling result of {context.request.method_name}" + ) + context.bridge.send_error_response( + context.request.op_key, + str(e), + ["method_execution", context.request.method_name], + ) diff --git a/pkgs/clan-app/clan_app/deps/webview/webview_bridge.py b/pkgs/clan-app/clan_app/deps/webview/webview_bridge.py new file mode 100644 index 000000000..8cef32a76 --- /dev/null +++ b/pkgs/clan-app/clan_app/deps/webview/webview_bridge.py @@ -0,0 +1,105 @@ +import json +import logging +import threading +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from clan_lib.api import dataclass_to_dict +from clan_lib.api.tasks import WebThread +from clan_lib.async_run import set_should_cancel + +from .api_bridge import ApiBridge, ApiRequest, ApiResponse +from .webview import FuncStatus + +if TYPE_CHECKING: + from .webview import Webview + +log = logging.getLogger(__name__) + + +@dataclass +class WebviewBridge(ApiBridge): + """Webview-specific implementation of the API bridge.""" + + webview: "Webview" + threads: dict[str, WebThread] = field(default_factory=dict) + + def send_response(self, response: ApiResponse) -> None: + """Send response back to the webview client.""" + + if response.success: + serialized = json.dumps( + dataclass_to_dict(response.data), indent=4, ensure_ascii=False + ) + status = FuncStatus.SUCCESS + else: + serialized = json.dumps( + dataclass_to_dict(response.data), indent=4, ensure_ascii=False + ) + status = FuncStatus.SUCCESS # Even errors are sent as SUCCESS to webview + + log.debug( + f"Sending response for op_key {response.op_key} with status {status.name} and data: {serialized}" + ) + self.webview.return_(response.op_key, status, serialized) + + def handle_webview_call( + self, + method_name: str, + op_key_bytes: bytes, + request_data: bytes, + arg: int, + ) -> None: + """Handle a call from webview's JavaScript bridge.""" + op_key = op_key_bytes.decode() + raw_args = json.loads(request_data.decode()) + + try: + # Parse the webview-specific request format + header = {} + args = {} + + if len(raw_args) == 1: + request = raw_args[0] + header = request.get("header", {}) + if not isinstance(header, dict): + msg = f"Expected header to be a dict, got {type(header)}" + raise TypeError(msg) + + body = request.get("body", {}) + if not isinstance(body, dict): + msg = f"Expected body to be a dict, got {type(body)}" + raise TypeError(msg) + + args = body + elif len(raw_args) > 1: + msg = "Expected a single argument, got multiple arguments" + raise ValueError(msg) + + # Create API request + api_request = ApiRequest( + method_name=method_name, args=args, header=header, op_key=op_key + ) + + except Exception as e: + self.send_error_response(op_key, str(e), ["webview_bridge", method_name]) + return + + # Process in a separate thread + def thread_task(stop_event: threading.Event) -> None: + set_should_cancel(lambda: stop_event.is_set()) + + try: + log.debug( + f"Calling {method_name}({json.dumps(api_request.args, indent=4)}) with header {json.dumps(api_request.header, indent=4)} and op_key {op_key}" + ) + self.process_request(api_request) + finally: + self.threads.pop(op_key, None) + + stop_event = threading.Event() + thread = threading.Thread( + target=thread_task, args=(stop_event,), name="WebviewThread" + ) + thread.start() + self.threads[op_key] = WebThread(thread=thread, stop_event=stop_event) From eb6166796c3fba51d7465512df9c8a396f856877 Mon Sep 17 00:00:00 2001 From: Qubasa Date: Tue, 8 Jul 2025 16:48:55 +0700 Subject: [PATCH 3/7] clan-app: Generalize architecture for API requests --- .../{deps/webview => api}/api_bridge.py | 21 +- .../clan_app/api/middleware/__init__.py | 14 ++ .../api/middleware/argument_parsing.py | 55 +++++ pkgs/clan-app/clan_app/api/middleware/base.py | 29 +++ .../clan_app/api/middleware/logging.py | 99 +++++++++ .../api/middleware/method_execution.py | 41 ++++ pkgs/clan-app/clan_app/app.py | 2 +- .../clan_app/deps/webview/middleware.py | 201 ------------------ .../clan-app/clan_app/deps/webview/webview.py | 3 +- .../clan_app/deps/webview/webview_bridge.py | 26 +-- pkgs/clan-cli/clan_lib/api/tasks.py | 1 - 11 files changed, 261 insertions(+), 231 deletions(-) rename pkgs/clan-app/clan_app/{deps/webview => api}/api_bridge.py (85%) create mode 100644 pkgs/clan-app/clan_app/api/middleware/__init__.py create mode 100644 pkgs/clan-app/clan_app/api/middleware/argument_parsing.py create mode 100644 pkgs/clan-app/clan_app/api/middleware/base.py create mode 100644 pkgs/clan-app/clan_app/api/middleware/logging.py create mode 100644 pkgs/clan-app/clan_app/api/middleware/method_execution.py delete mode 100644 pkgs/clan-app/clan_app/deps/webview/middleware.py diff --git a/pkgs/clan-app/clan_app/deps/webview/api_bridge.py b/pkgs/clan-app/clan_app/api/api_bridge.py similarity index 85% rename from pkgs/clan-app/clan_app/deps/webview/api_bridge.py rename to pkgs/clan-app/clan_app/api/api_bridge.py index e9479edc7..94e310e53 100644 --- a/pkgs/clan-app/clan_app/deps/webview/api_bridge.py +++ b/pkgs/clan-app/clan_app/api/api_bridge.py @@ -11,7 +11,7 @@ log = logging.getLogger(__name__) @dataclass(frozen=True) -class ApiRequest: +class BackendRequest: method_name: str args: dict[str, Any] header: dict[str, Any] @@ -19,11 +19,10 @@ class ApiRequest: @dataclass(frozen=True) -class ApiResponse: - op_key: str - success: bool - data: Any - error: str | None = None +class BackendResponse: + body: Any + header: dict[str, Any] + _op_key: str @dataclass @@ -33,10 +32,10 @@ class ApiBridge(ABC): middleware_chain: tuple["Middleware", ...] @abstractmethod - def send_response(self, response: ApiResponse) -> None: + def send_response(self, response: BackendResponse) -> None: """Send response back to the client.""" - def process_request(self, request: ApiRequest) -> None: + def process_request(self, request: BackendRequest) -> None: """Process an API request through the middleware chain.""" from .middleware import MiddlewareContext @@ -79,8 +78,10 @@ class ApiBridge(ABC): ], ) - response = ApiResponse( - op_key=op_key, success=False, data=error_data, error=error_message + response = BackendResponse( + body=error_data, + header={}, + _op_key=op_key, ) self.send_response(response) diff --git a/pkgs/clan-app/clan_app/api/middleware/__init__.py b/pkgs/clan-app/clan_app/api/middleware/__init__.py new file mode 100644 index 000000000..824c42ff1 --- /dev/null +++ b/pkgs/clan-app/clan_app/api/middleware/__init__.py @@ -0,0 +1,14 @@ +"""Middleware components for the webview API bridge.""" + +from .argument_parsing import ArgumentParsingMiddleware +from .base import Middleware, MiddlewareContext +from .logging import LoggingMiddleware +from .method_execution import MethodExecutionMiddleware + +__all__ = [ + "ArgumentParsingMiddleware", + "LoggingMiddleware", + "MethodExecutionMiddleware", + "Middleware", + "MiddlewareContext", +] diff --git a/pkgs/clan-app/clan_app/api/middleware/argument_parsing.py b/pkgs/clan-app/clan_app/api/middleware/argument_parsing.py new file mode 100644 index 000000000..b0eb5781c --- /dev/null +++ b/pkgs/clan-app/clan_app/api/middleware/argument_parsing.py @@ -0,0 +1,55 @@ +import logging +from dataclasses import dataclass + +from clan_lib.api import MethodRegistry, from_dict + +from clan_app.api.api_bridge import BackendRequest + +from .base import Middleware, MiddlewareContext + +log = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class ArgumentParsingMiddleware(Middleware): + """Middleware that handles argument parsing and dataclass construction.""" + + api: MethodRegistry + + def process(self, context: MiddlewareContext) -> None: + try: + # Convert dictionary arguments to dataclass instances + reconciled_arguments = {} + for k, v in context.request.args.items(): + if k == "op_key": + continue + + # Get the expected argument type from the API + arg_class = self.api.get_method_argtype(context.request.method_name, k) + + # Convert dictionary to dataclass instance + reconciled_arguments[k] = from_dict(arg_class, v) + + # Add op_key to arguments + reconciled_arguments["op_key"] = context.request.op_key + + # Create a new request with reconciled arguments + + updated_request = BackendRequest( + method_name=context.request.method_name, + args=reconciled_arguments, + header=context.request.header, + op_key=context.request.op_key, + ) + context.request = updated_request + + except Exception as e: + log.exception( + f"Error while parsing arguments for {context.request.method_name}" + ) + context.bridge.send_error_response( + context.request.op_key, + str(e), + ["argument_parsing", context.request.method_name], + ) + raise diff --git a/pkgs/clan-app/clan_app/api/middleware/base.py b/pkgs/clan-app/clan_app/api/middleware/base.py new file mode 100644 index 000000000..1db218f4b --- /dev/null +++ b/pkgs/clan-app/clan_app/api/middleware/base.py @@ -0,0 +1,29 @@ +from abc import ABC, abstractmethod +from contextlib import AbstractContextManager, ExitStack +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from clan_app.api.api_bridge import ApiBridge, BackendRequest + + +@dataclass +class MiddlewareContext: + request: "BackendRequest" + bridge: "ApiBridge" + exit_stack: ExitStack + + +@dataclass(frozen=True) +class Middleware(ABC): + """Abstract base class for middleware components.""" + + @abstractmethod + def process(self, context: MiddlewareContext) -> None: + """Process the request through this middleware.""" + + def register_context_manager( + self, context: MiddlewareContext, cm: AbstractContextManager[Any] + ) -> Any: + """Register a context manager with the exit stack.""" + return context.exit_stack.enter_context(cm) diff --git a/pkgs/clan-app/clan_app/api/middleware/logging.py b/pkgs/clan-app/clan_app/api/middleware/logging.py new file mode 100644 index 000000000..73a9dd999 --- /dev/null +++ b/pkgs/clan-app/clan_app/api/middleware/logging.py @@ -0,0 +1,99 @@ +import io +import logging +import types +from dataclasses import dataclass +from typing import Any + +from clan_lib.async_run import AsyncContext, get_async_ctx, set_async_ctx +from clan_lib.custom_logger import RegisteredHandler, setup_logging +from clan_lib.log_manager import LogManager + +from .base import Middleware, MiddlewareContext + +log = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class LoggingMiddleware(Middleware): + """Middleware that sets up logging context without executing methods.""" + + log_manager: LogManager + + def process(self, context: MiddlewareContext) -> None: + method = context.request.method_name + + try: + # Handle log group configuration + log_group: list[str] | None = context.request.header.get("logging", {}).get( + "group_path", None + ) + if log_group is not None: + if not isinstance(log_group, list): + msg = f"Expected log_group to be a list, got {type(log_group)}" + raise TypeError(msg) + log.warning( + f"Using log group {log_group} for {context.request.method_name} with op_key {context.request.op_key}" + ) + # Create log file + log_file = self.log_manager.create_log_file( + method, op_key=context.request.op_key, group_path=log_group + ).get_file_path() + + except Exception as e: + log.exception( + f"Error while handling request header of {context.request.method_name}" + ) + context.bridge.send_error_response( + context.request.op_key, + str(e), + ["header_middleware", context.request.method_name], + ) + return + + # Register logging context manager + class LoggingContextManager: + def __init__(self, log_file: Any) -> None: + self.log_file = log_file + self.log_f: Any = None + self.handler: RegisteredHandler | None = None + self.original_ctx: AsyncContext | None = None + + def __enter__(self) -> "LoggingContextManager": + self.log_f = self.log_file.open("ab") + self.original_ctx = get_async_ctx() + + # Set up async context for logging + ctx = AsyncContext(**self.original_ctx.__dict__) + ctx.stderr = self.log_f + ctx.stdout = self.log_f + set_async_ctx(ctx) + + # Set up logging handler + handler_stream = io.TextIOWrapper( + self.log_f, # type: ignore[arg-type] + encoding="utf-8", + write_through=True, + line_buffering=True, + ) + self.handler = setup_logging( + log.getEffectiveLevel(), log_file=handler_stream + ) + + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: types.TracebackType | None, + ) -> None: + if self.handler: + self.handler.root_logger.removeHandler(self.handler.new_handler) + self.handler.new_handler.close() + if self.log_f: + self.log_f.close() + if self.original_ctx: + set_async_ctx(self.original_ctx) + + # Register the logging context manager + self.register_context_manager(context, LoggingContextManager(log_file)) diff --git a/pkgs/clan-app/clan_app/api/middleware/method_execution.py b/pkgs/clan-app/clan_app/api/middleware/method_execution.py new file mode 100644 index 000000000..9f7b7b71c --- /dev/null +++ b/pkgs/clan-app/clan_app/api/middleware/method_execution.py @@ -0,0 +1,41 @@ +import logging +from dataclasses import dataclass + +from clan_lib.api import MethodRegistry + +from clan_app.api.api_bridge import BackendResponse + +from .base import Middleware, MiddlewareContext + +log = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class MethodExecutionMiddleware(Middleware): + """Middleware that handles actual method execution.""" + + api: MethodRegistry + + def process(self, context: MiddlewareContext) -> None: + method = self.api.functions[context.request.method_name] + + try: + # Execute the actual method + result = method(**context.request.args) + + response = BackendResponse( + body=result, + header={}, + _op_key=context.request.op_key, + ) + context.bridge.send_response(response) + + except Exception as e: + log.exception( + f"Error while handling result of {context.request.method_name}" + ) + context.bridge.send_error_response( + context.request.op_key, + str(e), + ["method_execution", context.request.method_name], + ) diff --git a/pkgs/clan-app/clan_app/app.py b/pkgs/clan-app/clan_app/app.py index 6c4ece18e..a9b1f4428 100644 --- a/pkgs/clan-app/clan_app/app.py +++ b/pkgs/clan-app/clan_app/app.py @@ -11,7 +11,7 @@ from clan_lib.log_manager import LogGroupConfig, LogManager from clan_lib.log_manager import api as log_manager_api from clan_app.api.file_gtk import open_file -from clan_app.deps.webview.middleware import ( +from clan_app.api.middleware import ( ArgumentParsingMiddleware, LoggingMiddleware, MethodExecutionMiddleware, diff --git a/pkgs/clan-app/clan_app/deps/webview/middleware.py b/pkgs/clan-app/clan_app/deps/webview/middleware.py deleted file mode 100644 index 3a5832a0a..000000000 --- a/pkgs/clan-app/clan_app/deps/webview/middleware.py +++ /dev/null @@ -1,201 +0,0 @@ -import io -import json -import logging -from abc import ABC, abstractmethod -from contextlib import ExitStack -from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, ContextManager - -from clan_lib.api import MethodRegistry, dataclass_to_dict, from_dict -from clan_lib.async_run import AsyncContext, get_async_ctx, set_async_ctx -from clan_lib.custom_logger import setup_logging -from clan_lib.log_manager import LogManager - -from .api_bridge import ApiRequest, ApiResponse - -if TYPE_CHECKING: - from .api_bridge import ApiBridge, ApiRequest - -log = logging.getLogger(__name__) - - -@dataclass -class MiddlewareContext: - request: "ApiRequest" - bridge: "ApiBridge" - exit_stack: ExitStack - - -@dataclass(frozen=True) -class Middleware(ABC): - """Abstract base class for middleware components.""" - - @abstractmethod - def process(self, context: MiddlewareContext) -> None: - """Process the request through this middleware.""" - - def register_context_manager( - self, context: MiddlewareContext, cm: ContextManager[Any] - ) -> Any: - """Register a context manager with the exit stack.""" - return context.exit_stack.enter_context(cm) - - -@dataclass(frozen=True) -class ArgumentParsingMiddleware(Middleware): - """Middleware that handles argument parsing and dataclass construction.""" - - api: MethodRegistry - - def process(self, context: MiddlewareContext) -> None: - try: - # Convert dictionary arguments to dataclass instances - reconciled_arguments = {} - for k, v in context.request.args.items(): - if k == "op_key": - continue - - # Get the expected argument type from the API - arg_class = self.api.get_method_argtype(context.request.method_name, k) - - # Convert dictionary to dataclass instance - reconciled_arguments[k] = from_dict(arg_class, v) - - # Add op_key to arguments - reconciled_arguments["op_key"] = context.request.op_key - - # Create a new request with reconciled arguments - - updated_request = ApiRequest( - method_name=context.request.method_name, - args=reconciled_arguments, - header=context.request.header, - op_key=context.request.op_key, - ) - context.request = updated_request - - except Exception as e: - log.exception( - f"Error while parsing arguments for {context.request.method_name}" - ) - context.bridge.send_error_response( - context.request.op_key, - str(e), - ["argument_parsing", context.request.method_name], - ) - raise - - -@dataclass(frozen=True) -class LoggingMiddleware(Middleware): - """Middleware that sets up logging context without executing methods.""" - - log_manager: LogManager - - def process(self, context: MiddlewareContext) -> None: - method = context.request.method_name - - try: - # Handle log group configuration - log_group: list[str] | None = context.request.header.get("logging", {}).get( - "group_path", None - ) - if log_group is not None: - if not isinstance(log_group, list): - msg = f"Expected log_group to be a list, got {type(log_group)}" - raise TypeError(msg) - log.warning( - f"Using log group {log_group} for {context.request.method_name} with op_key {context.request.op_key}" - ) - # Create log file - log_file = self.log_manager.create_log_file( - method, op_key=context.request.op_key, group_path=log_group - ).get_file_path() - - except Exception as e: - log.exception( - f"Error while handling request header of {context.request.method_name}" - ) - context.bridge.send_error_response( - context.request.op_key, - str(e), - ["header_middleware", context.request.method_name], - ) - return - - # Register logging context manager - class LoggingContextManager: - def __init__(self, log_file) -> None: - self.log_file = log_file - self.log_f = None - self.handler = None - self.original_ctx = None - - def __enter__(self): - self.log_f = self.log_file.open("ab") - self.original_ctx = get_async_ctx() - - # Set up async context for logging - ctx = AsyncContext(**self.original_ctx.__dict__) - ctx.stderr = self.log_f - ctx.stdout = self.log_f - set_async_ctx(ctx) - - # Set up logging handler - handler_stream = io.TextIOWrapper( - self.log_f, - encoding="utf-8", - write_through=True, - line_buffering=True, - ) - self.handler = setup_logging( - log.getEffectiveLevel(), log_file=handler_stream - ) - - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - if self.handler: - self.handler.root_logger.removeHandler(self.handler.new_handler) - self.handler.new_handler.close() - if self.log_f: - self.log_f.close() - if self.original_ctx: - set_async_ctx(self.original_ctx) - - # Register the logging context manager - self.register_context_manager(context, LoggingContextManager(log_file)) - - -@dataclass(frozen=True) -class MethodExecutionMiddleware(Middleware): - """Middleware that handles actual method execution.""" - - api: MethodRegistry - - def process(self, context: MiddlewareContext) -> None: - method = self.api.functions[context.request.method_name] - - try: - # Execute the actual method - result = method(**context.request.args) - wrapped_result = {"body": dataclass_to_dict(result), "header": {}} - - log.debug( - f"Result for {context.request.method_name}: {json.dumps(dataclass_to_dict(wrapped_result), indent=4)}" - ) - - response = ApiResponse( - op_key=context.request.op_key, success=True, data=wrapped_result - ) - context.bridge.send_response(response) - - except Exception as e: - log.exception( - f"Error while handling result of {context.request.method_name}" - ) - context.bridge.send_error_response( - context.request.op_key, - str(e), - ["method_execution", context.request.method_name], - ) diff --git a/pkgs/clan-app/clan_app/deps/webview/webview.py b/pkgs/clan-app/clan_app/deps/webview/webview.py index a121e54ad..49c11a064 100644 --- a/pkgs/clan-app/clan_app/deps/webview/webview.py +++ b/pkgs/clan-app/clan_app/deps/webview/webview.py @@ -13,7 +13,8 @@ from clan_lib.log_manager import LogManager from ._webview_ffi import _encode_c_string, _webview_lib if TYPE_CHECKING: - from .middleware import Middleware + from clan_app.api.middleware import Middleware + from .webview_bridge import WebviewBridge log = logging.getLogger(__name__) diff --git a/pkgs/clan-app/clan_app/deps/webview/webview_bridge.py b/pkgs/clan-app/clan_app/deps/webview/webview_bridge.py index 8cef32a76..7a0785289 100644 --- a/pkgs/clan-app/clan_app/deps/webview/webview_bridge.py +++ b/pkgs/clan-app/clan_app/deps/webview/webview_bridge.py @@ -8,7 +8,8 @@ from clan_lib.api import dataclass_to_dict from clan_lib.api.tasks import WebThread from clan_lib.async_run import set_should_cancel -from .api_bridge import ApiBridge, ApiRequest, ApiResponse +from clan_app.api.api_bridge import ApiBridge, BackendRequest, BackendResponse + from .webview import FuncStatus if TYPE_CHECKING: @@ -24,24 +25,15 @@ class WebviewBridge(ApiBridge): webview: "Webview" threads: dict[str, WebThread] = field(default_factory=dict) - def send_response(self, response: ApiResponse) -> None: + def send_response(self, response: BackendResponse) -> None: """Send response back to the webview client.""" - if response.success: - serialized = json.dumps( - dataclass_to_dict(response.data), indent=4, ensure_ascii=False - ) - status = FuncStatus.SUCCESS - else: - serialized = json.dumps( - dataclass_to_dict(response.data), indent=4, ensure_ascii=False - ) - status = FuncStatus.SUCCESS # Even errors are sent as SUCCESS to webview - - log.debug( - f"Sending response for op_key {response.op_key} with status {status.name} and data: {serialized}" + serialized = json.dumps( + dataclass_to_dict(response), indent=4, ensure_ascii=False ) - self.webview.return_(response.op_key, status, serialized) + + log.debug(f"Sending response: {serialized}") + self.webview.return_(response._op_key, FuncStatus.SUCCESS, serialized) # noqa: SLF001 def handle_webview_call( self, @@ -77,7 +69,7 @@ class WebviewBridge(ApiBridge): raise ValueError(msg) # Create API request - api_request = ApiRequest( + api_request = BackendRequest( method_name=method_name, args=args, header=header, op_key=op_key ) diff --git a/pkgs/clan-cli/clan_lib/api/tasks.py b/pkgs/clan-cli/clan_lib/api/tasks.py index 3d0258cb1..059d428c6 100644 --- a/pkgs/clan-cli/clan_lib/api/tasks.py +++ b/pkgs/clan-cli/clan_lib/api/tasks.py @@ -38,7 +38,6 @@ def long_blocking_task(somearg: str) -> str: time.sleep(1) ctx = get_async_ctx() log.debug(f"Thread ID: {threading.get_ident()}") - for i in range(30): if is_async_cancelled(): log.debug("Task was cancelled") From acab3b8905cea0024770c5af9bb2fbbc059d7145 Mon Sep 17 00:00:00 2001 From: Qubasa Date: Tue, 8 Jul 2025 17:17:29 +0700 Subject: [PATCH 4/7] clan-app: Ignore ruff errors --- pkgs/clan-app/clan_app/api/middleware/logging.py | 2 +- pkgs/clan-app/clan_app/deps/webview/webview_bridge.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pkgs/clan-app/clan_app/api/middleware/logging.py b/pkgs/clan-app/clan_app/api/middleware/logging.py index 73a9dd999..4a6b4e03a 100644 --- a/pkgs/clan-app/clan_app/api/middleware/logging.py +++ b/pkgs/clan-app/clan_app/api/middleware/logging.py @@ -30,7 +30,7 @@ class LoggingMiddleware(Middleware): if log_group is not None: if not isinstance(log_group, list): msg = f"Expected log_group to be a list, got {type(log_group)}" - raise TypeError(msg) + raise TypeError(msg) # noqa: TRY301 log.warning( f"Using log group {log_group} for {context.request.method_name} with op_key {context.request.op_key}" ) diff --git a/pkgs/clan-app/clan_app/deps/webview/webview_bridge.py b/pkgs/clan-app/clan_app/deps/webview/webview_bridge.py index 7a0785289..84055d7c5 100644 --- a/pkgs/clan-app/clan_app/deps/webview/webview_bridge.py +++ b/pkgs/clan-app/clan_app/deps/webview/webview_bridge.py @@ -56,17 +56,17 @@ class WebviewBridge(ApiBridge): header = request.get("header", {}) if not isinstance(header, dict): msg = f"Expected header to be a dict, got {type(header)}" - raise TypeError(msg) + raise TypeError(msg) # noqa: TRY301 body = request.get("body", {}) if not isinstance(body, dict): msg = f"Expected body to be a dict, got {type(body)}" - raise TypeError(msg) + raise TypeError(msg) # noqa: TRY301 args = body elif len(raw_args) > 1: msg = "Expected a single argument, got multiple arguments" - raise ValueError(msg) + raise ValueError(msg) # noqa: TRY301 # Create API request api_request = BackendRequest( From 50cbe3c8252c4ddf117594edb9924ff673f7a66e Mon Sep 17 00:00:00 2001 From: Qubasa Date: Tue, 8 Jul 2025 17:38:24 +0700 Subject: [PATCH 5/7] clan-app: Move json.loads to try catch --- pkgs/clan-app/clan_app/api/middleware/logging.py | 2 +- .../clan_app/deps/webview/webview_bridge.py | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/pkgs/clan-app/clan_app/api/middleware/logging.py b/pkgs/clan-app/clan_app/api/middleware/logging.py index 4a6b4e03a..6f066c85f 100644 --- a/pkgs/clan-app/clan_app/api/middleware/logging.py +++ b/pkgs/clan-app/clan_app/api/middleware/logging.py @@ -30,7 +30,7 @@ class LoggingMiddleware(Middleware): if log_group is not None: if not isinstance(log_group, list): msg = f"Expected log_group to be a list, got {type(log_group)}" - raise TypeError(msg) # noqa: TRY301 + raise TypeError(msg) # noqa: TRY301 log.warning( f"Using log group {log_group} for {context.request.method_name} with op_key {context.request.op_key}" ) diff --git a/pkgs/clan-app/clan_app/deps/webview/webview_bridge.py b/pkgs/clan-app/clan_app/deps/webview/webview_bridge.py index 84055d7c5..1df505715 100644 --- a/pkgs/clan-app/clan_app/deps/webview/webview_bridge.py +++ b/pkgs/clan-app/clan_app/deps/webview/webview_bridge.py @@ -43,10 +43,11 @@ class WebviewBridge(ApiBridge): arg: int, ) -> None: """Handle a call from webview's JavaScript bridge.""" - op_key = op_key_bytes.decode() - raw_args = json.loads(request_data.decode()) try: + op_key = op_key_bytes.decode() + raw_args = json.loads(request_data.decode()) + # Parse the webview-specific request format header = {} args = {} @@ -56,17 +57,17 @@ class WebviewBridge(ApiBridge): header = request.get("header", {}) if not isinstance(header, dict): msg = f"Expected header to be a dict, got {type(header)}" - raise TypeError(msg) # noqa: TRY301 + raise TypeError(msg) # noqa: TRY301 body = request.get("body", {}) if not isinstance(body, dict): msg = f"Expected body to be a dict, got {type(body)}" - raise TypeError(msg) # noqa: TRY301 + raise TypeError(msg) # noqa: TRY301 args = body elif len(raw_args) > 1: msg = "Expected a single argument, got multiple arguments" - raise ValueError(msg) # noqa: TRY301 + raise ValueError(msg) # noqa: TRY301 # Create API request api_request = BackendRequest( @@ -74,6 +75,8 @@ class WebviewBridge(ApiBridge): ) except Exception as e: + msg = f"Error while handling webview call {method_name} with op_key {op_key_bytes}" + log.exception(msg) self.send_error_response(op_key, str(e), ["webview_bridge", method_name]) return From 48c7613556504e8e8611f1881e76f76149c7b310 Mon Sep 17 00:00:00 2001 From: Qubasa Date: Tue, 8 Jul 2025 17:43:14 +0700 Subject: [PATCH 6/7] clan-cli: Add verb 'cancel' to openapi --- pkgs/clan-cli/clan_lib/api/tasks.py | 2 +- pkgs/clan-cli/openapi.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/pkgs/clan-cli/clan_lib/api/tasks.py b/pkgs/clan-cli/clan_lib/api/tasks.py index 059d428c6..0f856f322 100644 --- a/pkgs/clan-cli/clan_lib/api/tasks.py +++ b/pkgs/clan-cli/clan_lib/api/tasks.py @@ -33,7 +33,7 @@ def cancel_task(task_id: str) -> None: @API.register -def long_blocking_task(somearg: str) -> str: +def run_blocking_task(somearg: str) -> str: """A long blocking task that simulates a long-running operation.""" time.sleep(1) ctx = get_async_ctx() diff --git a/pkgs/clan-cli/openapi.py b/pkgs/clan-cli/openapi.py index bd6c280de..f14af341d 100644 --- a/pkgs/clan-cli/openapi.py +++ b/pkgs/clan-cli/openapi.py @@ -26,6 +26,7 @@ COMMON_VERBS = { "open", # initiate session, shell, file, etc. "check", # validate, probe, or assert "run", # start imperative task or action; machine-deploy etc. + "cancel" } From 639d227055d6097251a3b317e83f6a273bab9a31 Mon Sep 17 00:00:00 2001 From: Qubasa Date: Tue, 8 Jul 2025 17:58:49 +0700 Subject: [PATCH 7/7] clan-app: Fix delete tasks clan-app: Fix delete tasks --- pkgs/clan-app/clan_app/deps/webview/webview_bridge.py | 4 +++- pkgs/clan-cli/clan_lib/api/tasks.py | 2 +- pkgs/clan-cli/openapi.py | 1 - 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/pkgs/clan-app/clan_app/deps/webview/webview_bridge.py b/pkgs/clan-app/clan_app/deps/webview/webview_bridge.py index 1df505715..6c988ab30 100644 --- a/pkgs/clan-app/clan_app/deps/webview/webview_bridge.py +++ b/pkgs/clan-app/clan_app/deps/webview/webview_bridge.py @@ -75,7 +75,9 @@ class WebviewBridge(ApiBridge): ) except Exception as e: - msg = f"Error while handling webview call {method_name} with op_key {op_key_bytes}" + msg = ( + f"Error while handling webview call {method_name} with op_key {op_key}" + ) log.exception(msg) self.send_error_response(op_key, str(e), ["webview_bridge", method_name]) return diff --git a/pkgs/clan-cli/clan_lib/api/tasks.py b/pkgs/clan-cli/clan_lib/api/tasks.py index 0f856f322..7ba8e58d2 100644 --- a/pkgs/clan-cli/clan_lib/api/tasks.py +++ b/pkgs/clan-cli/clan_lib/api/tasks.py @@ -19,7 +19,7 @@ BAKEND_THREADS: dict[str, WebThread] | None = None @API.register -def cancel_task(task_id: str) -> None: +def delete_task(task_id: str) -> None: """Cancel a task by its op_key.""" assert BAKEND_THREADS is not None, "Backend threads not initialized" future = BAKEND_THREADS.get(task_id) diff --git a/pkgs/clan-cli/openapi.py b/pkgs/clan-cli/openapi.py index f14af341d..bd6c280de 100644 --- a/pkgs/clan-cli/openapi.py +++ b/pkgs/clan-cli/openapi.py @@ -26,7 +26,6 @@ COMMON_VERBS = { "open", # initiate session, shell, file, etc. "check", # validate, probe, or assert "run", # start imperative task or action; machine-deploy etc. - "cancel" }