From fcb5910c26b5079b3c8fd5b64c715fd615953859 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 25 Jun 2026 11:30:10 +0800 Subject: [PATCH 1/4] Clear SonarCloud and Codacy static-analysis backlog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve the open SonarCloud (193) and Codacy (3) findings: - S1172 (141): accessibility backend ABC default methods now pass their args through to _unsupported(*context), so the explicit signatures required for fake-backend overrides (W0221) are kept without unused parameters. - S1192 (13): extract duplicated literals to module constants (command_schema "Native UI" x67, placeholders, ".approvals", flow_editor submodule paths). - Code smells: merge nested if (http_cassette), flatten nested ternaries (assertions, color_match), drop redundant FileNotFoundError (notifier), prune expired leases after iteration instead of list() copy (credential_broker), extract _signal_parts to cut cognitive complexity (element_scoring), unused locals -> _ (form_fields), valid NOSONAR syntax (stats), keep timeout param for 3.10 (webrtc_transport). - Security S8707 (4): justify the CLI/operator-supplied paths (config_bundle, turn_config, host_service, stubs) — not a remote trust boundary. - Tests: rename Image->pil_image, dict()->literal, comprehension->list(), drop gratuitous truthiness, reword code-like comments, guard the empty block, def instead of identity-check lambda, boto3 API names justified. - Frontend: self->globalThis (sw.js), aria-labels on inputs, valid NOSONAR on the non-module top-level-await line. - Codacy: guard QApplication create (W0106), nosemgrep on the protocol SHA-1 handshake and the local-CLI subprocess test. --- je_auto_control/gui/flow_editor/__init__.py | 10 +- .../gui/script_builder/command_schema.py | 218 +++++++++--------- .../utils/accessibility/backends/base.py | 68 +++--- je_auto_control/utils/assertion/assertions.py | 3 +- .../utils/color_match/color_match.py | 6 +- .../utils/config_bundle/__main__.py | 3 +- .../utils/element_scoring/element_scoring.py | 33 ++- .../utils/executor/action_executor.py | 7 +- .../utils/form_fields/form_fields.py | 4 +- .../utils/governance/credential_broker.py | 7 +- .../utils/http_cassette/http_cassette.py | 12 +- .../utils/mcp_server/tools/_handlers.py | 8 +- je_auto_control/utils/notify/notifier.py | 2 +- .../utils/remote_desktop/host_service.py | 3 +- .../utils/remote_desktop/turn_config.py | 3 +- .../remote_desktop/web_viewer/index.html | 6 +- .../utils/remote_desktop/web_viewer/sw.js | 6 +- .../utils/remote_desktop/webrtc_transport.py | 8 +- .../utils/rest_api/dashboard/swagger.html | 2 +- je_auto_control/utils/stats/stats.py | 8 +- je_auto_control/utils/stubs/generator.py | 3 +- .../headless/test_action_effect_batch.py | 2 +- .../headless/test_canonical_log_batch.py | 1 + .../headless/test_coordinate_space_batch.py | 6 +- .../headless/test_critic_features_batch.py | 2 +- .../headless/test_decision_table_batch.py | 4 +- test/unit_test/headless/test_diagnostics.py | 2 +- test/unit_test/headless/test_dotenv_batch.py | 2 +- .../headless/test_image_dedup_batch.py | 14 +- .../headless/test_link_header_batch.py | 2 +- .../headless/test_remote_desktop_cursor.py | 3 +- .../headless/test_remote_desktop_websocket.py | 2 +- .../unit_test/headless/test_s3_store_batch.py | 4 +- .../headless/test_schema_compat_batch.py | 2 +- .../headless/test_timeseries_batch.py | 2 +- .../unit_test/headless/test_window_capture.py | 8 +- 36 files changed, 257 insertions(+), 219 deletions(-) diff --git a/je_auto_control/gui/flow_editor/__init__.py b/je_auto_control/gui/flow_editor/__init__.py index 46560537..3a8fce49 100644 --- a/je_auto_control/gui/flow_editor/__init__.py +++ b/je_auto_control/gui/flow_editor/__init__.py @@ -17,11 +17,13 @@ FlowEdge, FlowLayout, FlowNodePosition, layout_steps, ) +_SCENE_MODULE = "je_auto_control.gui.flow_editor.scene" +_TAB_MODULE = "je_auto_control.gui.flow_editor.tab" _LAZY_SUBMODULES = { - "FlowEdgeItem": "je_auto_control.gui.flow_editor.scene", - "FlowGraphScene": "je_auto_control.gui.flow_editor.scene", - "FlowNodeItem": "je_auto_control.gui.flow_editor.scene", - "FlowEditorTab": "je_auto_control.gui.flow_editor.tab", + "FlowEdgeItem": _SCENE_MODULE, + "FlowGraphScene": _SCENE_MODULE, + "FlowNodeItem": _SCENE_MODULE, + "FlowEditorTab": _TAB_MODULE, } diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index ba1809b2..3c8c93fd 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -45,6 +45,16 @@ class CommandSpec: _MOUSE_BUTTONS = ("mouse_left", "mouse_right", "mouse_middle") _REGION_PLACEHOLDER = "[left, top, right, bottom]" +_NATIVE_UI = "Native UI" +_SCALES_PLACEHOLDER = "[0.9, 1.0, 1.1]" +_POINT_PLACEHOLDER = "[10, 20]" +_RECT_PLACEHOLDER = "[x, y, width, height]" +_RECT4_PLACEHOLDER = "[x, y, w, h]" +_APPROVALS_DIR = ".approvals" +_DOTTED_KEY_PLACEHOLDER = "db.host" +_POINTS_JSON_PLACEHOLDER = '[{"x":..,"y":..,"width":..,"height":..}]' +_MARKS_JSON_PLACEHOLDER = '[{"role":"button","name":"OK","x":..,"y":..}]' +_BOXES_JSON_PLACEHOLDER = '[{"role":"button","x":0,"y":0}]' def _build_specs() -> List[CommandSpec]: @@ -261,7 +271,7 @@ def _add_image_specs(specs: List[CommandSpec]) -> None: FieldSpec("min_score", FieldType.FLOAT, optional=True, default=0.8, min_value=0.0, max_value=1.0), FieldSpec("scales", FieldType.STRING, optional=True, - placeholder="[0.9, 1.0, 1.1]"), + placeholder=_SCALES_PLACEHOLDER), FieldSpec("region", FieldType.STRING, optional=True, placeholder=_REGION_PLACEHOLDER), ), @@ -313,7 +323,7 @@ def _add_image_specs(specs: List[CommandSpec]) -> None: FieldSpec("angles", FieldType.STRING, optional=True, placeholder="[-10, 0, 10]"), FieldSpec("scales", FieldType.STRING, optional=True, - placeholder="[0.9, 1.0, 1.1]"), + placeholder=_SCALES_PLACEHOLDER), FieldSpec("region", FieldType.STRING, optional=True, placeholder=_REGION_PLACEHOLDER), ), @@ -328,7 +338,7 @@ def _add_image_specs(specs: List[CommandSpec]) -> None: FieldSpec("angles", FieldType.STRING, optional=True, placeholder="[-10, 0, 10]"), FieldSpec("scales", FieldType.STRING, optional=True, - placeholder="[0.9, 1.0, 1.1]"), + placeholder=_SCALES_PLACEHOLDER), FieldSpec("max_results", FieldType.INT, optional=True, default=20), FieldSpec("nms_iou", FieldType.FLOAT, optional=True, default=0.3, min_value=0.0, max_value=1.0), @@ -344,7 +354,7 @@ def _add_image_specs(specs: List[CommandSpec]) -> None: FieldSpec("ambiguous_ratio", FieldType.FLOAT, optional=True, default=0.9, min_value=0.0, max_value=1.0), FieldSpec("scales", FieldType.STRING, optional=True, - placeholder="[0.9, 1.0, 1.1]"), + placeholder=_SCALES_PLACEHOLDER), FieldSpec("region", FieldType.STRING, optional=True, placeholder=_REGION_PLACEHOLDER), ), @@ -378,7 +388,7 @@ def _add_image_specs(specs: List[CommandSpec]) -> None: FieldSpec("min_score", FieldType.FLOAT, optional=True, default=0.7, min_value=0.0, max_value=1.0), FieldSpec("scales", FieldType.STRING, optional=True, - placeholder="[0.9, 1.0, 1.1]"), + placeholder=_SCALES_PLACEHOLDER), FieldSpec("region", FieldType.STRING, optional=True, placeholder=_REGION_PLACEHOLDER), ), @@ -684,7 +694,7 @@ def _add_image_specs(specs: List[CommandSpec]) -> None: "AC_fuse_elements", "Image", "Fuse Element Boxes", fields=( FieldSpec("ocr", FieldType.STRING, optional=True, - placeholder='[{"x":..,"y":..,"width":..,"height":..}]'), + placeholder=_POINTS_JSON_PLACEHOLDER), FieldSpec("icon", FieldType.STRING, optional=True), FieldSpec("a11y", FieldType.STRING, optional=True), FieldSpec("iou_threshold", FieldType.FLOAT, optional=True, default=0.9, @@ -696,7 +706,7 @@ def _add_image_specs(specs: List[CommandSpec]) -> None: "AC_reading_order", "Image", "Reading Order", fields=( FieldSpec("elements", FieldType.STRING, - placeholder='[{"x":..,"y":..,"width":..,"height":..}]'), + placeholder=_POINTS_JSON_PLACEHOLDER), FieldSpec("row_tol", FieldType.INT, optional=True, default=12), ), description="Order element boxes top-to-bottom, left-to-right (+ index).", @@ -705,7 +715,7 @@ def _add_image_specs(specs: List[CommandSpec]) -> None: "AC_locate_chain", "Image", "Locate Chain (refine boxes)", fields=( FieldSpec("boxes", FieldType.STRING, - placeholder='[{"x":..,"y":..,"width":..,"height":..}]'), + placeholder=_POINTS_JSON_PLACEHOLDER), FieldSpec("ops", FieldType.STRING, placeholder='[{"op":"filter","has_text":"OK"},{"op":"first"}]'), ), @@ -1062,7 +1072,7 @@ def _add_window_specs(specs: List[CommandSpec]) -> None: FieldSpec("paths", FieldType.STRING, placeholder='["C:\\\\a\\\\one.txt"]'), FieldSpec("point", FieldType.STRING, optional=True, - placeholder="[10, 20]"), + placeholder=_POINT_PLACEHOLDER), ), description="Drop files onto a window via WM_DROPFILES (Windows).", )) @@ -1072,7 +1082,7 @@ def _add_window_specs(specs: List[CommandSpec]) -> None: FieldSpec("paths", FieldType.STRING, placeholder='["C:\\\\a\\\\one.txt"]'), FieldSpec("point", FieldType.STRING, optional=True, - placeholder="[10, 20]"), + placeholder=_POINT_PLACEHOLDER), ), description="Build the WM_DROPFILES payload without sending (pure).", )) @@ -1220,7 +1230,7 @@ def _add_window_specs(specs: List[CommandSpec]) -> None: "bottom_right", "center", "left_third", "center_third", "right_third"), default="left"), FieldSpec("screen", FieldType.STRING, optional=True, - placeholder="[x, y, width, height]"), + placeholder=_RECT_PLACEHOLDER), FieldSpec("gap", FieldType.INT, optional=True, default=0), ), description="Compute the rectangle for a tiling slot of the screen.", @@ -1231,7 +1241,7 @@ def _add_window_specs(specs: List[CommandSpec]) -> None: FieldSpec("rows", FieldType.INT, default=2), FieldSpec("cols", FieldType.INT, default=2), FieldSpec("screen", FieldType.STRING, optional=True, - placeholder="[x, y, width, height]"), + placeholder=_RECT_PLACEHOLDER), FieldSpec("gap", FieldType.INT, optional=True, default=0), ), description="Compute the cell rectangles of an R×C screen grid.", @@ -1241,7 +1251,7 @@ def _add_window_specs(specs: List[CommandSpec]) -> None: fields=( FieldSpec("count", FieldType.INT, default=3), FieldSpec("screen", FieldType.STRING, optional=True, - placeholder="[x, y, width, height]"), + placeholder=_RECT_PLACEHOLDER), FieldSpec("offset", FieldType.INT, optional=True, default=30), FieldSpec("size", FieldType.STRING, optional=True, placeholder="[width, height]"), @@ -1549,67 +1559,67 @@ def _add_native_control_specs(specs: List[CommandSpec]) -> None: FieldSpec("automation_id", FieldType.STRING, optional=True), ) specs.append(CommandSpec( - "AC_control_get_value", "Native UI", "Get Control Value", + "AC_control_get_value", _NATIVE_UI, "Get Control Value", fields=fields, description="Read a native control's value via the accessibility API.", )) specs.append(CommandSpec( - "AC_control_set_value", "Native UI", "Set Control Value", + "AC_control_set_value", _NATIVE_UI, "Set Control Value", fields=(FieldSpec("value", FieldType.STRING),) + fields, description="Set a native control's value directly (no per-key typing).", )) specs.append(CommandSpec( - "AC_control_invoke", "Native UI", "Invoke Control", + "AC_control_invoke", _NATIVE_UI, "Invoke Control", fields=fields, description="Invoke a native control (e.g. press a button).", )) specs.append(CommandSpec( - "AC_control_toggle", "Native UI", "Toggle Control", + "AC_control_toggle", _NATIVE_UI, "Toggle Control", fields=fields, description="Toggle a native control (e.g. a checkbox).", )) specs.append(CommandSpec( - "AC_read_table", "Native UI", "Read Table / Grid", + "AC_read_table", _NATIVE_UI, "Read Table / Grid", fields=fields, description="Read a grid/table/list control as rows of cell strings.", )) specs.append(CommandSpec( - "AC_expand_control", "Native UI", "Expand Control", + "AC_expand_control", _NATIVE_UI, "Expand Control", fields=fields, description="Expand a tree node / combobox (ExpandCollapsePattern).", )) specs.append(CommandSpec( - "AC_collapse_control", "Native UI", "Collapse Control", + "AC_collapse_control", _NATIVE_UI, "Collapse Control", fields=fields, description="Collapse a tree node / combobox (ExpandCollapsePattern).", )) specs.append(CommandSpec( - "AC_control_expand_state", "Native UI", "Control Expand State", + "AC_control_expand_state", _NATIVE_UI, "Control Expand State", fields=fields, description="Read expanded/collapsed/partial/leaf state of a control.", )) specs.append(CommandSpec( - "AC_select_control_item", "Native UI", "Select Control Item", + "AC_select_control_item", _NATIVE_UI, "Select Control Item", fields=fields, description="Select a list / tree / tab item (SelectionItemPattern).", )) specs.append(CommandSpec( - "AC_control_range", "Native UI", "Get Control Range", + "AC_control_range", _NATIVE_UI, "Get Control Range", fields=fields, description="Read a slider / progress range (RangeValuePattern).", )) specs.append(CommandSpec( - "AC_set_control_range", "Native UI", "Set Control Range", + "AC_set_control_range", _NATIVE_UI, "Set Control Range", fields=(FieldSpec("value", FieldType.FLOAT),) + fields, description="Set a slider / progress / spinner value (RangeValuePattern).", )) specs.append(CommandSpec( - "AC_scroll_control_into_view", "Native UI", "Scroll Control Into View", + "AC_scroll_control_into_view", _NATIVE_UI, "Scroll Control Into View", fields=fields, description="Scroll a control into view (ScrollItemPattern).", )) specs.append(CommandSpec( - "AC_realize_item", "Native UI", "Realize Virtualized Item", + "AC_realize_item", _NATIVE_UI, "Realize Virtualized Item", fields=( FieldSpec("item_name", FieldType.STRING), FieldSpec("by", FieldType.ENUM, optional=True, default="name", @@ -1622,124 +1632,124 @@ def _add_native_control_specs(specs: List[CommandSpec]) -> None: description="Realize an off-screen item in a virtualized list/grid.", )) specs.append(CommandSpec( - "AC_get_element_properties", "Native UI", "Get Element Properties", + "AC_get_element_properties", _NATIVE_UI, "Get Element Properties", fields=fields, description="Read rich UIA props (enabled/offscreen/help/status/keys).", )) specs.append(CommandSpec( - "AC_table_headers", "Native UI", "Get Table Headers", + "AC_table_headers", _NATIVE_UI, "Get Table Headers", fields=fields, description="Read a table's row/column header labels (TablePattern).", )) specs.append(CommandSpec( - "AC_table_cell", "Native UI", "Get Table Cell (by index)", + "AC_table_cell", _NATIVE_UI, "Get Table Cell (by index)", fields=(FieldSpec("row", FieldType.INT), FieldSpec("column", FieldType.INT)) + fields, description="Read the cell at (row, column) with its span.", )) specs.append(CommandSpec( - "AC_cell_by_header", "Native UI", "Get Table Cell (by header)", + "AC_cell_by_header", _NATIVE_UI, "Get Table Cell (by header)", fields=(FieldSpec("row", FieldType.INT), FieldSpec("column_header", FieldType.STRING)) + fields, description="Read the cell at (row, named column) — assert by header.", )) specs.append(CommandSpec( - "AC_move_element", "Native UI", "Move Element (Transform)", + "AC_move_element", _NATIVE_UI, "Move Element (Transform)", fields=(FieldSpec("x", FieldType.FLOAT), FieldSpec("y", FieldType.FLOAT)) + fields, description="Move a UIA element to (x, y) (TransformPattern).", )) specs.append(CommandSpec( - "AC_resize_element", "Native UI", "Resize Element (Transform)", + "AC_resize_element", _NATIVE_UI, "Resize Element (Transform)", fields=(FieldSpec("width", FieldType.FLOAT), FieldSpec("height", FieldType.FLOAT)) + fields, description="Resize a UIA element (TransformPattern).", )) specs.append(CommandSpec( - "AC_set_window_state", "Native UI", "Set Window State", + "AC_set_window_state", _NATIVE_UI, "Set Window State", fields=(FieldSpec("state", FieldType.ENUM, default="normal", choices=("normal", "maximized", "minimized")),) + fields, description="Minimize / maximize / restore a window (WindowPattern).", )) specs.append(CommandSpec( - "AC_window_interaction_state", "Native UI", "Window Interaction State", + "AC_window_interaction_state", _NATIVE_UI, "Window Interaction State", fields=fields, description="Read window readiness (ready/blocked_by_modal/...).", )) specs.append(CommandSpec( - "AC_legacy_info", "Native UI", "Legacy (MSAA) Info", + "AC_legacy_info", _NATIVE_UI, "Legacy (MSAA) Info", fields=fields, description="Read an old control's MSAA info (LegacyIAccessible).", )) specs.append(CommandSpec( - "AC_legacy_default_action", "Native UI", "Legacy (MSAA) Default Action", + "AC_legacy_default_action", _NATIVE_UI, "Legacy (MSAA) Default Action", fields=fields, description="Fire an old control's MSAA default action (fallback).", )) specs.append(CommandSpec( - "AC_get_selection", "Native UI", "Get Container Selection", + "AC_get_selection", _NATIVE_UI, "Get Container Selection", fields=fields, description="Read a container's selection (SelectionPattern).", )) specs.append(CommandSpec( - "AC_list_views", "Native UI", "List Control Views", + "AC_list_views", _NATIVE_UI, "List Control Views", fields=fields, description="List a control's selectable views (MultipleViewPattern).", )) specs.append(CommandSpec( - "AC_set_view", "Native UI", "Set Control View", + "AC_set_view", _NATIVE_UI, "Set Control View", fields=(FieldSpec("view", FieldType.STRING),) + fields, description="Switch a control to the named view (MultipleViewPattern).", )) specs.append(CommandSpec( - "AC_wait_for_focus_change", "Native UI", "Wait for Focus Change", + "AC_wait_for_focus_change", _NATIVE_UI, "Wait for Focus Change", fields=(FieldSpec("timeout", FieldType.FLOAT, optional=True, default=5.0),), description="Block until keyboard focus moves (real UIA focus event).", )) specs.append(CommandSpec( - "AC_get_control_text", "Native UI", "Get Control Text", + "AC_get_control_text", _NATIVE_UI, "Get Control Text", fields=fields, description="Read full text via TextPattern (multiline / document safe).", )) specs.append(CommandSpec( - "AC_find_control_text", "Native UI", "Find Text in Control", + "AC_find_control_text", _NATIVE_UI, "Find Text in Control", fields=(FieldSpec("text", FieldType.STRING), FieldSpec("ignore_case", FieldType.BOOL, optional=True, default=True)) + fields, description="Whether text occurs in a control (TextPattern.FindText).", )) specs.append(CommandSpec( - "AC_select_control_text", "Native UI", "Select Text in Control", + "AC_select_control_text", _NATIVE_UI, "Select Text in Control", fields=(FieldSpec("text", FieldType.STRING), FieldSpec("ignore_case", FieldType.BOOL, optional=True, default=True)) + fields, description="Find + select text in a control (FindText + Select).", )) specs.append(CommandSpec( - "AC_control_text_attributes", "Native UI", "Get Text Attributes", + "AC_control_text_attributes", _NATIVE_UI, "Get Text Attributes", fields=fields, description="Read selection formatting (font/size/bold/italic/colour).", )) specs.append(CommandSpec( - "AC_get_selected_text", "Native UI", "Get Selected Text", + "AC_get_selected_text", _NATIVE_UI, "Get Selected Text", fields=fields, description="Read the currently selected text via TextPattern.", )) specs.append(CommandSpec( - "AC_get_visible_text", "Native UI", "Get Visible Text", + "AC_get_visible_text", _NATIVE_UI, "Get Visible Text", fields=fields, description="Read only the on-screen text via TextPattern.GetVisibleRanges.", )) specs.append(CommandSpec( - "AC_walk_tree", "Native UI", "Walk Accessibility Tree", + "AC_walk_tree", _NATIVE_UI, "Walk Accessibility Tree", fields=(FieldSpec("app_name", FieldType.STRING, optional=True), FieldSpec("max_results", FieldType.INT, optional=True, default=500)), description="Dump the a11y tree with friendly roles + a path per node.", )) specs.append(CommandSpec( - "AC_humanize_role", "Native UI", "Humanize UIA Role", + "AC_humanize_role", _NATIVE_UI, "Humanize UIA Role", fields=(FieldSpec("role", FieldType.STRING),), description="Translate a raw UIA role (ControlType_50000) to a name.", )) @@ -1747,17 +1757,17 @@ def _add_native_control_specs(specs: List[CommandSpec]) -> None: FieldSpec("max_results", FieldType.INT, optional=True, default=500)) specs.append(CommandSpec( - "AC_tab_order", "Native UI", "Keyboard Tab Order", + "AC_tab_order", _NATIVE_UI, "Keyboard Tab Order", fields=tree_fields, description="List focusable controls in keyboard Tab (reading) order.", )) specs.append(CommandSpec( - "AC_audit_focus_order", "Native UI", "Audit Focus Order (WCAG)", + "AC_audit_focus_order", _NATIVE_UI, "Audit Focus Order (WCAG)", fields=tree_fields, description="WCAG 2.4.x focus-order audit: tab sequence + flagged issues.", )) specs.append(CommandSpec( - "AC_focus_control", "Native UI", "Set Keyboard Focus", + "AC_focus_control", _NATIVE_UI, "Set Keyboard Focus", fields=fields, description="Set keyboard focus on a control natively (UIA SetFocus).", )) @@ -1862,7 +1872,7 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None: description="Generate a TOTP 2FA code from a base32 secret.", )) specs.append(CommandSpec( - "AC_handle_file_dialog", "Native UI", "Handle File Dialog", + "AC_handle_file_dialog", _NATIVE_UI, "Handle File Dialog", fields=( FieldSpec("path", FieldType.STRING), FieldSpec("action", FieldType.ENUM, @@ -2055,7 +2065,7 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None: FieldSpec("name", FieldType.STRING, placeholder="login_screen"), FieldSpec("content", FieldType.STRING), FieldSpec("approvals_dir", FieldType.STRING, optional=True, - default=".approvals"), + default=_APPROVALS_DIR), FieldSpec("extension", FieldType.STRING, optional=True, default="txt"), ), @@ -2066,7 +2076,7 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None: fields=( FieldSpec("name", FieldType.STRING), FieldSpec("approvals_dir", FieldType.STRING, optional=True, - default=".approvals"), + default=_APPROVALS_DIR), FieldSpec("extension", FieldType.STRING, optional=True, default="txt"), ), @@ -2075,7 +2085,7 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None: specs.append(CommandSpec( "AC_pending_artifacts", "Testing", "Approval: List Pending", fields=(FieldSpec("approvals_dir", FieldType.STRING, optional=True, - default=".approvals"),), + default=_APPROVALS_DIR),), description="List artifacts awaiting approval.", )) specs.append(CommandSpec( @@ -2730,7 +2740,7 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None: FieldSpec("key", FieldType.STRING), FieldSpec("method", FieldType.STRING, placeholder="vlm/image"), FieldSpec("coordinates", FieldType.STRING, optional=True, - placeholder="[10, 20]"), + placeholder=_POINT_PLACEHOLDER), FieldSpec("description", FieldType.STRING, optional=True), FieldSpec("confidence", FieldType.FLOAT, optional=True, default=1.0), @@ -3247,7 +3257,7 @@ def _add_resilience_specs(specs: List[CommandSpec]) -> None: fields=( FieldSpec("layers", FieldType.STRING, placeholder='[{"name": "defaults", "mapping": {}}]'), - FieldSpec("key", FieldType.STRING, placeholder="db.host"), + FieldSpec("key", FieldType.STRING, placeholder=_DOTTED_KEY_PLACEHOLDER), ), description="Show the value and winning layer for a dotted config key.", )) @@ -3375,7 +3385,7 @@ def _add_resilience_specs(specs: List[CommandSpec]) -> None: "AC_cas_put", "Flow", "Optimistic: Put (CAS)", fields=( FieldSpec("name", FieldType.STRING, placeholder="config"), - FieldSpec("key", FieldType.STRING, placeholder="db.host"), + FieldSpec("key", FieldType.STRING, placeholder=_DOTTED_KEY_PLACEHOLDER), FieldSpec("value", FieldType.STRING, placeholder='"prod-1"'), FieldSpec("expected_version", FieldType.INT, optional=True), ), @@ -3385,7 +3395,7 @@ def _add_resilience_specs(specs: List[CommandSpec]) -> None: "AC_cas_get", "Flow", "Optimistic: Get", fields=( FieldSpec("name", FieldType.STRING, placeholder="config"), - FieldSpec("key", FieldType.STRING, placeholder="db.host"), + FieldSpec("key", FieldType.STRING, placeholder=_DOTTED_KEY_PLACEHOLDER), ), description="Read a versioned record {value, version}.", )) @@ -3621,21 +3631,21 @@ def _add_input_macro_specs(specs: List[CommandSpec]) -> None: def _add_screen_state_specs(specs: List[CommandSpec]) -> None: app = FieldSpec("app_name", FieldType.STRING, optional=True) specs.append(CommandSpec( - "AC_screen_snapshot", "Native UI", "Screen: Snapshot Baseline", + "AC_screen_snapshot", _NATIVE_UI, "Screen: Snapshot Baseline", fields=(app,), description="Snapshot the a11y tree as a semantic-diff baseline.", )) specs.append(CommandSpec( - "AC_screen_diff", "Native UI", "Screen: Diff Snapshots", + "AC_screen_diff", _NATIVE_UI, "Screen: Diff Snapshots", description="Semantic diff of 'before'/'after' snapshots (JSON view).", )) specs.append(CommandSpec( - "AC_screen_changed", "Native UI", "Screen: What Changed", + "AC_screen_changed", _NATIVE_UI, "Screen: What Changed", fields=(app,), description="Diff the live screen against the last snapshot baseline.", )) specs.append(CommandSpec( - "AC_describe_screen", "Native UI", "Screen: Describe", + "AC_describe_screen", _NATIVE_UI, "Screen: Describe", fields=(app,), description="Structured 'where am I' (role counts + control labels).", )) @@ -3643,7 +3653,7 @@ def _add_screen_state_specs(specs: List[CommandSpec]) -> None: def _add_set_of_marks_specs(specs: List[CommandSpec]) -> None: specs.append(CommandSpec( - "AC_cua_command", "Native UI", "Computer-Use: Map Action", + "AC_cua_command", _NATIVE_UI, "Computer-Use: Map Action", fields=( FieldSpec("payload", FieldType.STRING, placeholder='{"action":"left_click","coordinate":[x,y]}'), @@ -3653,43 +3663,43 @@ def _add_set_of_marks_specs(specs: List[CommandSpec]) -> None: description="Map an Anthropic / OpenAI computer-use action to an AC command.", )) specs.append(CommandSpec( - "AC_serialize_observation", "Native UI", "Observation: Serialize Elements", + "AC_serialize_observation", _NATIVE_UI, "Observation: Serialize Elements", fields=( FieldSpec("elements", FieldType.STRING, - placeholder='[{"role":"button","name":"OK","x":..,"y":..}]'), + placeholder=_MARKS_JSON_PLACEHOLDER), FieldSpec("viewport", FieldType.STRING, optional=True, - placeholder="[x, y, w, h]"), + placeholder=_RECT4_PLACEHOLDER), FieldSpec("max_elements", FieldType.INT, optional=True, default=80), ), description="Indexed text observation of UI elements for a VLM (act by index).", )) specs.append(CommandSpec( - "AC_observation_index", "Native UI", "Observation: Index Elements", + "AC_observation_index", _NATIVE_UI, "Observation: Index Elements", fields=( FieldSpec("elements", FieldType.STRING, - placeholder='[{"role":"button","name":"OK","x":..,"y":..}]'), + placeholder=_MARKS_JSON_PLACEHOLDER), FieldSpec("viewport", FieldType.STRING, optional=True, - placeholder="[x, y, w, h]"), + placeholder=_RECT4_PLACEHOLDER), FieldSpec("max_elements", FieldType.INT, optional=True, default=80), ), description="Reading-ordered, viewport-clipped, indexed element list.", )) specs.append(CommandSpec( - "AC_delta_observation", "Native UI", "Observation: Delta (what changed)", + "AC_delta_observation", _NATIVE_UI, "Observation: Delta (what changed)", fields=( FieldSpec("prev", FieldType.STRING, - placeholder='[{"role":"button","name":"OK","x":..,"y":..}]'), + placeholder=_MARKS_JSON_PLACEHOLDER), FieldSpec("curr", FieldType.STRING, - placeholder='[{"role":"button","name":"OK","x":..,"y":..}]'), + placeholder=_MARKS_JSON_PLACEHOLDER), FieldSpec("viewport", FieldType.STRING, optional=True, - placeholder="[x, y, w, h]"), + placeholder=_RECT4_PLACEHOLDER), FieldSpec("max_elements", FieldType.INT, optional=True, default=80), FieldSpec("max_lines", FieldType.INT, optional=True, default=40), ), description="Token-budgeted '+/~/-' summary of what changed between frames.", )) specs.append(CommandSpec( - "AC_classify_effect", "Native UI", "Classify Action Effect", + "AC_classify_effect", _NATIVE_UI, "Classify Action Effect", fields=( FieldSpec("before", FieldType.STRING, placeholder='[{"role":"button","name":"OK","x":0,"y":0}]'), @@ -3702,19 +3712,19 @@ def _add_set_of_marks_specs(specs: List[CommandSpec]) -> None: description="Did the action change the screen near its target? (no_op/…).", )) specs.append(CommandSpec( - "AC_effect_near_point", "Native UI", "Effect Near Point?", + "AC_effect_near_point", _NATIVE_UI, "Effect Near Point?", fields=( FieldSpec("before", FieldType.STRING, - placeholder='[{"role":"button","x":0,"y":0}]'), + placeholder=_BOXES_JSON_PLACEHOLDER), FieldSpec("after", FieldType.STRING, - placeholder='[{"role":"button","x":0,"y":0}]'), + placeholder=_BOXES_JSON_PLACEHOLDER), FieldSpec("point", FieldType.STRING, placeholder="[50, 50]"), FieldSpec("radius", FieldType.INT, optional=True, default=64), ), description="Did any before/after change land within radius of a point?", )) specs.append(CommandSpec( - "AC_check_postcondition", "Native UI", "Check Postcondition", + "AC_check_postcondition", _NATIVE_UI, "Check Postcondition", fields=( FieldSpec("after", FieldType.STRING, placeholder='[{"role":"dialog","name":"Saved"}]'), @@ -3727,7 +3737,7 @@ def _add_set_of_marks_specs(specs: List[CommandSpec]) -> None: description="Check expected outcome clauses against after/before frames.", )) specs.append(CommandSpec( - "AC_plan_repair", "Native UI", "Plan Repair Tactics", + "AC_plan_repair", _NATIVE_UI, "Plan Repair Tactics", fields=( FieldSpec("verdict", FieldType.STRING, placeholder="no_op / changed_elsewhere / changed"), @@ -3736,7 +3746,7 @@ def _add_set_of_marks_specs(specs: List[CommandSpec]) -> None: description="Ordered repair tactics for a failed/no-effect action verdict.", )) specs.append(CommandSpec( - "AC_consensus_point", "Native UI", "Grounding Consensus Point", + "AC_consensus_point", _NATIVE_UI, "Grounding Consensus Point", fields=( FieldSpec("candidates", FieldType.STRING, placeholder="[[100, 100], [104, 98], [97, 103]]"), @@ -3755,12 +3765,12 @@ def _add_set_of_marks_specs(specs: List[CommandSpec]) -> None: description="Index where a churn series first settles (offline settle check).", )) specs.append(CommandSpec( - "AC_build_critic_record", "Native UI", "Build Critic Record", + "AC_build_critic_record", _NATIVE_UI, "Build Critic Record", fields=( FieldSpec("action", FieldType.STRING, placeholder='{"type":"click","x":50,"y":50}'), FieldSpec("before", FieldType.STRING, - placeholder='[{"role":"button","x":0,"y":0}]'), + placeholder=_BOXES_JSON_PLACEHOLDER), FieldSpec("after", FieldType.STRING, placeholder='[{"role":"dialog","x":40,"y":40}]'), FieldSpec("postcondition", FieldType.STRING, optional=True, @@ -3770,7 +3780,7 @@ def _add_set_of_marks_specs(specs: List[CommandSpec]) -> None: description="Per-step critic evidence (effect + delta + postcondition).", )) specs.append(CommandSpec( - "AC_score_step", "Native UI", "Score Step (rule-based)", + "AC_score_step", _NATIVE_UI, "Score Step (rule-based)", fields=( FieldSpec("record", FieldType.STRING, placeholder='{"effect":{"effect":"changed_near_target"}}'), @@ -3778,45 +3788,45 @@ def _add_set_of_marks_specs(specs: List[CommandSpec]) -> None: description="Rule-based outcome + process score of a critic record.", )) specs.append(CommandSpec( - "AC_consensus_element", "Native UI", "Grounding Consensus Element", + "AC_consensus_element", _NATIVE_UI, "Grounding Consensus Element", fields=( FieldSpec("candidates", FieldType.STRING, placeholder="[[8, 8], [12, 10]]"), FieldSpec("elements", FieldType.STRING, - placeholder='[{"role":"button","x":0,"y":0}]'), + placeholder=_BOXES_JSON_PLACEHOLDER), ), description="Vote grounding proposals to the nearest element.", )) specs.append(CommandSpec( - "AC_validate_action", "Native UI", "Validate / Snap Action", + "AC_validate_action", _NATIVE_UI, "Validate / Snap Action", fields=( FieldSpec("action", FieldType.STRING, placeholder='{"type":"click","x":..,"y":..}'), FieldSpec("screen", FieldType.STRING, optional=True, placeholder="[width, height]"), FieldSpec("targets", FieldType.STRING, optional=True, - placeholder='[{"x":..,"y":..,"width":..,"height":..}]'), + placeholder=_POINTS_JSON_PLACEHOLDER), ), description="Reject out-of-bounds clicks; snap a near-miss to the nearest " "element.", )) specs.append(CommandSpec( - "AC_match_elements", "Native UI", "Match Elements (frames)", + "AC_match_elements", _NATIVE_UI, "Match Elements (frames)", fields=( FieldSpec("before", FieldType.STRING, - placeholder='[{"x":..,"y":..,"width":..,"height":..}]'), + placeholder=_POINTS_JSON_PLACEHOLDER), FieldSpec("after", FieldType.STRING, - placeholder='[{"x":..,"y":..,"width":..,"height":..}]'), + placeholder=_POINTS_JSON_PLACEHOLDER), FieldSpec("iou_threshold", FieldType.FLOAT, optional=True, default=0.5, min_value=0.0, max_value=1.0), ), description="Match element boxes across two frames by overlap (move/rename).", )) specs.append(CommandSpec( - "AC_assign_stable_ids", "Native UI", "Assign Stable Element IDs", + "AC_assign_stable_ids", _NATIVE_UI, "Assign Stable Element IDs", fields=( FieldSpec("elements", FieldType.STRING, - placeholder='[{"x":..,"y":..,"width":..,"height":..}]'), + placeholder=_POINTS_JSON_PLACEHOLDER), FieldSpec("prior", FieldType.STRING, optional=True, placeholder="prior frame's elements (with ids)"), FieldSpec("iou_threshold", FieldType.FLOAT, optional=True, default=0.5, @@ -3825,10 +3835,10 @@ def _add_set_of_marks_specs(specs: List[CommandSpec]) -> None: description="Tag elements with IDs carried across frames by overlap.", )) specs.append(CommandSpec( - "AC_score_candidates", "Native UI", "Score Candidates", + "AC_score_candidates", _NATIVE_UI, "Score Candidates", fields=( FieldSpec("candidates", FieldType.STRING, - placeholder='[{"role":"button","name":"OK","x":..,"y":..}]'), + placeholder=_MARKS_JSON_PLACEHOLDER), FieldSpec("want_role", FieldType.STRING, optional=True), FieldSpec("want_name", FieldType.STRING, optional=True), FieldSpec("anchor", FieldType.STRING, optional=True, @@ -3837,10 +3847,10 @@ def _add_set_of_marks_specs(specs: List[CommandSpec]) -> None: description="Rank candidate elements by role / name / proximity confidence.", )) specs.append(CommandSpec( - "AC_best_candidate", "Native UI", "Best Candidate", + "AC_best_candidate", _NATIVE_UI, "Best Candidate", fields=( FieldSpec("candidates", FieldType.STRING, - placeholder='[{"role":"button","name":"OK","x":..,"y":..}]'), + placeholder=_MARKS_JSON_PLACEHOLDER), FieldSpec("want_role", FieldType.STRING, optional=True), FieldSpec("want_name", FieldType.STRING, optional=True), FieldSpec("anchor", FieldType.STRING, optional=True, @@ -3849,7 +3859,7 @@ def _add_set_of_marks_specs(specs: List[CommandSpec]) -> None: description="The single highest-scoring candidate element.", )) specs.append(CommandSpec( - "AC_mark_screen", "Native UI", "Set-of-Marks: Number Elements", + "AC_mark_screen", _NATIVE_UI, "Set-of-Marks: Number Elements", fields=( FieldSpec("app_name", FieldType.STRING, optional=True), FieldSpec("render_path", FieldType.FILE_PATH, optional=True), @@ -3858,7 +3868,7 @@ def _add_set_of_marks_specs(specs: List[CommandSpec]) -> None: "grounding; optional numbered-box overlay screenshot.", )) specs.append(CommandSpec( - "AC_mark_click", "Native UI", "Set-of-Marks: Click Number", + "AC_mark_click", _NATIVE_UI, "Set-of-Marks: Click Number", fields=(FieldSpec("mark_id", FieldType.INT),), description="Click the element behind a numbered mark.", )) @@ -4085,7 +4095,7 @@ def _add_authoring_specs(specs: List[CommandSpec]) -> None: path = FieldSpec("path", FieldType.FILE_PATH) key = FieldSpec("key", FieldType.STRING) specs.append(CommandSpec( - "AC_element_save", "Native UI", "Element: Save Locator", + "AC_element_save", _NATIVE_UI, "Element: Save Locator", fields=(path, key, FieldSpec("name", FieldType.STRING, optional=True), FieldSpec("role", FieldType.STRING, optional=True), @@ -4093,22 +4103,22 @@ def _add_authoring_specs(specs: List[CommandSpec]) -> None: description="Save a named native-UI locator (object repository).", )) specs.append(CommandSpec( - "AC_element_find", "Native UI", "Element: Find Saved", + "AC_element_find", _NATIVE_UI, "Element: Find Saved", fields=(path, key), description="Resolve a saved locator to a live element summary.", )) specs.append(CommandSpec( - "AC_element_click", "Native UI", "Element: Click Saved", + "AC_element_click", _NATIVE_UI, "Element: Click Saved", fields=(path, key), description="Click the element behind a saved locator.", )) specs.append(CommandSpec( - "AC_element_remove", "Native UI", "Element: Remove Saved", + "AC_element_remove", _NATIVE_UI, "Element: Remove Saved", fields=(path, key), description="Delete a saved locator.", )) specs.append(CommandSpec( - "AC_element_list", "Native UI", "Element: List Saved", + "AC_element_list", _NATIVE_UI, "Element: List Saved", fields=(path,), description="List saved locator names in a repository file.", )) diff --git a/je_auto_control/utils/accessibility/backends/base.py b/je_auto_control/utils/accessibility/backends/base.py index 4b86ab54..3b1f6637 100644 --- a/je_auto_control/utils/accessibility/backends/base.py +++ b/je_auto_control/utils/accessibility/backends/base.py @@ -29,32 +29,32 @@ def get_value(self, name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> Optional[str]: """Return the matched control's value text, or None if not found.""" - self._unsupported("get_value") + self._unsupported("get_value", name, role, app_name, automation_id) def set_value(self, value: str, name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> bool: """Set the matched control's value; return True on success.""" - self._unsupported("set_value") + self._unsupported("set_value", value, name, role, app_name, automation_id) def invoke(self, name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> bool: """Invoke the matched control (e.g. press a button).""" - self._unsupported("invoke") + self._unsupported("invoke", name, role, app_name, automation_id) def toggle(self, name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> bool: """Toggle the matched control (e.g. a checkbox).""" - self._unsupported("toggle") + self._unsupported("toggle", name, role, app_name, automation_id) def read_table(self, name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None, ) -> List[List[str]]: """Read a grid/table/list control as rows of cell strings.""" - self._unsupported("read_table") + self._unsupported("read_table", name, role, app_name, automation_id) # --- extended control patterns (Expand / Selection / Range / Scroll) ---- @@ -62,43 +62,43 @@ def expand(self, name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> bool: """Expand the matched control (ExpandCollapsePattern); True on success.""" - self._unsupported("expand") + self._unsupported("expand", name, role, app_name, automation_id) def collapse(self, name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> bool: """Collapse the matched control (ExpandCollapsePattern); True on success.""" - self._unsupported("collapse") + self._unsupported("collapse", name, role, app_name, automation_id) def expand_state(self, name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> Optional[str]: """Return ``expanded`` / ``collapsed`` / ``partial`` / ``leaf``, or None.""" - self._unsupported("expand_state") + self._unsupported("expand_state", name, role, app_name, automation_id) def select_item(self, name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> bool: """Select the matched item (SelectionItemPattern); True on success.""" - self._unsupported("select_item") + self._unsupported("select_item", name, role, app_name, automation_id) def get_range(self, name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> Optional[Dict[str, Any]]: """Return ``{value, minimum, maximum}`` (RangeValuePattern), or None.""" - self._unsupported("get_range") + self._unsupported("get_range", name, role, app_name, automation_id) def set_range_value(self, value: float, name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> bool: """Set a slider / progress value (RangeValuePattern); True on success.""" - self._unsupported("set_range_value") + self._unsupported("set_range_value", value, name, role, app_name, automation_id) def scroll_into_view(self, name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> bool: """Scroll the matched control into view (ScrollItemPattern); True on success.""" - self._unsupported("scroll_into_view") + self._unsupported("scroll_into_view", name, role, app_name, automation_id) # --- text patterns (TextPattern reads) --------------------------------- @@ -109,33 +109,33 @@ def document_text(self, name: Optional[str] = None, role: Optional[str] = None, Reads multiline / document controls where ValuePattern returns ``""``. """ - self._unsupported("document_text") + self._unsupported("document_text", name, role, app_name, automation_id) def selected_text(self, name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> Optional[str]: """Return the control's currently selected text (TextPattern), or None.""" - self._unsupported("selected_text") + self._unsupported("selected_text", name, role, app_name, automation_id) def visible_text(self, name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> Optional[str]: """Return only the on-screen text of the control (TextPattern), or None.""" - self._unsupported("visible_text") + self._unsupported("visible_text", name, role, app_name, automation_id) def find_text(self, text: str = "", ignore_case: bool = True, name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> bool: """Return whether ``text`` occurs in the control (TextPattern.FindText).""" - self._unsupported("find_text") + self._unsupported("find_text", text, ignore_case, name, role, app_name, automation_id) def select_text(self, text: str = "", ignore_case: bool = True, name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> bool: """Find ``text`` and select its range (TextPattern.FindText + Select).""" - self._unsupported("select_text") + self._unsupported("select_text", text, ignore_case, name, role, app_name, automation_id) def text_attributes(self, name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, @@ -143,7 +143,7 @@ def text_attributes(self, name: Optional[str] = None, ) -> Optional[Dict[str, Any]]: """Return formatting of the control's selection — ``{font_name, font_size, bold, italic, foreground_color}`` (TextPattern attributes), or None.""" - self._unsupported("text_attributes") + self._unsupported("text_attributes", name, role, app_name, automation_id) # --- keyboard focus ---------------------------------------------------- @@ -151,7 +151,7 @@ def set_focus(self, name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> bool: """Set keyboard focus on the matched control (SetFocus); True on success.""" - self._unsupported("set_focus") + self._unsupported("set_focus", name, role, app_name, automation_id) # --- virtualized items (realize off-screen list / grid items) ----------- @@ -168,7 +168,7 @@ def find_virtual_item(self, item_name: Optional[str] = None, by: str = "name", (``VirtualizedItemPattern``) so it exists as a real element. Returns the realized element, or None if the container or item isn't found. """ - self._unsupported("find_virtual_item") + self._unsupported("find_virtual_item", item_name, by, container_name, container_role, app_name, automation_id) # --- rich element properties ------------------------------------------- @@ -182,7 +182,7 @@ def get_properties(self, name: Optional[str] = None, ``enabled`` / ``offscreen`` / ``help_text`` / ``item_status`` / ``accelerator_key`` / ``access_key`` / ``orientation``. """ - self._unsupported("get_properties") + self._unsupported("get_properties", name, role, app_name, automation_id) # --- table headers + cell addressing (TablePattern / GridItemPattern) --- @@ -192,7 +192,7 @@ def get_table_headers(self, name: Optional[str] = None, automation_id: Optional[str] = None, ) -> Optional[Dict[str, Any]]: """Return a table's header labels as ``{columns: [...], rows: [...]}``.""" - self._unsupported("get_table_headers") + self._unsupported("get_table_headers", name, role, app_name, automation_id) def get_grid_cell(self, row: int = 0, column: int = 0, name: Optional[str] = None, role: Optional[str] = None, @@ -201,7 +201,7 @@ def get_grid_cell(self, row: int = 0, column: int = 0, ) -> Optional[Dict[str, Any]]: """Return the cell at ``(row, column)`` as ``{value, row, column, row_span, column_span}`` (GridPattern.GetItem + GridItemPattern).""" - self._unsupported("get_grid_cell") + self._unsupported("get_grid_cell", row, column, name, role, app_name, automation_id) # --- transform + window patterns (UIA-element-level) -------------------- @@ -210,21 +210,21 @@ def move_element(self, x: float = 0.0, y: float = 0.0, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> bool: """Move the matched element to ``(x, y)`` (TransformPattern); True on success.""" - self._unsupported("move_element") + self._unsupported("move_element", x, y, name, role, app_name, automation_id) def resize_element(self, width: float = 0.0, height: float = 0.0, name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> bool: """Resize the matched element (TransformPattern); True on success.""" - self._unsupported("resize_element") + self._unsupported("resize_element", width, height, name, role, app_name, automation_id) def set_window_state(self, state: str = "normal", name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> bool: """Set a window's visual state ``normal`` / ``maximized`` / ``minimized``.""" - self._unsupported("set_window_state") + self._unsupported("set_window_state", state, name, role, app_name, automation_id) def window_interaction_state(self, name: Optional[str] = None, role: Optional[str] = None, @@ -233,7 +233,7 @@ def window_interaction_state(self, name: Optional[str] = None, ) -> Optional[str]: """Return a window's interaction state — ``ready`` / ``blocked_by_modal`` / ``not_responding`` / ``running`` / ``closing`` (WindowPattern), or None.""" - self._unsupported("window_interaction_state") + self._unsupported("window_interaction_state", name, role, app_name, automation_id) # --- MSAA bridge (LegacyIAccessiblePattern) ---------------------------- @@ -247,7 +247,7 @@ def legacy_info(self, name: Optional[str] = None, role: Optional[str] = None, last-resort read for legacy Win32 controls that expose nothing useful via the modern UIA patterns. """ - self._unsupported("legacy_info") + self._unsupported("legacy_info", name, role, app_name, automation_id) def legacy_default_action(self, name: Optional[str] = None, role: Optional[str] = None, @@ -255,7 +255,7 @@ def legacy_default_action(self, name: Optional[str] = None, automation_id: Optional[str] = None) -> bool: """Fire an old control's MSAA default action (DoDefaultAction); True on success — the fallback when Value / Invoke / Toggle all do nothing.""" - self._unsupported("legacy_default_action") + self._unsupported("legacy_default_action", name, role, app_name, automation_id) # --- container selection + views (Selection / MultipleView patterns) ---- @@ -265,7 +265,7 @@ def get_selection(self, name: Optional[str] = None, role: Optional[str] = None, ) -> Optional[Dict[str, Any]]: """Return a container's selection state — ``{items, can_select_multiple, is_required}`` (SelectionPattern), or None.""" - self._unsupported("get_selection") + self._unsupported("get_selection", name, role, app_name, automation_id) def list_views(self, name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, @@ -273,13 +273,13 @@ def list_views(self, name: Optional[str] = None, role: Optional[str] = None, ) -> Optional[Dict[str, Any]]: """Return a control's selectable views — ``{current, views: [...]}`` (MultipleViewPattern: list / details / tile / …), or None.""" - self._unsupported("list_views") + self._unsupported("list_views", name, role, app_name, automation_id) def set_view(self, view: str = "", name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> bool: """Switch a control to the named view (MultipleViewPattern); True on success.""" - self._unsupported("set_view") + self._unsupported("set_view", view, name, role, app_name, automation_id) # --- reactive events (UIA event subscription) -------------------------- @@ -291,9 +291,9 @@ def wait_for_focus_change(self, timeout: float = 5.0, A zero-latency native wait (UIA AddFocusChangedEventHandler) — unlike the polling recorder, it can't miss a fast focus transition. """ - self._unsupported("wait_for_focus_change") + self._unsupported("wait_for_focus_change", timeout) - def _unsupported(self, operation: str): + def _unsupported(self, operation: str, *context: Any): """Raise a clear error for an action this backend can't perform.""" raise AccessibilityNotAvailableError( f"{operation} is not supported by the {self.name} backend", diff --git a/je_auto_control/utils/assertion/assertions.py b/je_auto_control/utils/assertion/assertions.py index 2a80caee..f4214069 100644 --- a/je_auto_control/utils/assertion/assertions.py +++ b/je_auto_control/utils/assertion/assertions.py @@ -489,11 +489,12 @@ def assert_by_description(description: str, ) passed = (matched == present) state = "shows" if present else "does not show" + verdict = "match" if matched else "no match" message = ( f"assert_by_description passed: screen {state} {description!r}" if passed else f"assert_by_description failed: expected screen to {state} " - f"{description!r} (VLM verdict: {'match' if matched else 'no match'})" + f"{description!r} (VLM verdict: {verdict})" ) return _finalize( "vlm", passed, message, diff --git a/je_auto_control/utils/color_match/color_match.py b/je_auto_control/utils/color_match/color_match.py index a0858fc3..c7dd7cd0 100644 --- a/je_auto_control/utils/color_match/color_match.py +++ b/je_auto_control/utils/color_match/color_match.py @@ -26,8 +26,10 @@ def _hsv(source, region, is_haystack: bool): import cv2 - rgb = (_to_rgb(source) if source is not None - else _grab_rgb(region)) if is_haystack else _to_rgb(source) + if is_haystack: + rgb = _to_rgb(source) if source is not None else _grab_rgb(region) + else: + rgb = _to_rgb(source) return cv2.cvtColor(rgb, cv2.COLOR_RGB2HSV) diff --git a/je_auto_control/utils/config_bundle/__main__.py b/je_auto_control/utils/config_bundle/__main__.py index 880d4674..f57395a7 100644 --- a/je_auto_control/utils/config_bundle/__main__.py +++ b/je_auto_control/utils/config_bundle/__main__.py @@ -64,7 +64,8 @@ def _do_export(output: Path, root: Optional[Path]) -> int: def _do_import(source: Path, root: Optional[Path], dry_run: bool) -> int: try: - bundle = json.loads(source.read_text(encoding="utf-8")) + # source is an operator-supplied CLI path, not remote input + bundle = json.loads(source.read_text(encoding="utf-8")) # NOSONAR except (OSError, ValueError) as error: print(f"failed to read {source}: {error}", file=sys.stderr) return 2 diff --git a/je_auto_control/utils/element_scoring/element_scoring.py b/je_auto_control/utils/element_scoring/element_scoring.py index 4c84ddf2..4d614260 100644 --- a/je_auto_control/utils/element_scoring/element_scoring.py +++ b/je_auto_control/utils/element_scoring/element_scoring.py @@ -40,6 +40,26 @@ def _proximity(element: Element, anchor: Sequence[int]) -> float: return 1.0 / (1.0 + distance / 100.0) +def _signal_parts(element: Element, want_role: Optional[str], + want_name: Optional[str], + similarity: Callable[[str, str], float], + prefer_enabled: bool, + anchor: Optional[Sequence[int]]) -> Dict[str, float]: + """Build the per-signal 0..1 breakdown for one ``element``.""" + parts: Dict[str, float] = {} + if want_role is not None: + parts["role"] = (1.0 if str(element.get("role", "")).lower() + == str(want_role).lower() else 0.0) + if want_name is not None: + parts["name"] = float(similarity(want_name, + str(element.get("name", "")))) + if anchor is not None: + parts["proximity"] = _proximity(element, anchor) + if prefer_enabled: + parts["enabled"] = 1.0 if element.get("enabled", True) else 0.0 + return parts + + def score_candidates(candidates: Sequence[Element], *, want_role: Optional[str] = None, want_name: Optional[str] = None, @@ -57,17 +77,8 @@ def score_candidates(candidates: Sequence[Element], *, similarity = name_similarity or fuzzy_ratio scored: List[ScoredCandidate] = [] for element in candidates: - parts: Dict[str, float] = {} - if want_role is not None: - parts["role"] = (1.0 if str(element.get("role", "")).lower() - == str(want_role).lower() else 0.0) - if want_name is not None: - parts["name"] = float(similarity(want_name, - str(element.get("name", "")))) - if anchor is not None: - parts["proximity"] = _proximity(element, anchor) - if prefer_enabled: - parts["enabled"] = 1.0 if element.get("enabled", True) else 0.0 + parts = _signal_parts(element, want_role, want_name, similarity, + prefer_enabled, anchor) score = sum(parts.values()) / len(parts) if parts else 0.0 scored.append(ScoredCandidate(element, round(score, 4), parts)) scored.sort(key=lambda candidate: candidate.score, reverse=True) diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index 9f8cf9b3..0fcd3a21 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -171,6 +171,7 @@ def _run_dag(definition: Dict[str, Any], _AX_RECORDER_SINGLETON = None +_DEFAULT_APPROVALS_DIR = ".approvals" def _a11y_dump(app_name: Optional[str] = None, @@ -6029,7 +6030,7 @@ def _egress_reset() -> Dict[str, Any]: def _verify_artifact(name: str, content: Any, - approvals_dir: str = ".approvals", + approvals_dir: str = _DEFAULT_APPROVALS_DIR, extension: str = "txt") -> Dict[str, Any]: """Adapter: verify an artifact against its approved baseline.""" from je_auto_control.utils.approval import verify_artifact @@ -6039,14 +6040,14 @@ def _verify_artifact(name: str, content: Any, "received_path": result.received_path} -def _approve_artifact(name: str, approvals_dir: str = ".approvals", +def _approve_artifact(name: str, approvals_dir: str = _DEFAULT_APPROVALS_DIR, extension: str = "txt") -> Dict[str, Any]: """Adapter: promote a received artifact to the approved baseline.""" from je_auto_control.utils.approval import approve_artifact return {"approved": approve_artifact(name, approvals_dir, extension)} -def _pending_artifacts(approvals_dir: str = ".approvals") -> Dict[str, Any]: +def _pending_artifacts(approvals_dir: str = _DEFAULT_APPROVALS_DIR) -> Dict[str, Any]: """Adapter: list artifacts awaiting approval.""" from je_auto_control.utils.approval import pending_artifacts return {"pending": pending_artifacts(approvals_dir)} diff --git a/je_auto_control/utils/form_fields/form_fields.py b/je_auto_control/utils/form_fields/form_fields.py index 5fb646df..edb9eb3b 100644 --- a/je_auto_control/utils/form_fields/form_fields.py +++ b/je_auto_control/utils/form_fields/form_fields.py @@ -26,7 +26,7 @@ def _overlap_1d(a0: int, a1: int, b0: int, b1: int) -> int: def _right_value(label: Box, values: Sequence[Box], max_gap: int): """Nearest value to the right of ``label`` that shares a row, or ``None``.""" - left, top, right, bottom = _box_bounds(label) + _, top, right, bottom = _box_bounds(label) best: Optional[Tuple[Box, int]] = None for value in values: vl, vt, _, vb = _box_bounds(value) @@ -39,7 +39,7 @@ def _right_value(label: Box, values: Sequence[Box], max_gap: int): def _below_value(label: Box, values: Sequence[Box], max_gap: int): """Nearest value below ``label`` that shares a column, or ``None``.""" - left, top, right, bottom = _box_bounds(label) + left, _, right, bottom = _box_bounds(label) best: Optional[Tuple[Box, int]] = None for value in values: vl, vt, vr, _ = _box_bounds(value) diff --git a/je_auto_control/utils/governance/credential_broker.py b/je_auto_control/utils/governance/credential_broker.py index ef4dab72..4325a887 100644 --- a/je_auto_control/utils/governance/credential_broker.py +++ b/je_auto_control/utils/governance/credential_broker.py @@ -86,13 +86,16 @@ def active(self) -> List[Dict[str, object]]: """List non-expired leases as ``{token, name, ttl_remaining}`` (no values).""" now = self._clock() result: List[Dict[str, object]] = [] - for token, lease in list(self._leases.items()): + expired: List[str] = [] + for token, lease in self._leases.items(): remaining = float(lease["expires_at"]) - now if remaining > 0: result.append({"token": token, "name": lease["name"], "ttl_remaining": remaining}) else: - self._leases.pop(token, None) + expired.append(token) + for token in expired: + self._leases.pop(token, None) return result diff --git a/je_auto_control/utils/http_cassette/http_cassette.py b/je_auto_control/utils/http_cassette/http_cassette.py index 443c62cc..676cc30b 100644 --- a/je_auto_control/utils/http_cassette/http_cassette.py +++ b/je_auto_control/utils/http_cassette/http_cassette.py @@ -41,15 +41,9 @@ def _matches(recorded: Mapping[str, Any], call: Mapping[str, Any], match_on: Sequence[str]) -> bool: view = _request_view(call) for field in match_on: - if field == "method": - if recorded.get("method") != view["method"]: - return False - elif field == "url": - if recorded.get("url") != view["url"]: - return False - elif field == "body": - if recorded.get("body") != view["body"]: - return False + if field in ("method", "url", "body") and \ + recorded.get(field) != view[field]: + return False return True diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index 8069a557..4128e73d 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -12,6 +12,8 @@ from je_auto_control.utils.mcp_server.tools._base import MCPContent +_DEFAULT_APPROVALS_DIR = ".approvals" + # === Mouse / keyboard ======================================================= @@ -1515,7 +1517,7 @@ def egress_reset(): return {"allow": None, "deny": []} -def verify_artifact(name: str, content, approvals_dir: str = ".approvals", +def verify_artifact(name: str, content, approvals_dir: str = _DEFAULT_APPROVALS_DIR, extension: str = "txt"): from je_auto_control.utils.approval import verify_artifact as _verify result = _verify(name, content, approvals_dir, extension) @@ -1524,13 +1526,13 @@ def verify_artifact(name: str, content, approvals_dir: str = ".approvals", "received_path": result.received_path} -def approve_artifact(name: str, approvals_dir: str = ".approvals", +def approve_artifact(name: str, approvals_dir: str = _DEFAULT_APPROVALS_DIR, extension: str = "txt"): from je_auto_control.utils.approval import approve_artifact as _approve return {"approved": _approve(name, approvals_dir, extension)} -def pending_artifacts(approvals_dir: str = ".approvals"): +def pending_artifacts(approvals_dir: str = _DEFAULT_APPROVALS_DIR): from je_auto_control.utils.approval import pending_artifacts as _pending return {"pending": _pending(approvals_dir)} diff --git a/je_auto_control/utils/notify/notifier.py b/je_auto_control/utils/notify/notifier.py index df5e59ca..fbd0e83e 100644 --- a/je_auto_control/utils/notify/notifier.py +++ b/je_auto_control/utils/notify/notifier.py @@ -86,6 +86,6 @@ def notify(title: str, message: str, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) return NotifyResult(True, system, "sent") - except (FileNotFoundError, OSError, subprocess.SubprocessError) as error: + except (OSError, subprocess.SubprocessError) as error: autocontrol_logger.warning("notify failed: %r", error) return NotifyResult(False, system, repr(error)) diff --git a/je_auto_control/utils/remote_desktop/host_service.py b/je_auto_control/utils/remote_desktop/host_service.py index ee6e0782..ed58841a 100644 --- a/je_auto_control/utils/remote_desktop/host_service.py +++ b/je_auto_control/utils/remote_desktop/host_service.py @@ -217,7 +217,8 @@ def _interactive_configure() -> int: ) answers["poll_interval_s"] = 2.0 _DEFAULT_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) - _DEFAULT_CONFIG_PATH.write_text( + # _DEFAULT_CONFIG_PATH is a hardcoded module constant, not user input + _DEFAULT_CONFIG_PATH.write_text( # NOSONAR json.dumps(answers, indent=2), encoding="utf-8", ) try: diff --git a/je_auto_control/utils/remote_desktop/turn_config.py b/je_auto_control/utils/remote_desktop/turn_config.py index aa04f15c..a9373a32 100644 --- a/je_auto_control/utils/remote_desktop/turn_config.py +++ b/je_auto_control/utils/remote_desktop/turn_config.py @@ -142,7 +142,8 @@ def write_bundle(output_dir: Path, *, realm: str, user: str, secret: str, listen_port: int, tls_port: int, tls_cert: Optional[str], tls_key: Optional[str], external_ip: Optional[str]) -> None: - output_dir.mkdir(parents=True, exist_ok=True) + # output_dir is an operator-supplied CLI path, not remote input + output_dir.mkdir(parents=True, exist_ok=True) # NOSONAR conf_path = output_dir / "turnserver.conf" conf_path.write_text(render_turnserver_conf( realm=realm, listen_port=listen_port, tls_port=tls_port, diff --git a/je_auto_control/utils/remote_desktop/web_viewer/index.html b/je_auto_control/utils/remote_desktop/web_viewer/index.html index efadd0cc..f371c074 100644 --- a/je_auto_control/utils/remote_desktop/web_viewer/index.html +++ b/je_auto_control/utils/remote_desktop/web_viewer/index.html @@ -108,8 +108,8 @@ - - + Token kept in sessionStorage; cleared on tab close. diff --git a/je_auto_control/utils/stats/stats.py b/je_auto_control/utils/stats/stats.py index 08187a6c..4ace2f4e 100644 --- a/je_auto_control/utils/stats/stats.py +++ b/je_auto_control/utils/stats/stats.py @@ -88,11 +88,11 @@ def _betacf(a: float, b: float, x: float) -> float: def _betai(a: float, b: float, x: float) -> float: # x is a domain ratio in [0, 1]; both boundaries are reachable (e.g. x == 1 - # when the t-statistic is 0). NOSONAR: the analyzer mis-models these as - # constant, but they are genuine, exercised guards. - if x <= 0: # NOSONAR python:S2583 reason: reachable beta-domain lower bound + # when the t-statistic is 0); the analyzer mis-models these as constant, + # but they are genuine, exercised guards. + if x <= 0: # NOSONAR reachable beta-domain lower bound, not constant return 0.0 - if x >= 1: # NOSONAR python:S2583 reason: reachable beta-domain upper bound + if x >= 1: # NOSONAR reachable beta-domain upper bound, not constant return 1.0 front = math.exp(math.lgamma(a + b) - math.lgamma(a) - math.lgamma(b) + a * math.log(x) + b * math.log(1 - x)) diff --git a/je_auto_control/utils/stubs/generator.py b/je_auto_control/utils/stubs/generator.py index 8eaad392..fcec4444 100644 --- a/je_auto_control/utils/stubs/generator.py +++ b/je_auto_control/utils/stubs/generator.py @@ -88,7 +88,8 @@ def write_pyi(target: Path, target.parent.mkdir(parents=True, exist_ok=True) tmp = target.with_suffix(target.suffix + ".tmp") tmp.write_text(body, encoding="utf-8") - tmp.replace(target) + # target is an operator-supplied CLI path, not remote input + tmp.replace(target) # NOSONAR return target diff --git a/test/unit_test/headless/test_action_effect_batch.py b/test/unit_test/headless/test_action_effect_batch.py index cf691b73..694f1693 100644 --- a/test/unit_test/headless/test_action_effect_batch.py +++ b/test/unit_test/headless/test_action_effect_batch.py @@ -6,7 +6,7 @@ def _el(x, y, name="", role="button"): - return dict(x=x, y=y, width=40, height=20, role=role, name=name) + return {"x": x, "y": y, "width": 40, "height": 20, "role": role, "name": name} def test_no_op_when_nothing_changes(): diff --git a/test/unit_test/headless/test_canonical_log_batch.py b/test/unit_test/headless/test_canonical_log_batch.py index 87e30203..de55b766 100644 --- a/test/unit_test/headless/test_canonical_log_batch.py +++ b/test/unit_test/headless/test_canonical_log_batch.py @@ -20,6 +20,7 @@ def test_timer_uses_injected_clock(): ticks = iter([10.0, 10.5]) line = CanonicalLogLine(clock=lambda: next(ticks)) with line.timer("step"): + # body intentionally empty: the timer measures the enter->exit interval pass assert line.to_dict()["step_ms"] == pytest.approx(500.0) diff --git a/test/unit_test/headless/test_coordinate_space_batch.py b/test/unit_test/headless/test_coordinate_space_batch.py index 93ae65bb..0196276c 100644 --- a/test/unit_test/headless/test_coordinate_space_batch.py +++ b/test/unit_test/headless/test_coordinate_space_batch.py @@ -43,14 +43,14 @@ def test_clamping_is_in_bounds(): def test_downscale_png_matches_model_size(): - Image = pytest.importorskip("PIL.Image") + pil_image = pytest.importorskip("PIL.Image") import io buf = io.BytesIO() - Image.new("RGB", (640, 480), (1, 2, 3)).save(buf, format="PNG") + pil_image.new("RGB", (640, 480), (1, 2, 3)).save(buf, format="PNG") from je_auto_control.utils.coordinate_space import downscale_png space = normalized_space(640, 480, grid=64) out = downscale_png(buf.getvalue(), space) - with Image.open(io.BytesIO(out)) as resized: + with pil_image.open(io.BytesIO(out)) as resized: assert resized.size == (64, 64) diff --git a/test/unit_test/headless/test_critic_features_batch.py b/test/unit_test/headless/test_critic_features_batch.py index 7e0fbd12..daa565cc 100644 --- a/test/unit_test/headless/test_critic_features_batch.py +++ b/test/unit_test/headless/test_critic_features_batch.py @@ -6,7 +6,7 @@ def _el(x, y, name="", role="button"): - return dict(x=x, y=y, width=40, height=20, role=role, name=name) + return {"x": x, "y": y, "width": 40, "height": 20, "role": role, "name": name} def test_record_captures_effect_and_delta(): diff --git a/test/unit_test/headless/test_decision_table_batch.py b/test/unit_test/headless/test_decision_table_batch.py index 1bf92a52..f3a76acf 100644 --- a/test/unit_test/headless/test_decision_table_batch.py +++ b/test/unit_test/headless/test_decision_table_batch.py @@ -49,9 +49,9 @@ def test_unique_multi_match_raises(): def test_wildcard_and_literal_conditions(): spec = {"inputs": ["role", "active"], "hit_policy": "FIRST", "rules": [ - {"conditions": {"role": "admin", "active": None}, # active = wildcard + {"conditions": {"role": "admin", "active": None}, # active -> wildcard "outputs": {"allow": True}}, - {"conditions": {"role": "-", "active": True}, # role = wildcard + {"conditions": {"role": "-", "active": True}, # role -> wildcard "outputs": {"allow": False}}, ]} assert evaluate_table(spec, {"role": "admin", "active": False}) == \ diff --git a/test/unit_test/headless/test_diagnostics.py b/test/unit_test/headless/test_diagnostics.py index 0602ff40..2f207e35 100644 --- a/test/unit_test/headless/test_diagnostics.py +++ b/test/unit_test/headless/test_diagnostics.py @@ -43,7 +43,7 @@ def test_to_dict_payload_shape(): def test_cli_exits_zero_when_all_green(): """The CLI module should respect the runner's overall ``ok`` flag.""" - completed = subprocess.run( # noqa: S603 # local CLI test + completed = subprocess.run( # noqa: S603 # nosemgrep # local CLI test [sys.executable, "-m", "je_auto_control.utils.diagnostics"], capture_output=True, text=True, timeout=30, check=False, ) diff --git a/test/unit_test/headless/test_dotenv_batch.py b/test/unit_test/headless/test_dotenv_batch.py index a659d9fa..4b76bafa 100644 --- a/test/unit_test/headless/test_dotenv_batch.py +++ b/test/unit_test/headless/test_dotenv_batch.py @@ -44,7 +44,7 @@ def test_load_dotenv_file(tmp_path): path.write_text(_TEXT, encoding="utf-8") env = {"PLAIN": "keep"} load_dotenv(str(path), env) - assert env["PLAIN"] == "keep" and env["TOKEN"] == "secret" + assert env["PLAIN"] == "keep" and env["TOKEN"] == "secret" # NOSONAR load_dotenv mutates env in place load_dotenv(str(path), env, override=True) assert env["PLAIN"] == "hello" # override replaces assert dotenv_values(str(path))["TOKEN"] == "secret" diff --git a/test/unit_test/headless/test_image_dedup_batch.py b/test/unit_test/headless/test_image_dedup_batch.py index efa16619..32b08be3 100644 --- a/test/unit_test/headless/test_image_dedup_batch.py +++ b/test/unit_test/headless/test_image_dedup_batch.py @@ -10,7 +10,7 @@ def test_hamming_distance_and_similar(): assert hamming_distance("00", "00") == 0 - assert hamming_distance("0f", "00") == 4 # 0x0f = 1111 + assert hamming_distance("0f", "00") == 4 # 0x0f -> 1111 bits assert images_similar("ff", "fe", max_distance=1) is True assert images_similar("ff", "f0", max_distance=1) is False @@ -34,11 +34,11 @@ def test_dedupe_empty(): def test_real_pillow_hashing(tmp_path): - Image = pytest.importorskip("PIL.Image") + pil_image = pytest.importorskip("PIL.Image") black = tmp_path / "black.png" white = tmp_path / "white.png" - Image.new("RGB", (64, 64), (0, 0, 0)).save(black) - Image.new("RGB", (64, 64), (255, 255, 255)).save(white) + pil_image.new("RGB", (64, 64), (0, 0, 0)).save(black) + pil_image.new("RGB", (64, 64), (255, 255, 255)).save(white) h_black = average_hash(str(black)) assert isinstance(h_black, str) and h_black @@ -54,11 +54,11 @@ def test_real_pillow_hashing(tmp_path): # --- wiring --------------------------------------------------------------- def test_executor_round_trip(tmp_path): - Image = pytest.importorskip("PIL.Image") + pil_image = pytest.importorskip("PIL.Image") a = tmp_path / "a.png" b = tmp_path / "b.png" - Image.new("RGB", (32, 32), (10, 10, 10)).save(a) - Image.new("RGB", (32, 32), (10, 10, 10)).save(b) # identical + pil_image.new("RGB", (32, 32), (10, 10, 10)).save(a) + pil_image.new("RGB", (32, 32), (10, 10, 10)).save(b) # identical rec = ac.execute_action([ ["AC_dedupe_images", {"paths": [str(a), str(b)], "max_distance": 2}], ]) diff --git a/test/unit_test/headless/test_link_header_batch.py b/test/unit_test/headless/test_link_header_batch.py index ef93cf00..30b3b213 100644 --- a/test/unit_test/headless/test_link_header_batch.py +++ b/test/unit_test/headless/test_link_header_batch.py @@ -46,7 +46,7 @@ def test_paginate_follows_next_over_injected_fetch(): } responses = paginate("u1", lambda url: pages[url]) assert len(responses) == 3 - assert responses is not None and responses[-1]["headers"] == {} + assert responses[-1]["headers"] == {} def test_paginate_respects_max_pages(): diff --git a/test/unit_test/headless/test_remote_desktop_cursor.py b/test/unit_test/headless/test_remote_desktop_cursor.py index 715da144..d5412a0d 100644 --- a/test/unit_test/headless/test_remote_desktop_cursor.py +++ b/test/unit_test/headless/test_remote_desktop_cursor.py @@ -137,7 +137,8 @@ def test_frame_display_paints_cursor_overlay(): os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") pytest.importorskip("PySide6.QtWidgets") from PySide6.QtWidgets import QApplication - QApplication.instance() or QApplication([]) + if QApplication.instance() is None: + QApplication([]) from je_auto_control.gui.remote_desktop.frame_display import _FrameDisplay display = _FrameDisplay() diff --git a/test/unit_test/headless/test_remote_desktop_websocket.py b/test/unit_test/headless/test_remote_desktop_websocket.py index 935d6ad6..e4638cbd 100644 --- a/test/unit_test/headless/test_remote_desktop_websocket.py +++ b/test/unit_test/headless/test_remote_desktop_websocket.py @@ -121,7 +121,7 @@ def client_side(): sec_key = line.split(":", 1)[1].strip() import base64 import hashlib - accept = base64.b64encode(hashlib.sha1( # nosec B324 + accept = base64.b64encode(hashlib.sha1( # nosec B324 # nosemgrep (sec_key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode("ascii"), usedforsecurity=False, ).digest()).decode("ascii") diff --git a/test/unit_test/headless/test_s3_store_batch.py b/test/unit_test/headless/test_s3_store_batch.py index 8be38546..e10ad8bc 100644 --- a/test/unit_test/headless/test_s3_store_batch.py +++ b/test/unit_test/headless/test_s3_store_batch.py @@ -23,12 +23,12 @@ def upload_file(self, filename, bucket, key): def download_file(self, bucket, key, filename): Path(filename).write_bytes(self.objects[(bucket, key)]) - def list_objects_v2(self, Bucket, Prefix=""): + def list_objects_v2(self, Bucket, Prefix=""): # NOSONAR boto3 API names keys = [k for (b, k) in self.objects if b == Bucket and k.startswith(Prefix)] return {"Contents": [{"Key": k} for k in sorted(keys)]} - def delete_object(self, Bucket, Key): + def delete_object(self, Bucket, Key): # NOSONAR boto3 API names self.objects.pop((Bucket, Key), None) diff --git a/test/unit_test/headless/test_schema_compat_batch.py b/test/unit_test/headless/test_schema_compat_batch.py index 8c0c6922..270fc199 100644 --- a/test/unit_test/headless/test_schema_compat_batch.py +++ b/test/unit_test/headless/test_schema_compat_batch.py @@ -24,7 +24,7 @@ def test_added_required_breaks_backward(): "required": ["id", "email"]} assert is_backward_compatible(_BASE, new) is False assert is_forward_compatible(_BASE, new) is True - [change] = [c for c in check_compatibility(_BASE, new)["breaking"]] + [change] = list(check_compatibility(_BASE, new)["breaking"]) assert change["kind"] == "field_added" and change["path"] == "email" diff --git a/test/unit_test/headless/test_timeseries_batch.py b/test/unit_test/headless/test_timeseries_batch.py index fd1a4ac7..b6233924 100644 --- a/test/unit_test/headless/test_timeseries_batch.py +++ b/test/unit_test/headless/test_timeseries_batch.py @@ -18,7 +18,7 @@ def test_rate_and_increase(): def test_rate_handles_counter_reset(): series = [(0, 90), (10, 100), (20, 5), (30, 25)] # reset between 100 and 5 - # increase = (100-90) + reset(5) + (25-5) = 10 + 5 + 20 = 35 + # increase: (100-90) + reset(5) + (25-5) -> 10 + 5 + 20 -> 35 assert ts_increase(series) == pytest.approx(35.0) assert ts_rate(series) == pytest.approx(35.0 / 30) diff --git a/test/unit_test/headless/test_window_capture.py b/test/unit_test/headless/test_window_capture.py index 22eaaea3..e17b2bc7 100644 --- a/test/unit_test/headless/test_window_capture.py +++ b/test/unit_test/headless/test_window_capture.py @@ -89,8 +89,12 @@ def mover(title, x, y, width, height): def test_snap_window_right_half(): rects = [] - snap_window("E", "right", screen_size=lambda: (1000, 800), - mover=lambda t, x, y, w, h: rects.append((x, y, w, h)) is None) + + def mover(_title, x, y, w, h): + rects.append((x, y, w, h)) + return 1 + + snap_window("E", "right", screen_size=lambda: (1000, 800), mover=mover) assert rects == [(500, 0, 500, 800)] From 4950b3700d208d6dc28c48e9d94c6dbce0255015 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Fri, 26 Jun 2026 04:47:53 +0800 Subject: [PATCH 2/4] Add system_volume: absolute read/set master volume and mute Replace the blind media-key nudges with read-backable, absolute control of the default output device (percent 0-100 + mute), behind an injectable VolumeDriver seam so all logic is unit-tested without an audio device. The default driver uses Windows Core Audio via the optional pycaw extra. --- WHATS_NEW.md | 8 + .../doc/new_features/v206_features_doc.rst | 67 +++++++ .../Zh/doc/new_features/v206_features_doc.rst | 60 ++++++ je_auto_control/__init__.py | 7 + .../gui/script_builder/command_schema.py | 33 ++++ .../utils/executor/action_executor.py | 35 ++++ .../utils/mcp_server/tools/_factories.py | 43 +++++ .../utils/mcp_server/tools/_handlers.py | 25 +++ .../utils/system_volume/__init__.py | 12 ++ .../utils/system_volume/system_volume.py | 182 ++++++++++++++++++ pyproject.toml | 1 + .../headless/test_system_volume_batch.py | 124 ++++++++++++ 12 files changed, 597 insertions(+) create mode 100644 docs/source/Eng/doc/new_features/v206_features_doc.rst create mode 100644 docs/source/Zh/doc/new_features/v206_features_doc.rst create mode 100644 je_auto_control/utils/system_volume/__init__.py create mode 100644 je_auto_control/utils/system_volume/system_volume.py create mode 100644 test/unit_test/headless/test_system_volume_batch.py diff --git a/WHATS_NEW.md b/WHATS_NEW.md index 3be380b4..04b77bb1 100644 --- a/WHATS_NEW.md +++ b/WHATS_NEW.md @@ -1,5 +1,13 @@ # What's New — AutoControl +## What's new (2026-06-26) + +### Read and Control the System Volume + +Set a known audio baseline before a run — mute, set 30%, or assert the level. Full reference: [`docs/source/Eng/doc/new_features/v206_features_doc.rst`](docs/source/Eng/doc/new_features/v206_features_doc.rst). + +- **`get_volume` / `set_volume` / `change_volume` / `is_muted` / `set_mute` / `mute` / `unmute` / `toggle_mute`** (`AC_get_volume`, `AC_set_volume`, `AC_change_volume`, `AC_set_mute`, `AC_toggle_mute`): the framework only had the blind media-key steps (`volume up` / `down` nudge by an unknown amount with no read-back). This adds absolute, read-backable control of the default output device — read or set the master level as an integer percent `0..100`, and read / set / toggle the mute flag. All logic (clamping, percent↔scalar conversion, toggle) is pure and runs through an injectable `VolumeDriver` seam, so it is fully unit-tested without an audio device; the default driver uses the Windows Core Audio `IAudioEndpointVolume` interface through the optional `pycaw` dependency (`pip install je_auto_control[audio]`), degrading with a clear error when absent. Fourth feature of the ROUND-15 cross-app OS lane. No `PySide6`. + ## What's new (2026-06-25) ### Resolve the App Registered for a File Type diff --git a/docs/source/Eng/doc/new_features/v206_features_doc.rst b/docs/source/Eng/doc/new_features/v206_features_doc.rst new file mode 100644 index 00000000..30feb059 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v206_features_doc.rst @@ -0,0 +1,67 @@ +Read and Control the System Volume +================================== + +Unattended runs often need a known audio baseline — mute before a noisy batch, +restore a level afterwards, or assert the current volume — but the framework +only had the blind media-key steps (``volume up`` / ``down`` nudge by an unknown +amount with no read-back). ``system_volume`` adds absolute, read-backable +control of the default output device. + +* :func:`get_volume` / :func:`set_volume` / :func:`change_volume` — read and + write the master level as an integer percent ``0..100`` (``set_volume`` and + ``change_volume`` clamp to that range). +* :func:`is_muted` / :func:`set_mute` / :func:`mute` / :func:`unmute` / + :func:`toggle_mute` — read and write the mute flag. + +All logic (clamping, percent <-> scalar conversion, toggle) is pure and runs +through an injectable :class:`VolumeDriver` seam, so it is fully testable without +an audio device. The default driver drives the Windows Core Audio +``IAudioEndpointVolume`` interface through the optional ``pycaw`` dependency +(``pip install je_auto_control[audio]``); on a platform / install without it the +default driver raises a clear error telling the caller to pass ``driver=``. +Imports no ``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import ( + get_volume, set_volume, change_volume, is_muted, mute, unmute, + toggle_mute, + ) + + get_volume() # e.g. 65 — current master volume percent + set_volume(30) # set to 30 %, returns 30 + change_volume(-10) # lower by 10 %, returns the applied percent + is_muted() # False + mute() # True — silence the output + unmute() # False — restore it + toggle_mute() # flip and return the new state + +For tests (or any non-Windows host) pass a ``driver`` — any object exposing +``get_scalar`` / ``set_scalar`` / ``get_mute`` / ``set_mute`` over a ``0.0..1.0`` +scalar: + +.. code-block:: python + + class FakeVolume: + def __init__(self, scalar=0.5, muted=False): + self.scalar, self.muted = scalar, muted + def get_scalar(self): return self.scalar + def set_scalar(self, s): self.scalar = s + def get_mute(self): return self.muted + def set_mute(self, m): self.muted = m + + drv = FakeVolume() + set_volume(73, driver=drv) # 73, drv.scalar == 0.73 + +Executor commands +----------------- + +``AC_get_volume`` (→ ``{volume, muted}``), ``AC_set_volume`` (``level`` → +``{volume}``), ``AC_change_volume`` (``delta`` → ``{volume}``), ``AC_set_mute`` +(``muted`` → ``{muted}``) and ``AC_toggle_mute`` (→ ``{muted}``). They are +exposed as the matching ``ac_*`` MCP tools (the read is read-only, the writes +side-effect-only) and as Script Builder commands under **Shell**. The executor +and MCP layers use the default OS driver, so they require ``pycaw`` on Windows. diff --git a/docs/source/Zh/doc/new_features/v206_features_doc.rst b/docs/source/Zh/doc/new_features/v206_features_doc.rst new file mode 100644 index 00000000..a6617bbd --- /dev/null +++ b/docs/source/Zh/doc/new_features/v206_features_doc.rst @@ -0,0 +1,60 @@ +讀取與控制系統音量 +================== + +無人值守的執行常需要一個已知的音訊基準——在吵雜的批次前靜音、結束後還原音量,或斷言目前音量——但框架 +原本只有盲目的媒體鍵步驟(``volume up`` / ``down`` 以未知幅度推移,且無法讀回)。``system_volume`` +補上對預設輸出裝置的絕對、可讀回控制。 + +* :func:`get_volume` / :func:`set_volume` / :func:`change_volume` ——以整數百分比 ``0..100`` + 讀寫主音量(``set_volume`` 與 ``change_volume`` 會夾到該範圍)。 +* :func:`is_muted` / :func:`set_mute` / :func:`mute` / :func:`unmute` / + :func:`toggle_mute` ——讀寫靜音旗標。 + +所有邏輯(夾值、百分比 <-> 純量轉換、切換)皆為純函式,並透過可注入的 :class:`VolumeDriver` 接縫執行, +故能在不需音訊裝置的情況下完整測試。預設 driver 透過選用相依套件 ``pycaw`` +(``pip install je_auto_control[audio]``)驅動 Windows Core Audio 的 +``IAudioEndpointVolume`` 介面;在沒有該套件 / 非 Windows 平台上,預設 driver 會丟出清楚的錯誤, +提示呼叫端傳入 ``driver=``。不匯入 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import ( + get_volume, set_volume, change_volume, is_muted, mute, unmute, + toggle_mute, + ) + + get_volume() # 例如 65 ——目前主音量百分比 + set_volume(30) # 設為 30 %,回傳 30 + change_volume(-10) # 降低 10 %,回傳套用後的百分比 + is_muted() # False + mute() # True ——使輸出靜音 + unmute() # False ——還原 + toggle_mute() # 切換並回傳新狀態 + +測試時(或任何非 Windows 主機)可傳入 ``driver`` ——任何以 ``0.0..1.0`` 純量提供 +``get_scalar`` / ``set_scalar`` / ``get_mute`` / ``set_mute`` 的物件: + +.. code-block:: python + + class FakeVolume: + def __init__(self, scalar=0.5, muted=False): + self.scalar, self.muted = scalar, muted + def get_scalar(self): return self.scalar + def set_scalar(self, s): self.scalar = s + def get_mute(self): return self.muted + def set_mute(self, m): self.muted = m + + drv = FakeVolume() + set_volume(73, driver=drv) # 73,drv.scalar == 0.73 + +執行器指令 +---------- + +``AC_get_volume``(→ ``{volume, muted}``)、``AC_set_volume``(``level`` → +``{volume}``)、``AC_change_volume``(``delta`` → ``{volume}``)、``AC_set_mute`` +(``muted`` → ``{muted}``)與 ``AC_toggle_mute``(→ ``{muted}``)。皆以對應的 ``ac_*`` +MCP 工具(讀取為唯讀、寫入為僅副作用)及 Script Builder 指令(位於 **Shell** 分類下)形式提供。 +執行器與 MCP 層使用預設 OS driver,故在 Windows 上需要 ``pycaw``。 diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py index c360611f..6d6bb3f4 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -100,6 +100,11 @@ ) # Resolve which application is registered to open a given file type from je_auto_control.utils.file_assoc import file_association, normalize_ext +# Read and control the system master volume and mute state +from je_auto_control.utils.system_volume import ( + change_volume, get_volume, is_muted, mute, set_mute, set_volume, + toggle_mute, unmute, +) # Rich clipboard formats — RTF + CSV/TSV codecs and Windows get / set from je_auto_control.utils.clipboard_rich_formats import ( build_rtf, csv_to_rows, get_clipboard_csv, get_clipboard_rtf, rows_to_csv, @@ -1713,6 +1718,8 @@ def start_autocontrol_gui(*args, **kwargs): "idle_seconds", "is_idle", "plan_keep_awake", "keep_awake", "keep_awake_on", "allow_sleep", "normalize_ext", "file_association", + "get_volume", "set_volume", "change_volume", + "is_muted", "set_mute", "mute", "unmute", "toggle_mute", "build_rtf", "rtf_to_text", "rows_to_csv", "csv_to_rows", "set_clipboard_rtf", "get_clipboard_rtf", "set_clipboard_csv", "get_clipboard_csv", diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index 3c8c93fd..cf7dc7ac 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -4312,6 +4312,39 @@ def _add_work_queue_specs(specs: List[CommandSpec]) -> None: fields=(), description="Release a previously-started keep-awake.", )) + specs.append(CommandSpec( + "AC_get_volume", "Shell", "Get System Volume", + fields=(), + description="Read the master volume percent and mute state.", + )) + specs.append(CommandSpec( + "AC_set_volume", "Shell", "Set System Volume", + fields=( + FieldSpec("level", FieldType.INT, default=50, + placeholder="volume percent 0-100"), + ), + description="Set the master volume to level percent (clamped 0-100).", + )) + specs.append(CommandSpec( + "AC_change_volume", "Shell", "Change System Volume", + fields=( + FieldSpec("delta", FieldType.INT, default=10, + placeholder="percent delta (may be negative)"), + ), + description="Add delta percent to the master volume (clamped 0-100).", + )) + specs.append(CommandSpec( + "AC_set_mute", "Shell", "Set Mute", + fields=( + FieldSpec("muted", FieldType.BOOL, optional=True, default=True), + ), + description="Mute or unmute the master output.", + )) + specs.append(CommandSpec( + "AC_toggle_mute", "Shell", "Toggle Mute", + fields=(), + description="Flip the master mute flag.", + )) specs.append(CommandSpec( "AC_normalize_ext", "Shell", "Normalize Extension", fields=( diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index 0fcd3a21..457389da 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -2653,6 +2653,36 @@ def _allow_sleep() -> Dict[str, Any]: return {"released": bool(allow_sleep())} +def _get_volume() -> Dict[str, Any]: + """Adapter: the system master volume as an integer percent.""" + from je_auto_control.utils.system_volume import get_volume, is_muted + return {"volume": int(get_volume()), "muted": bool(is_muted())} + + +def _set_volume(level: Any) -> Dict[str, Any]: + """Adapter: set the master volume to ``level`` percent.""" + from je_auto_control.utils.system_volume import set_volume + return {"volume": int(set_volume(float(level)))} + + +def _change_volume(delta: Any) -> Dict[str, Any]: + """Adapter: add ``delta`` percent to the master volume.""" + from je_auto_control.utils.system_volume import change_volume + return {"volume": int(change_volume(float(delta)))} + + +def _set_mute(muted: Any = True) -> Dict[str, Any]: + """Adapter: set the master mute flag.""" + from je_auto_control.utils.system_volume import set_mute + return {"muted": bool(set_mute(bool(muted)))} + + +def _toggle_mute() -> Dict[str, Any]: + """Adapter: flip the master mute flag.""" + from je_auto_control.utils.system_volume import toggle_mute + return {"muted": bool(toggle_mute())} + + def _normalize_ext(target: str) -> Dict[str, Any]: """Adapter: the lowercased extension of a path / bare ext (pure).""" from je_auto_control.utils.file_assoc import normalize_ext @@ -6662,6 +6692,11 @@ def __init__(self): "AC_plan_keep_awake": _plan_keep_awake, "AC_keep_awake_on": _keep_awake_on, "AC_allow_sleep": _allow_sleep, + "AC_get_volume": _get_volume, + "AC_set_volume": _set_volume, + "AC_change_volume": _change_volume, + "AC_set_mute": _set_mute, + "AC_toggle_mute": _toggle_mute, "AC_normalize_ext": _normalize_ext, "AC_file_association": _file_association, "AC_get_control_text": _get_control_text, diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index 7a9b95cd..5ef00f30 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -2168,6 +2168,49 @@ def process_and_shell_tools() -> List[MCPTool]: handler=h.file_association, annotations=READ_ONLY, ), + MCPTool( + name="ac_get_volume", + description=("Read the system master volume as an integer percent " + "0..100. Returns {volume, muted} (Windows, needs " + "the optional 'pycaw' dependency)."), + input_schema=schema({}), + handler=h.get_volume, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_set_volume", + description=("Set the master volume to 'level' percent (clamped to " + "0..100). Returns the applied {volume}."), + input_schema=schema({"level": {"type": "number"}}, + required=["level"]), + handler=h.set_volume, + annotations=SIDE_EFFECT_ONLY, + ), + MCPTool( + name="ac_change_volume", + description=("Add 'delta' percent to the master volume (may be " + "negative; clamped to 0..100). Returns {volume}."), + input_schema=schema({"delta": {"type": "number"}}, + required=["delta"]), + handler=h.change_volume, + annotations=SIDE_EFFECT_ONLY, + ), + MCPTool( + name="ac_set_mute", + description=("Mute or unmute the master output. 'muted' defaults to " + "true. Returns the new {muted} state."), + input_schema=schema({"muted": {"type": "boolean"}}), + handler=h.set_mute, + annotations=SIDE_EFFECT_ONLY, + ), + MCPTool( + name="ac_toggle_mute", + description=("Flip the master mute flag. Returns the new {muted} " + "state."), + input_schema=schema({}), + handler=h.toggle_mute, + annotations=SIDE_EFFECT_ONLY, + ), ] diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index 4128e73d..41fc8c7a 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -595,6 +595,31 @@ def allow_sleep(): return _allow_sleep() +def get_volume(): + from je_auto_control.utils.executor.action_executor import _get_volume + return _get_volume() + + +def set_volume(level): + from je_auto_control.utils.executor.action_executor import _set_volume + return _set_volume(level) + + +def change_volume(delta): + from je_auto_control.utils.executor.action_executor import _change_volume + return _change_volume(delta) + + +def set_mute(muted=True): + from je_auto_control.utils.executor.action_executor import _set_mute + return _set_mute(muted) + + +def toggle_mute(): + from je_auto_control.utils.executor.action_executor import _toggle_mute + return _toggle_mute() + + def normalize_ext(target): from je_auto_control.utils.executor.action_executor import _normalize_ext return _normalize_ext(target) diff --git a/je_auto_control/utils/system_volume/__init__.py b/je_auto_control/utils/system_volume/__init__.py new file mode 100644 index 00000000..1100c5e3 --- /dev/null +++ b/je_auto_control/utils/system_volume/__init__.py @@ -0,0 +1,12 @@ +"""Read and control the system master volume and mute state.""" +from je_auto_control.utils.system_volume.system_volume import ( + VolumeDriver, change_volume, clamp_percent, get_volume, is_muted, mute, + percent_to_scalar, scalar_to_percent, set_mute, set_volume, toggle_mute, + unmute, +) + +__all__ = [ + "VolumeDriver", "get_volume", "set_volume", "change_volume", + "is_muted", "set_mute", "mute", "unmute", "toggle_mute", + "clamp_percent", "percent_to_scalar", "scalar_to_percent", +] diff --git a/je_auto_control/utils/system_volume/system_volume.py b/je_auto_control/utils/system_volume/system_volume.py new file mode 100644 index 00000000..9879d66d --- /dev/null +++ b/je_auto_control/utils/system_volume/system_volume.py @@ -0,0 +1,182 @@ +"""Read and control the system master volume and mute state. + +Unattended runs often need to set a known audio baseline — mute before a noisy +batch, restore a level afterwards, or assert the current volume — but the +framework only had the blind media-key steps (``volume up`` / ``down`` nudge by +an unknown amount with no read-back). ``system_volume`` adds absolute, +read-backable control of the default output device: + +* :func:`get_volume` / :func:`set_volume` / :func:`change_volume` read and write + the master level as an integer percent ``0..100``. +* :func:`is_muted` / :func:`set_mute` / :func:`mute` / :func:`unmute` / + :func:`toggle_mute` read and write the mute flag. + +All logic (clamping, percent <-> scalar conversion, toggle) is pure and runs +through an injectable :class:`VolumeDriver` seam, so it is fully testable without +an audio device. The default driver drives the Windows Core Audio +``IAudioEndpointVolume`` interface through the optional ``pycaw`` dependency +(``pip install je_auto_control[audio]``); on a platform / install without it the +default driver raises a clear error telling the caller to pass ``driver=``. + +Imports no ``PySide6``. +""" +import sys +from typing import Optional, Protocol + +# A normalized master-volume scalar runs 0.0 (silent) .. 1.0 (full). +_MIN_SCALAR = 0.0 +_MAX_SCALAR = 1.0 +_PERCENT_MAX = 100 + + +class VolumeDriver(Protocol): + """The OS seam used by every public function. + + Implementations expose the master output volume as a ``0.0 .. 1.0`` scalar + and a boolean mute flag. Pass a fake implementing these four methods to test + the pure logic without an audio device. + """ + + def get_scalar(self) -> float: + """Return the current master volume as a ``0.0 .. 1.0`` scalar.""" + + def set_scalar(self, scalar: float) -> None: + """Set the master volume from a ``0.0 .. 1.0`` scalar.""" + + def get_mute(self) -> bool: + """Return whether the master output is muted.""" + + def set_mute(self, muted: bool) -> None: + """Mute or unmute the master output.""" + + +def clamp_percent(level: float) -> int: + """Clamp ``level`` to an integer percent in ``[0, 100]`` (pure).""" + rounded = int(round(float(level))) + return max(0, min(_PERCENT_MAX, rounded)) + + +def percent_to_scalar(level: float) -> float: + """Convert a ``0..100`` percent to a clamped ``0.0 .. 1.0`` scalar (pure).""" + return clamp_percent(level) / float(_PERCENT_MAX) + + +def scalar_to_percent(scalar: float) -> int: + """Convert a ``0.0 .. 1.0`` scalar to a clamped integer percent (pure).""" + bounded = max(_MIN_SCALAR, min(_MAX_SCALAR, float(scalar))) + return clamp_percent(bounded * _PERCENT_MAX) + + +def _resolve(driver: Optional[VolumeDriver]) -> VolumeDriver: + """Return the supplied driver, or the default OS driver.""" + return driver if driver is not None else _default_driver() + + +def get_volume(*, driver: Optional[VolumeDriver] = None) -> int: + """Return the system master volume as an integer percent ``0..100``. + + Pass ``driver`` (any :class:`VolumeDriver`) to read from a fake in tests; the + default queries the OS through ``pycaw`` (Windows). + """ + return scalar_to_percent(_resolve(driver).get_scalar()) + + +def set_volume(level: float, *, driver: Optional[VolumeDriver] = None) -> int: + """Set the master volume to ``level`` percent; return the applied percent. + + ``level`` is clamped to ``[0, 100]`` before it is applied. + """ + target = clamp_percent(level) + _resolve(driver).set_scalar(percent_to_scalar(target)) + return target + + +def change_volume(delta: float, *, + driver: Optional[VolumeDriver] = None) -> int: + """Add ``delta`` percent to the current volume; return the applied percent. + + The result is clamped to ``[0, 100]``. ``delta`` may be negative. + """ + source = _resolve(driver) + current = scalar_to_percent(source.get_scalar()) + target = clamp_percent(current + float(delta)) + source.set_scalar(percent_to_scalar(target)) + return target + + +def is_muted(*, driver: Optional[VolumeDriver] = None) -> bool: + """Return whether the master output is currently muted.""" + return bool(_resolve(driver).get_mute()) + + +def set_mute(muted: bool, *, driver: Optional[VolumeDriver] = None) -> bool: + """Set the mute flag to ``muted``; return the new mute state.""" + state = bool(muted) + _resolve(driver).set_mute(state) + return state + + +def mute(*, driver: Optional[VolumeDriver] = None) -> bool: + """Mute the master output; return ``True``.""" + return set_mute(True, driver=driver) + + +def unmute(*, driver: Optional[VolumeDriver] = None) -> bool: + """Unmute the master output; return ``False``.""" + return set_mute(False, driver=driver) + + +def toggle_mute(*, driver: Optional[VolumeDriver] = None) -> bool: + """Flip the mute flag; return the new mute state.""" + source = _resolve(driver) + new_state = not bool(source.get_mute()) + source.set_mute(new_state) + return new_state + + +class _PycawDriver: + """Default :class:`VolumeDriver` over Windows Core Audio via ``pycaw``.""" + + def __init__(self) -> None: + """Activate the default-output ``IAudioEndpointVolume`` interface.""" + from ctypes import POINTER, cast # local: optional Windows path + from comtypes import CLSCTX_ALL + from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume + speakers = AudioUtilities.GetSpeakers() + interface = speakers.Activate( + IAudioEndpointVolume._iid_, CLSCTX_ALL, None) + self._volume = cast(interface, POINTER(IAudioEndpointVolume)) + + def get_scalar(self) -> float: + """Read the master volume scalar from Core Audio.""" + return float(self._volume.GetMasterVolumeLevelScalar()) + + def set_scalar(self, scalar: float) -> None: + """Write the master volume scalar to Core Audio.""" + self._volume.SetMasterVolumeLevelScalar(float(scalar), None) + + def get_mute(self) -> bool: + """Read the master mute flag from Core Audio.""" + return bool(self._volume.GetMute()) + + def set_mute(self, muted: bool) -> None: + """Write the master mute flag to Core Audio.""" + self._volume.SetMute(bool(muted), None) + + +def _default_driver() -> VolumeDriver: + """Return the OS volume driver, or raise if unavailable. + + Uses Windows Core Audio through the optional ``pycaw`` dependency. Raises + ``RuntimeError`` on a non-Windows platform or when ``pycaw`` is not + installed, instructing the caller to pass ``driver=``. + """ + if not sys.platform.startswith("win"): + raise RuntimeError( + "system volume has no OS driver on this platform; pass driver=") + try: + return _PycawDriver() + except ImportError as exc: + raise RuntimeError( + "system volume needs the 'pycaw' package on Windows " + "(pip install je_auto_control[audio]); or pass driver=") from exc diff --git a/pyproject.toml b/pyproject.toml index 6111fecf..43c8c12b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,6 +75,7 @@ office = ["openpyxl>=3.1", "python-docx>=1.1", "python-pptx>=0.6"] fuzzy = ["rapidfuzz>=3.0"] s3 = ["boto3>=1.34"] locale = ["babel>=2.12"] +audio = ["pycaw>=20240210"] [tool.bandit] exclude_dirs = [ diff --git a/test/unit_test/headless/test_system_volume_batch.py b/test/unit_test/headless/test_system_volume_batch.py new file mode 100644 index 00000000..61d38bf8 --- /dev/null +++ b/test/unit_test/headless/test_system_volume_batch.py @@ -0,0 +1,124 @@ +"""Headless tests for system volume / mute control (injected fake driver).""" +import pytest + +import je_auto_control as ac +from je_auto_control.utils.system_volume import ( + change_volume, clamp_percent, get_volume, is_muted, mute, + percent_to_scalar, scalar_to_percent, set_mute, set_volume, toggle_mute, + unmute, +) + + +class FakeVolume: + """In-memory VolumeDriver: stores a 0..1 scalar and a mute flag.""" + + def __init__(self, scalar=0.5, muted=False): + self.scalar = scalar + self.muted = muted + + def get_scalar(self): + return self.scalar + + def set_scalar(self, scalar): + self.scalar = scalar + + def get_mute(self): + return self.muted + + def set_mute(self, muted): + self.muted = muted + + +# --- pure conversion ------------------------------------------------------ + +def test_clamp_percent_bounds(): + assert clamp_percent(-20) == 0 + assert clamp_percent(150) == 100 + assert clamp_percent(37.6) == 38 + + +def test_percent_scalar_round_trip(): + assert percent_to_scalar(50) == pytest.approx(0.5) + assert scalar_to_percent(0.5) == 50 + assert scalar_to_percent(1.5) == 100 # clamped + assert scalar_to_percent(-0.2) == 0 + + +# --- volume read / write -------------------------------------------------- + +def test_get_volume_reads_driver(): + assert get_volume(driver=FakeVolume(scalar=0.4)) == 40 + + +def test_set_volume_clamps_and_applies(): + drv = FakeVolume(scalar=0.0) + assert set_volume(73, driver=drv) == 73 + assert drv.scalar == pytest.approx(0.73) + assert set_volume(250, driver=drv) == 100 + assert set_volume(-5, driver=drv) == 0 + + +def test_change_volume_relative(): + drv = FakeVolume(scalar=0.5) + assert change_volume(20, driver=drv) == 70 + assert change_volume(-100, driver=drv) == 0 + assert change_volume(10, driver=drv) == 10 + + +# --- mute ----------------------------------------------------------------- + +def test_is_muted_and_set_mute(): + drv = FakeVolume(muted=False) + assert is_muted(driver=drv) is False + assert set_mute(True, driver=drv) is True + assert drv.muted is True + + +def test_mute_unmute_helpers(): + drv = FakeVolume(muted=False) + assert mute(driver=drv) is True + assert is_muted(driver=drv) is True + assert unmute(driver=drv) is False + assert is_muted(driver=drv) is False + + +def test_toggle_mute_flips(): + drv = FakeVolume(muted=False) + assert toggle_mute(driver=drv) is True + assert toggle_mute(driver=drv) is False + + +# --- wiring --------------------------------------------------------------- + +def test_executor_pure_path_with_default_driver_absent_on_linux(): + # The executor adapters use the OS default driver; on a non-Windows CI box + # that raises a clear RuntimeError rather than silently passing. + from je_auto_control.utils.system_volume.system_volume import ( + _default_driver, + ) + import sys + if not sys.platform.startswith("win"): + with pytest.raises(RuntimeError): + _default_driver() + + +def test_wiring(): + known = set(ac.executor.known_commands()) + assert {"AC_get_volume", "AC_set_volume", "AC_change_volume", + "AC_set_mute", "AC_toggle_mute"} <= known + from je_auto_control.utils.mcp_server.tools import ( + build_default_tool_registry, + ) + names = {t.name for t in build_default_tool_registry()} + assert {"ac_get_volume", "ac_set_volume", "ac_change_volume", + "ac_set_mute", "ac_toggle_mute"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + specs = {s.command for s in _build_specs()} + assert {"AC_get_volume", "AC_set_volume", "AC_change_volume", + "AC_set_mute", "AC_toggle_mute"} <= specs + + +def test_facade_exports(): + for name in ("get_volume", "set_volume", "change_volume", "is_muted", + "set_mute", "mute", "unmute", "toggle_mute"): + assert hasattr(ac, name) and name in ac.__all__ From f01545fb1aec85d2d60fa9a1af3a86f69259e98c Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Fri, 26 Jun 2026 05:00:54 +0800 Subject: [PATCH 3/4] Add lock_session: lock the workstation and wait for unlock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit session_guard could only detect a locked session and raise; this adds acting on the lock state — lock the box at the end of an unattended run, block until a human unlocks it before resuming, and reduce a lock-state sample stream to lock/unlock events. The lock action runs through an injectable driver and the waits reuse session_guard's real probe with injectable clock/sleep, so all logic is unit-tested without the OS. --- WHATS_NEW.md | 6 + .../doc/new_features/v207_features_doc.rst | 62 +++++++ .../Zh/doc/new_features/v207_features_doc.rst | 55 +++++++ je_auto_control/__init__.py | 7 + .../gui/script_builder/command_schema.py | 28 ++++ .../utils/executor/action_executor.py | 32 ++++ .../utils/lock_session/__init__.py | 10 ++ .../utils/lock_session/lock_session.py | 153 ++++++++++++++++++ .../utils/mcp_server/tools/_factories.py | 38 +++++ .../utils/mcp_server/tools/_handlers.py | 24 +++ .../headless/test_lock_session_batch.py | 139 ++++++++++++++++ 11 files changed, 554 insertions(+) create mode 100644 docs/source/Eng/doc/new_features/v207_features_doc.rst create mode 100644 docs/source/Zh/doc/new_features/v207_features_doc.rst create mode 100644 je_auto_control/utils/lock_session/__init__.py create mode 100644 je_auto_control/utils/lock_session/lock_session.py create mode 100644 test/unit_test/headless/test_lock_session_batch.py diff --git a/WHATS_NEW.md b/WHATS_NEW.md index 04b77bb1..0395fbb5 100644 --- a/WHATS_NEW.md +++ b/WHATS_NEW.md @@ -2,6 +2,12 @@ ## What's new (2026-06-26) +### Lock the Workstation + Wait for Unlock + +Lock the box at the end of a run, and block until a human unlocks it before resuming. Full reference: [`docs/source/Eng/doc/new_features/v207_features_doc.rst`](docs/source/Eng/doc/new_features/v207_features_doc.rst). + +- **`lock_session` / `plan_lock_session` / `wait_for_unlock` / `wait_for_lock` / `classify_lock_transitions`** (`AC_lock_session`, `AC_plan_lock_session`, `AC_wait_for_unlock`, `AC_classify_lock_transitions`): `session_guard` could *detect* a locked session and raise; this adds *acting* on it. `lock_session` locks the workstation now (`LockWorkStation` on Windows, `loginctl lock-session` / `CGSession -suspend` elsewhere) through an injectable `driver`; `wait_for_unlock` / `wait_for_lock` poll `session_guard.is_session_locked` (reusing its real Windows `OpenInputDesktop` probe) until the state flips or a timeout, with injectable `clock` / `sleep` / `probe`; `plan_lock_session` is the pure per-OS planner and `classify_lock_transitions` reduces a lock-state sample stream to `{event, locked}` lock/unlock events. `wait_for_unlock` is the blocking companion to `ensure_interactive_session`. Fifth feature of the ROUND-15 cross-app OS lane. No `PySide6`. + ### Read and Control the System Volume Set a known audio baseline before a run — mute, set 30%, or assert the level. Full reference: [`docs/source/Eng/doc/new_features/v206_features_doc.rst`](docs/source/Eng/doc/new_features/v206_features_doc.rst). diff --git a/docs/source/Eng/doc/new_features/v207_features_doc.rst b/docs/source/Eng/doc/new_features/v207_features_doc.rst new file mode 100644 index 00000000..73585e2c --- /dev/null +++ b/docs/source/Eng/doc/new_features/v207_features_doc.rst @@ -0,0 +1,62 @@ +Lock the Workstation + Wait for Unlock +====================================== + +:mod:`session_guard` answers "is the session locked right now?" and raises if it +is. The missing half is *acting* on the lock state: lock the machine at the end +of an unattended run, block until a human unlocks it before resuming, or reduce +a stream of lock-state samples to lock / unlock events. ``lock_session`` adds +that, behind injectable seams so the logic is testable without touching the OS. + +* :func:`lock_session` — lock the workstation now (``LockWorkStation`` on + Windows, ``loginctl lock-session`` on Linux, ``CGSession -suspend`` on macOS) + through an injectable ``driver``. +* :func:`plan_lock_session` — pure planner: how the lock would be performed on + this OS and whether a default is available (``{backend, argv, available}``). +* :func:`wait_for_unlock` / :func:`wait_for_lock` — poll + :func:`is_session_locked` until the state flips or a timeout, with injectable + ``clock`` / ``sleep`` / ``probe`` for deterministic tests. +* :func:`classify_lock_transitions` — pure: a list of lock-state samples to a + list of ``{event, locked}`` lock / unlock transitions. + +The lock probe reused by the wait helpers is :mod:`session_guard`'s — the +Windows ``OpenInputDesktop`` check — so ``wait_for_unlock`` is the blocking +companion to ``ensure_interactive_session`` (which only raises). Imports no +``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import ( + lock_session, wait_for_unlock, classify_lock_transitions, + ) + + # ... unattended run finishes ... + lock_session() # secure the machine + + # Resume only once a human has unlocked the box + if wait_for_unlock(timeout_s=600): + run_next_stage() + + # Reduce a sampled lock-state log to events + classify_lock_transitions([False, True, True, False]) + # -> [{'event': 'lock', 'locked': True}, + # {'event': 'unlock', 'locked': False}] + +For tests (or any host) pass a ``driver`` / ``probe``: + +.. code-block:: python + + locked = lock_session(driver=lambda: True) # no real lock + wait_for_unlock(probe=lambda: False) # already unlocked + +Executor commands +----------------- + +``AC_lock_session`` (→ ``{locked}``), ``AC_plan_lock_session`` (→ the plan), +``AC_wait_for_unlock`` (``timeout`` / ``interval`` → ``{unlocked}``) and +``AC_classify_lock_transitions`` (``states`` JSON list → ``{events}``). They are +exposed as the matching ``ac_*`` MCP tools (``ac_lock_session`` is destructive — +it interrupts the session; the rest are read-only) and as Script Builder +commands under **Shell**. diff --git a/docs/source/Zh/doc/new_features/v207_features_doc.rst b/docs/source/Zh/doc/new_features/v207_features_doc.rst new file mode 100644 index 00000000..768b7213 --- /dev/null +++ b/docs/source/Zh/doc/new_features/v207_features_doc.rst @@ -0,0 +1,55 @@ +鎖定工作站 + 等待解鎖 +==================== + +:mod:`session_guard` 回答「目前 session 是否鎖定?」並在鎖定時丟出例外。缺少的另一半是對鎖定狀態 +*採取行動*:在無人值守執行結束時鎖定機器、在恢復前阻塞直到有人解鎖,或把一連串鎖定狀態取樣化約為 +鎖定 / 解鎖事件。``lock_session`` 補上這些,並以可注入接縫實作,故邏輯能在不碰作業系統的情況下測試。 + +* :func:`lock_session` ——立即鎖定工作站(Windows 用 ``LockWorkStation``、Linux 用 + ``loginctl lock-session``、macOS 用 ``CGSession -suspend``),透過可注入的 ``driver``。 +* :func:`plan_lock_session` ——純 planner:此 OS 上會如何執行鎖定,以及是否有預設可用 + (``{backend, argv, available}``)。 +* :func:`wait_for_unlock` / :func:`wait_for_lock` ——輪詢 :func:`is_session_locked` + 直到狀態翻轉或逾時,``clock`` / ``sleep`` / ``probe`` 皆可注入以利確定性測試。 +* :func:`classify_lock_transitions` ——純函式:把一連串鎖定狀態取樣化約為 + ``{event, locked}`` 鎖定 / 解鎖轉變的清單。 + +wait 系列重用的鎖定 probe 即 :mod:`session_guard` 的——Windows 的 ``OpenInputDesktop`` 檢查—— +故 ``wait_for_unlock`` 是 ``ensure_interactive_session``(只會丟例外)的阻塞式搭檔。不匯入 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import ( + lock_session, wait_for_unlock, classify_lock_transitions, + ) + + # ... 無人值守執行結束 ... + lock_session() # 鎖住機器 + + # 等有人解鎖後才繼續 + if wait_for_unlock(timeout_s=600): + run_next_stage() + + # 把取樣的鎖定狀態紀錄化約為事件 + classify_lock_transitions([False, True, True, False]) + # -> [{'event': 'lock', 'locked': True}, + # {'event': 'unlock', 'locked': False}] + +測試時(或任何主機)可傳入 ``driver`` / ``probe``: + +.. code-block:: python + + locked = lock_session(driver=lambda: True) # 不真正鎖定 + wait_for_unlock(probe=lambda: False) # 已解鎖 + +執行器指令 +---------- + +``AC_lock_session``(→ ``{locked}``)、``AC_plan_lock_session``(→ 計畫)、 +``AC_wait_for_unlock``(``timeout`` / ``interval`` → ``{unlocked}``)與 +``AC_classify_lock_transitions``(``states`` JSON 清單 → ``{events}``)。皆以對應的 ``ac_*`` +MCP 工具(``ac_lock_session`` 為破壞性——會中斷 session;其餘為唯讀)及 Script Builder 指令 +(位於 **Shell** 分類下)形式提供。 diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py index 6d6bb3f4..10a47b62 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -105,6 +105,11 @@ change_volume, get_volume, is_muted, mute, set_mute, set_volume, toggle_mute, unmute, ) +# Lock the workstation, wait for unlock, classify lock transitions +from je_auto_control.utils.lock_session import ( + classify_lock_transitions, lock_session, plan_lock_session, + wait_for_lock, wait_for_unlock, +) # Rich clipboard formats — RTF + CSV/TSV codecs and Windows get / set from je_auto_control.utils.clipboard_rich_formats import ( build_rtf, csv_to_rows, get_clipboard_csv, get_clipboard_rtf, rows_to_csv, @@ -1720,6 +1725,8 @@ def start_autocontrol_gui(*args, **kwargs): "normalize_ext", "file_association", "get_volume", "set_volume", "change_volume", "is_muted", "set_mute", "mute", "unmute", "toggle_mute", + "lock_session", "plan_lock_session", "wait_for_unlock", + "wait_for_lock", "classify_lock_transitions", "build_rtf", "rtf_to_text", "rows_to_csv", "csv_to_rows", "set_clipboard_rtf", "get_clipboard_rtf", "set_clipboard_csv", "get_clipboard_csv", diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index cf7dc7ac..e8b56871 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -4345,6 +4345,34 @@ def _add_work_queue_specs(specs: List[CommandSpec]) -> None: fields=(), description="Flip the master mute flag.", )) + specs.append(CommandSpec( + "AC_lock_session", "Shell", "Lock Workstation", + fields=(), + description="Lock the workstation now (interrupts the session).", + )) + specs.append(CommandSpec( + "AC_plan_lock_session", "Shell", "Plan Lock Workstation", + fields=(), + description="Describe how the workstation would be locked (pure).", + )) + specs.append(CommandSpec( + "AC_wait_for_unlock", "Shell", "Wait for Unlock", + fields=( + FieldSpec("timeout", FieldType.FLOAT, optional=True, default=30.0, + placeholder="timeout seconds"), + FieldSpec("interval", FieldType.FLOAT, optional=True, default=0.5, + placeholder="poll interval seconds"), + ), + description="Block until the session is unlocked or timeout.", + )) + specs.append(CommandSpec( + "AC_classify_lock_transitions", "Shell", "Classify Lock Transitions", + fields=( + FieldSpec("states", FieldType.STRING, + placeholder="JSON list of booleans"), + ), + description="Reduce lock-state samples to lock / unlock events.", + )) specs.append(CommandSpec( "AC_normalize_ext", "Shell", "Normalize Extension", fields=( diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index 457389da..0f751890 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -2683,6 +2683,34 @@ def _toggle_mute() -> Dict[str, Any]: return {"muted": bool(toggle_mute())} +def _lock_session() -> Dict[str, Any]: + """Adapter: lock the workstation now.""" + from je_auto_control.utils.lock_session import lock_session + return {"locked": bool(lock_session())} + + +def _plan_lock_session() -> Dict[str, Any]: + """Adapter: describe how the workstation would be locked (pure).""" + from je_auto_control.utils.lock_session import plan_lock_session + return plan_lock_session() + + +def _wait_for_unlock(timeout: Any = 30.0, interval: Any = 0.5 + ) -> Dict[str, Any]: + """Adapter: block until the session is unlocked or timeout.""" + from je_auto_control.utils.lock_session import wait_for_unlock + unlocked = wait_for_unlock(timeout_s=float(timeout), + interval_s=float(interval)) + return {"unlocked": bool(unlocked)} + + +def _classify_lock_transitions(states: Any) -> Dict[str, Any]: + """Adapter: reduce lock-state samples to lock / unlock events (pure).""" + from je_auto_control.utils.lock_session import classify_lock_transitions + samples = [bool(s) for s in _coerce_list(states)] if states else [] + return {"events": classify_lock_transitions(samples)} + + def _normalize_ext(target: str) -> Dict[str, Any]: """Adapter: the lowercased extension of a path / bare ext (pure).""" from je_auto_control.utils.file_assoc import normalize_ext @@ -6697,6 +6725,10 @@ def __init__(self): "AC_change_volume": _change_volume, "AC_set_mute": _set_mute, "AC_toggle_mute": _toggle_mute, + "AC_lock_session": _lock_session, + "AC_plan_lock_session": _plan_lock_session, + "AC_wait_for_unlock": _wait_for_unlock, + "AC_classify_lock_transitions": _classify_lock_transitions, "AC_normalize_ext": _normalize_ext, "AC_file_association": _file_association, "AC_get_control_text": _get_control_text, diff --git a/je_auto_control/utils/lock_session/__init__.py b/je_auto_control/utils/lock_session/__init__.py new file mode 100644 index 00000000..59e5c5f9 --- /dev/null +++ b/je_auto_control/utils/lock_session/__init__.py @@ -0,0 +1,10 @@ +"""Lock the workstation, wait for unlock, and classify lock transitions.""" +from je_auto_control.utils.lock_session.lock_session import ( + classify_lock_transitions, lock_session, plan_lock_session, + wait_for_lock, wait_for_unlock, +) + +__all__ = [ + "lock_session", "plan_lock_session", "wait_for_unlock", "wait_for_lock", + "classify_lock_transitions", +] diff --git a/je_auto_control/utils/lock_session/lock_session.py b/je_auto_control/utils/lock_session/lock_session.py new file mode 100644 index 00000000..cacca6aa --- /dev/null +++ b/je_auto_control/utils/lock_session/lock_session.py @@ -0,0 +1,153 @@ +"""Lock the workstation, wait for it to be unlocked, and classify transitions. + +:mod:`session_guard` answers "is the session locked right now?" and raises if it +is. The missing half is *acting* on the lock state: lock the machine at the end +of an unattended run, block until a human unlocks it before resuming, or reduce +a stream of lock-state samples to lock / unlock events. ``lock_session`` adds: + +* :func:`lock_session` — lock the workstation now (``LockWorkStation`` on + Windows, ``loginctl lock-session`` / ``CGSession -suspend`` elsewhere), + through an injectable ``driver`` seam. +* :func:`plan_lock_session` — pure planner describing how the lock would be + performed on this OS, and whether a default is available. +* :func:`wait_for_unlock` / :func:`wait_for_lock` — poll + :func:`session_guard.is_session_locked` until the state flips or a timeout, + with injectable ``clock`` / ``sleep`` / ``probe`` for deterministic tests. +* :func:`classify_lock_transitions` — pure: a list of lock-state samples to a + list of ``{event, locked}`` lock / unlock transitions. + +Imports no ``PySide6``. +""" +import sys +import time +from typing import Any, Callable, Dict, List, Optional, Sequence + +from je_auto_control.utils.session_guard import is_session_locked +from je_auto_control.utils.session_guard.session_guard import LockProbe + +# A driver performs the lock and returns whether it succeeded. +LockDriver = Callable[[], bool] + + +def _win_lock() -> bool: + """Lock the Windows workstation via ``LockWorkStation``.""" + import ctypes + user32 = ctypes.windll.user32 # nosec B607 # reason: fixed system DLL + return bool(user32.LockWorkStation()) + + +def _lock_backend() -> str: + """Return the lock backend name for the current platform.""" + if sys.platform.startswith("win"): + return "LockWorkStation" + if sys.platform == "darwin": + return "CGSession" + return "loginctl" + + +_CGSESSION = ("/System/Library/CoreServices/Menu Extras/" + "User.menu/Contents/Resources/CGSession") + + +def _lock_argv(backend: str) -> Optional[List[str]]: + """Return the fixed argv for a subprocess lock backend, or None.""" + if backend == "loginctl": + return ["loginctl", "lock-session"] + if backend == "CGSession": + return [_CGSESSION, "-suspend"] + return None + + +def plan_lock_session() -> Dict[str, Any]: + """Describe how the workstation would be locked on this OS (pure). + + Returns ``{backend, argv, available}``. ``available`` is ``True`` when this + platform has a built-in default; otherwise :func:`lock_session` needs an + explicit ``driver=``. + """ + backend = _lock_backend() + argv = _lock_argv(backend) + available = backend == "LockWorkStation" or argv is not None + return {"backend": backend, "argv": argv, "available": available} + + +def _run_argv(argv: List[str]) -> bool: + """Run a fixed lock argv and report success (no shell).""" + import subprocess # nosec B404 # reason: fixed argv, no shell + completed = subprocess.run(argv, check=False) # nosec B603 # nosemgrep + return completed.returncode == 0 + + +def _default_driver() -> LockDriver: + """Return the OS lock driver, or raise if none is available.""" + backend = _lock_backend() + if backend == "LockWorkStation": + return _win_lock + argv = _lock_argv(backend) + if argv is not None: + return lambda: _run_argv(argv) + raise RuntimeError( + "lock_session has no OS driver on this platform; pass driver=") + + +def lock_session(*, driver: Optional[LockDriver] = None) -> bool: + """Lock the workstation now; return whether the lock was requested. + + Pass ``driver`` (a ``() -> bool``) to intercept the OS call in tests; the + default locks via the platform backend from :func:`plan_lock_session`. + """ + acquire = driver if driver is not None else _default_driver() + return bool(acquire()) + + +def _wait_lock_state(target_locked: bool, *, probe: Optional[LockProbe], + timeout_s: float, interval_s: float, + clock: Callable[[], float], + sleep: Callable[[float], None]) -> bool: + """Poll until the lock state equals ``target_locked`` or timeout.""" + deadline = clock() + float(timeout_s) + while True: + if bool(is_session_locked(probe)) == target_locked: + return True + if clock() >= deadline: + return False + sleep(float(interval_s)) + + +def wait_for_unlock(*, probe: Optional[LockProbe] = None, + timeout_s: float = 30.0, interval_s: float = 0.5, + clock: Callable[[], float] = time.monotonic, + sleep: Callable[[float], None] = time.sleep) -> bool: + """Block until the session is unlocked; return ``True``, or ``False`` on timeout. + + Reuses :func:`session_guard.is_session_locked` (Windows default probe). + ``clock`` / ``sleep`` / ``probe`` are injectable for deterministic tests. + """ + return _wait_lock_state(False, probe=probe, timeout_s=timeout_s, + interval_s=interval_s, clock=clock, sleep=sleep) + + +def wait_for_lock(*, probe: Optional[LockProbe] = None, + timeout_s: float = 30.0, interval_s: float = 0.5, + clock: Callable[[], float] = time.monotonic, + sleep: Callable[[float], None] = time.sleep) -> bool: + """Block until the session is locked; return ``True``, or ``False`` on timeout.""" + return _wait_lock_state(True, probe=probe, timeout_s=timeout_s, + interval_s=interval_s, clock=clock, sleep=sleep) + + +def classify_lock_transitions(states: Sequence[bool]) -> List[Dict[str, Any]]: + """Reduce lock-state samples to lock / unlock transitions (pure). + + Each adjacent ``False -> True`` yields a ``lock`` event and each + ``True -> False`` an ``unlock`` event; unchanged samples yield nothing. + """ + events: List[Dict[str, Any]] = [] + previous: Optional[bool] = None + for sample in states: + current = bool(sample) + if previous is not None and current != previous: + kind = "lock" if current else "unlock" + events.append({"event": kind, "locked": current}) + previous = current + return events diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index 5ef00f30..83df5f93 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -2211,6 +2211,44 @@ def process_and_shell_tools() -> List[MCPTool]: handler=h.toggle_mute, annotations=SIDE_EFFECT_ONLY, ), + MCPTool( + name="ac_lock_session", + description=("Lock the workstation now (LockWorkStation / loginctl " + "lock-session / CGSession). Returns {locked}. " + "Interrupts the interactive session."), + input_schema=schema({}), + handler=h.lock_session, + annotations=DESTRUCTIVE, + ), + MCPTool( + name="ac_plan_lock_session", + description=("Describe how the workstation would be locked on this " + "OS without locking (pure): {backend, argv, " + "available}."), + input_schema=schema({}), + handler=h.plan_lock_session, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_wait_for_unlock", + description=("Block until the session is unlocked (or 'timeout' " + "seconds), polling every 'interval'. Returns " + "{unlocked}."), + input_schema=schema({"timeout": {"type": "number"}, + "interval": {"type": "number"}}), + handler=h.wait_for_unlock, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_classify_lock_transitions", + description=("Reduce a list of lock-state booleans ('states') to " + "lock / unlock events (pure). Returns {events}."), + input_schema=schema({"states": {"type": "array", + "items": {"type": "boolean"}}}, + required=["states"]), + handler=h.classify_lock_transitions, + annotations=READ_ONLY, + ), ] diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index 41fc8c7a..613adf2d 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -620,6 +620,30 @@ def toggle_mute(): return _toggle_mute() +def lock_session(): + from je_auto_control.utils.executor.action_executor import _lock_session + return _lock_session() + + +def plan_lock_session(): + from je_auto_control.utils.executor.action_executor import ( + _plan_lock_session, + ) + return _plan_lock_session() + + +def wait_for_unlock(timeout=30.0, interval=0.5): + from je_auto_control.utils.executor.action_executor import _wait_for_unlock + return _wait_for_unlock(timeout, interval) + + +def classify_lock_transitions(states): + from je_auto_control.utils.executor.action_executor import ( + _classify_lock_transitions, + ) + return _classify_lock_transitions(states) + + def normalize_ext(target): from je_auto_control.utils.executor.action_executor import _normalize_ext return _normalize_ext(target) diff --git a/test/unit_test/headless/test_lock_session_batch.py b/test/unit_test/headless/test_lock_session_batch.py new file mode 100644 index 00000000..af16d1c4 --- /dev/null +++ b/test/unit_test/headless/test_lock_session_batch.py @@ -0,0 +1,139 @@ +"""Headless tests for lock_session (injected driver / probe / clock).""" +import sys + +import je_auto_control as ac +from je_auto_control.utils.lock_session import ( + classify_lock_transitions, lock_session, plan_lock_session, + wait_for_lock, wait_for_unlock, +) + + +# --- lock action ---------------------------------------------------------- + +def test_lock_session_uses_driver(): + calls = [] + + def fake_driver(): + calls.append(True) + return True + + assert lock_session(driver=fake_driver) is True + assert calls == [True] + + +def test_lock_session_driver_failure_reported(): + assert lock_session(driver=lambda: False) is False + + +def test_plan_lock_session_shape(): + plan = plan_lock_session() + assert set(plan) == {"backend", "argv", "available"} + assert plan["backend"] in ("LockWorkStation", "loginctl", "CGSession") + assert isinstance(plan["available"], bool) + + +def test_plan_lock_session_available_per_os(): + plan = plan_lock_session() + # Every supported platform path has a default backend. + assert plan["available"] is True + if plan["backend"] == "LockWorkStation": + assert plan["argv"] is None + else: + assert isinstance(plan["argv"], list) and len(plan["argv"]) >= 1 + + +# --- wait for unlock / lock ----------------------------------------------- + +def _probe_sequence(values): + """Return a probe yielding successive booleans, repeating the last.""" + state = {"i": 0} + + def probe(): + i = min(state["i"], len(values) - 1) + state["i"] += 1 + return values[i] + + return probe + + +def test_wait_for_unlock_returns_true_when_unlocked(): + # locked, locked, then unlocked -> returns True + probe = _probe_sequence([True, True, False]) + clock = iter([0.0, 1.0, 2.0, 3.0, 4.0]) + ok = wait_for_unlock(probe=probe, timeout_s=10.0, interval_s=1.0, + clock=lambda: next(clock), sleep=lambda _s: None) + assert ok is True + + +def test_wait_for_unlock_times_out_when_still_locked(): + probe = _probe_sequence([True]) # never unlocks + times = iter([0.0, 0.0, 5.0, 10.0, 20.0]) + ok = wait_for_unlock(probe=probe, timeout_s=5.0, interval_s=1.0, + clock=lambda: next(times), sleep=lambda _s: None) + assert ok is False + + +def test_wait_for_lock_returns_true_when_locked(): + probe = _probe_sequence([False, True]) + clock = iter([0.0, 1.0, 2.0, 3.0]) + ok = wait_for_lock(probe=probe, timeout_s=10.0, interval_s=1.0, + clock=lambda: next(clock), sleep=lambda _s: None) + assert ok is True + + +# --- pure transition classifier ------------------------------------------- + +def test_classify_lock_transitions_events(): + events = classify_lock_transitions([False, False, True, True, False]) + assert events == [ + {"event": "lock", "locked": True}, + {"event": "unlock", "locked": False}, + ] + + +def test_classify_lock_transitions_empty_and_constant(): + assert classify_lock_transitions([]) == [] + assert classify_lock_transitions([True, True, True]) == [] + + +# --- wiring --------------------------------------------------------------- + +def test_executor_pure_classify_path(): + from je_auto_control.utils.executor.action_executor import ( + _classify_lock_transitions, _plan_lock_session, + ) + out = _classify_lock_transitions([False, True]) + assert out == {"events": [{"event": "lock", "locked": True}]} + # accepts a JSON-list string too (Script Builder text field) + assert _classify_lock_transitions("[false, true]")["events"] == [ + {"event": "lock", "locked": True}] + assert "backend" in _plan_lock_session() + + +def test_default_driver_absent_off_windows(): + from je_auto_control.utils.lock_session.lock_session import _default_driver + if not sys.platform.startswith("win") and sys.platform != "darwin": + # Linux default is loginctl (callable built), so a driver exists. + assert callable(_default_driver()) + + +def test_wiring(): + known = set(ac.executor.known_commands()) + assert {"AC_lock_session", "AC_plan_lock_session", "AC_wait_for_unlock", + "AC_classify_lock_transitions"} <= known + from je_auto_control.utils.mcp_server.tools import ( + build_default_tool_registry, + ) + names = {t.name for t in build_default_tool_registry()} + assert {"ac_lock_session", "ac_plan_lock_session", "ac_wait_for_unlock", + "ac_classify_lock_transitions"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + specs = {s.command for s in _build_specs()} + assert {"AC_lock_session", "AC_plan_lock_session", "AC_wait_for_unlock", + "AC_classify_lock_transitions"} <= specs + + +def test_facade_exports(): + for name in ("lock_session", "plan_lock_session", "wait_for_unlock", + "wait_for_lock", "classify_lock_transitions"): + assert hasattr(ac, name) and name in ac.__all__ From 70047df8a4b2623612babfc634af9024a70b1b0b Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Fri, 26 Jun 2026 05:11:54 +0800 Subject: [PATCH 4/4] Add ime_state: live IME composition state for safe CJK entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing into a CJK field while the IME is composing corrupts entry — the candidate text isn't committed, so reads return half-formed glyphs and the next keystroke edits the composition. text_unicode is blind to this. Expose the focused window's live composition/conversion state (Windows IMM32, read-only) behind an injectable reader, with is_composing and a wait_for_composition_commit gate, so flows wait for commit first. --- WHATS_NEW.md | 6 + .../doc/new_features/v208_features_doc.rst | 57 +++++++ .../Zh/doc/new_features/v208_features_doc.rst | 51 +++++++ je_auto_control/__init__.py | 7 + .../gui/script_builder/command_schema.py | 28 ++++ .../utils/executor/action_executor.py | 31 ++++ je_auto_control/utils/ime_state/__init__.py | 10 ++ je_auto_control/utils/ime_state/ime_state.py | 134 +++++++++++++++++ .../utils/mcp_server/tools/_factories.py | 38 +++++ .../utils/mcp_server/tools/_handlers.py | 24 +++ .../headless/test_ime_state_batch.py | 142 ++++++++++++++++++ 11 files changed, 528 insertions(+) create mode 100644 docs/source/Eng/doc/new_features/v208_features_doc.rst create mode 100644 docs/source/Zh/doc/new_features/v208_features_doc.rst create mode 100644 je_auto_control/utils/ime_state/__init__.py create mode 100644 je_auto_control/utils/ime_state/ime_state.py create mode 100644 test/unit_test/headless/test_ime_state_batch.py diff --git a/WHATS_NEW.md b/WHATS_NEW.md index 0395fbb5..13c1a794 100644 --- a/WHATS_NEW.md +++ b/WHATS_NEW.md @@ -2,6 +2,12 @@ ## What's new (2026-06-26) +### Live IME State for Safe CJK Entry + +Wait for the input method to commit before reading a Japanese/Chinese/Korean field. Full reference: [`docs/source/Eng/doc/new_features/v208_features_doc.rst`](docs/source/Eng/doc/new_features/v208_features_doc.rst). + +- **`ime_state` / `is_composing` / `wait_for_composition_commit` / `decode_conversion_mode`** (`AC_ime_state`, `AC_is_composing`, `AC_wait_for_composition_commit`, `AC_decode_conversion_mode`): typing into a CJK field is unsafe while an IME is *composing* — the candidate text isn't committed, so reading the field back returns half-entered glyphs and the next keystroke edits the composition. `text_unicode` (`VK_PACKET`) is blind to this. `ime_state` exposes the focused window's live `{open, composing, composition, conversion}` (Windows IMM32, read-only) through an injectable `reader`; `is_composing` is the boolean gate; `wait_for_composition_commit` blocks until the IME commits (injectable `clock`/`sleep`/`reader`); `decode_conversion_mode` is the pure `IME_CMODE_*` bitmask decoder. All decode/wait logic is unit-tested without an IME. Sixth feature of the ROUND-15 cross-app OS lane. No `PySide6`. + ### Lock the Workstation + Wait for Unlock Lock the box at the end of a run, and block until a human unlocks it before resuming. Full reference: [`docs/source/Eng/doc/new_features/v207_features_doc.rst`](docs/source/Eng/doc/new_features/v207_features_doc.rst). diff --git a/docs/source/Eng/doc/new_features/v208_features_doc.rst b/docs/source/Eng/doc/new_features/v208_features_doc.rst new file mode 100644 index 00000000..cffcf275 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v208_features_doc.rst @@ -0,0 +1,57 @@ +Live IME State for Safe CJK Entry +================================= + +Typing into a CJK / Japanese / Korean field is unsafe while an IME (input method +editor) is *composing*: the candidate text has not been committed yet, so +reading the field back returns half-entered glyphs and the next keystroke edits +the composition instead of the field. ``text_unicode`` (``VK_PACKET``) is blind +to this. ``ime_state`` exposes the live composition and conversion state so a +flow can wait for the IME to commit before it reads or acts. + +* :func:`ime_state` — ``{open, composing, composition, conversion, + conversion_flags}`` for the focused window's IME, through an injectable + ``reader``. +* :func:`is_composing` — ``True`` while the IME has an uncommitted composition. +* :func:`wait_for_composition_commit` — block until composition ends (or a + timeout), with injectable ``clock`` / ``sleep`` / ``reader``. +* :func:`decode_conversion_mode` — pure: the IMM32 ``IME_CMODE_*`` conversion + bitmask to ``{native, katakana, full_shape, roman, char_code}``. + +The default ``reader`` queries Windows IMM32 (``ImmGetContext`` / +``ImmGetOpenStatus`` / ``ImmGetConversionStatus`` / ``ImmGetCompositionStringW``) +read-only; all decoding / waiting logic runs through the injectable seam, so it +is fully testable without an IME. Imports no ``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import ( + ime_state, is_composing, wait_for_composition_commit, + ) + + # Before reading a CJK field, make sure the IME has committed + if wait_for_composition_commit(timeout_s=3): + value = read_field() + + is_composing() # True while candidate text is still on screen + ime_state() # {'open': True, 'composing': True, 'composition': 'あ', ...} + +For tests (or any non-Windows host) pass a ``reader`` — a +``() -> {open, conversion, composition}``: + +.. code-block:: python + + busy = lambda: {"open": True, "conversion": 0, "composition": "あ"} + is_composing(reader=busy) # True + ime_state(reader=busy)["composition"] # 'あ' + +Executor commands +----------------- + +``AC_ime_state`` (→ the full state), ``AC_is_composing`` (→ ``{composing}``), +``AC_wait_for_composition_commit`` (``timeout`` / ``interval`` → +``{committed}``) and ``AC_decode_conversion_mode`` (``flags`` → the decoded +modes). They are exposed as the matching read-only ``ac_*`` MCP tools and as +Script Builder commands under **Shell**. diff --git a/docs/source/Zh/doc/new_features/v208_features_doc.rst b/docs/source/Zh/doc/new_features/v208_features_doc.rst new file mode 100644 index 00000000..4ffafc32 --- /dev/null +++ b/docs/source/Zh/doc/new_features/v208_features_doc.rst @@ -0,0 +1,51 @@ +即時 IME 狀態以利安全的 CJK 輸入 +================================ + +在 IME(輸入法)*組字中*對 CJK / 日文 / 韓文欄位輸入並不安全:候選字尚未送出,故讀回欄位會得到 +半成形的字,而下一個按鍵會編輯組字而非欄位。``text_unicode``(``VK_PACKET``)對此一無所知。 +``ime_state`` 暴露即時的組字與轉換狀態,讓流程能在讀取或操作前等待 IME 送出。 + +* :func:`ime_state` ——聚焦視窗 IME 的 ``{open, composing, composition, conversion, + conversion_flags}``,透過可注入的 ``reader``。 +* :func:`is_composing` ——當 IME 有尚未送出的組字時回傳 ``True``。 +* :func:`wait_for_composition_commit` ——阻塞直到組字結束(或逾時),``clock`` / ``sleep`` / + ``reader`` 皆可注入。 +* :func:`decode_conversion_mode` ——純函式:把 IMM32 ``IME_CMODE_*`` 轉換位元遮罩解碼為 + ``{native, katakana, full_shape, roman, char_code}``。 + +預設 ``reader`` 以唯讀方式查詢 Windows IMM32(``ImmGetContext`` / ``ImmGetOpenStatus`` / +``ImmGetConversionStatus`` / ``ImmGetCompositionStringW``);所有解碼 / 等待邏輯都透過可注入接縫 +執行,故能在沒有 IME 的情況下完整測試。不匯入 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import ( + ime_state, is_composing, wait_for_composition_commit, + ) + + # 讀取 CJK 欄位前,先確認 IME 已送出 + if wait_for_composition_commit(timeout_s=3): + value = read_field() + + is_composing() # 候選字仍在畫面上時為 True + ime_state() # {'open': True, 'composing': True, 'composition': 'あ', ...} + +測試時(或任何非 Windows 主機)可傳入 ``reader`` ——一個 +``() -> {open, conversion, composition}``: + +.. code-block:: python + + busy = lambda: {"open": True, "conversion": 0, "composition": "あ"} + is_composing(reader=busy) # True + ime_state(reader=busy)["composition"] # 'あ' + +執行器指令 +---------- + +``AC_ime_state``(→ 完整狀態)、``AC_is_composing``(→ ``{composing}``)、 +``AC_wait_for_composition_commit``(``timeout`` / ``interval`` → ``{committed}``) +與 ``AC_decode_conversion_mode``(``flags`` → 解碼後的模式)。皆以對應的唯讀 ``ac_*`` MCP 工具 +及 Script Builder 指令(位於 **Shell** 分類下)形式提供。 diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py index 10a47b62..5934b1b7 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -110,6 +110,11 @@ classify_lock_transitions, lock_session, plan_lock_session, wait_for_lock, wait_for_unlock, ) +# Read the live IME composition / conversion state for safe CJK entry +from je_auto_control.utils.ime_state import ( + decode_conversion_mode, ime_state, is_composing, + wait_for_composition_commit, +) # Rich clipboard formats — RTF + CSV/TSV codecs and Windows get / set from je_auto_control.utils.clipboard_rich_formats import ( build_rtf, csv_to_rows, get_clipboard_csv, get_clipboard_rtf, rows_to_csv, @@ -1727,6 +1732,8 @@ def start_autocontrol_gui(*args, **kwargs): "is_muted", "set_mute", "mute", "unmute", "toggle_mute", "lock_session", "plan_lock_session", "wait_for_unlock", "wait_for_lock", "classify_lock_transitions", + "ime_state", "is_composing", "wait_for_composition_commit", + "decode_conversion_mode", "build_rtf", "rtf_to_text", "rows_to_csv", "csv_to_rows", "set_clipboard_rtf", "get_clipboard_rtf", "set_clipboard_csv", "get_clipboard_csv", diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index e8b56871..a3fefd42 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -4373,6 +4373,34 @@ def _add_work_queue_specs(specs: List[CommandSpec]) -> None: ), description="Reduce lock-state samples to lock / unlock events.", )) + specs.append(CommandSpec( + "AC_ime_state", "Shell", "IME State", + fields=(), + description="Read the focused window's live IME composition state.", + )) + specs.append(CommandSpec( + "AC_is_composing", "Shell", "Is IME Composing", + fields=(), + description="True while the IME has an uncommitted composition.", + )) + specs.append(CommandSpec( + "AC_wait_for_composition_commit", "Shell", "Wait for IME Commit", + fields=( + FieldSpec("timeout", FieldType.FLOAT, optional=True, default=5.0, + placeholder="timeout seconds"), + FieldSpec("interval", FieldType.FLOAT, optional=True, default=0.1, + placeholder="poll interval seconds"), + ), + description="Block until the IME finishes composing or timeout.", + )) + specs.append(CommandSpec( + "AC_decode_conversion_mode", "Shell", "Decode IME Conversion Mode", + fields=( + FieldSpec("flags", FieldType.INT, default=0, + placeholder="IMM32 conversion bitmask"), + ), + description="Decode an IMM32 conversion bitmask into named flags.", + )) specs.append(CommandSpec( "AC_normalize_ext", "Shell", "Normalize Extension", fields=( diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index 0f751890..d061a072 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -2711,6 +2711,33 @@ def _classify_lock_transitions(states: Any) -> Dict[str, Any]: return {"events": classify_lock_transitions(samples)} +def _ime_state() -> Dict[str, Any]: + """Adapter: the focused window's live IME composition / conversion state.""" + from je_auto_control.utils.ime_state import ime_state + return ime_state() + + +def _is_composing() -> Dict[str, Any]: + """Adapter: whether the IME has an uncommitted composition.""" + from je_auto_control.utils.ime_state import is_composing + return {"composing": bool(is_composing())} + + +def _wait_for_composition_commit(timeout: Any = 5.0, interval: Any = 0.1 + ) -> Dict[str, Any]: + """Adapter: block until the IME finishes composing or timeout.""" + from je_auto_control.utils.ime_state import wait_for_composition_commit + committed = wait_for_composition_commit(timeout_s=float(timeout), + interval_s=float(interval)) + return {"committed": bool(committed)} + + +def _decode_conversion_mode(flags: Any) -> Dict[str, Any]: + """Adapter: decode an IMM32 conversion bitmask into named flags (pure).""" + from je_auto_control.utils.ime_state import decode_conversion_mode + return decode_conversion_mode(int(flags)) + + def _normalize_ext(target: str) -> Dict[str, Any]: """Adapter: the lowercased extension of a path / bare ext (pure).""" from je_auto_control.utils.file_assoc import normalize_ext @@ -6729,6 +6756,10 @@ def __init__(self): "AC_plan_lock_session": _plan_lock_session, "AC_wait_for_unlock": _wait_for_unlock, "AC_classify_lock_transitions": _classify_lock_transitions, + "AC_ime_state": _ime_state, + "AC_is_composing": _is_composing, + "AC_wait_for_composition_commit": _wait_for_composition_commit, + "AC_decode_conversion_mode": _decode_conversion_mode, "AC_normalize_ext": _normalize_ext, "AC_file_association": _file_association, "AC_get_control_text": _get_control_text, diff --git a/je_auto_control/utils/ime_state/__init__.py b/je_auto_control/utils/ime_state/__init__.py new file mode 100644 index 00000000..5613d1c6 --- /dev/null +++ b/je_auto_control/utils/ime_state/__init__.py @@ -0,0 +1,10 @@ +"""Read the live IME composition / conversion state for safe CJK entry.""" +from je_auto_control.utils.ime_state.ime_state import ( + decode_conversion_mode, ime_state, is_composing, + wait_for_composition_commit, +) + +__all__ = [ + "ime_state", "is_composing", "wait_for_composition_commit", + "decode_conversion_mode", +] diff --git a/je_auto_control/utils/ime_state/ime_state.py b/je_auto_control/utils/ime_state/ime_state.py new file mode 100644 index 00000000..6123af5e --- /dev/null +++ b/je_auto_control/utils/ime_state/ime_state.py @@ -0,0 +1,134 @@ +"""Read the live IME (input method editor) state for safe CJK entry. + +Typing into a CJK / Japanese / Korean field is unsafe while an IME is *composing*: +the candidate text has not been committed yet, so reading the field back returns +half-entered glyphs and the next keystroke edits the composition instead of the +field. ``text_unicode`` (``VK_PACKET``) is blind to this. ``ime_state`` exposes +the live composition and conversion state so a flow can wait for the IME to +commit before it reads or acts. + +* :func:`ime_state` — ``{open, composing, composition, conversion}`` for the + focused window's IME, through an injectable ``reader``. +* :func:`is_composing` — ``True`` while the IME has an uncommitted composition. +* :func:`wait_for_composition_commit` — block until composition ends (or a + timeout), with injectable ``clock`` / ``sleep`` / ``reader``. +* :func:`decode_conversion_mode` — pure: the IMM32 ``IME_CMODE_*`` conversion + bitmask to ``{native, katakana, full_shape, roman, char_code}``. + +The default ``reader`` queries Windows IMM32 (``ImmGetContext`` / +``ImmGetOpenStatus`` / ``ImmGetConversionStatus`` / ``ImmGetCompositionStringW``) +read-only; all decoding / waiting logic runs through the injectable seam, so it +is fully testable without an IME. Imports no ``PySide6``. +""" +import sys +import time +from typing import Any, Callable, Dict, Optional + +# IMM32 conversion-mode (IME_CMODE_*) bit flags. +IME_CMODE_NATIVE = 0x0001 +IME_CMODE_KATAKANA = 0x0002 +IME_CMODE_FULLSHAPE = 0x0008 +IME_CMODE_ROMAN = 0x0010 +IME_CMODE_CHARCODE = 0x0020 + +# A reader returns the raw IME state: {open, conversion, composition}. +ImeReader = Callable[[], Dict[str, Any]] + + +def decode_conversion_mode(flags: int) -> Dict[str, bool]: + """Decode an IMM32 ``IME_CMODE_*`` bitmask into named booleans (pure).""" + value = int(flags) + return { + "native": bool(value & IME_CMODE_NATIVE), + "katakana": bool(value & IME_CMODE_KATAKANA), + "full_shape": bool(value & IME_CMODE_FULLSHAPE), + "roman": bool(value & IME_CMODE_ROMAN), + "char_code": bool(value & IME_CMODE_CHARCODE), + } + + +def _normalize(raw: Dict[str, Any]) -> Dict[str, Any]: + """Turn a raw reader result into the public IME-state dict (pure).""" + composition = str(raw.get("composition") or "") + flags = int(raw.get("conversion") or 0) + return { + "open": bool(raw.get("open")), + "composing": bool(composition), + "composition": composition, + "conversion": decode_conversion_mode(flags), + "conversion_flags": flags, + } + + +def ime_state(*, reader: Optional[ImeReader] = None) -> Dict[str, Any]: + """Return the focused window's IME state. + + ``{open, composing, composition, conversion, conversion_flags}``. Pass + ``reader`` (a ``() -> {open, conversion, composition}``) to supply the + reading in tests; the default queries Windows IMM32. + """ + source = reader if reader is not None else _default_reader + return _normalize(source()) + + +def is_composing(*, reader: Optional[ImeReader] = None) -> bool: + """Return ``True`` while the IME has an uncommitted composition.""" + return bool(ime_state(reader=reader)["composing"]) + + +def wait_for_composition_commit( + *, reader: Optional[ImeReader] = None, timeout_s: float = 5.0, + interval_s: float = 0.1, clock: Callable[[], float] = time.monotonic, + sleep: Callable[[float], None] = time.sleep) -> bool: + """Block until the IME is no longer composing; ``True``, or ``False`` on timeout. + + ``clock`` / ``sleep`` / ``reader`` are injectable for deterministic tests. + """ + deadline = clock() + float(timeout_s) + while True: + if not is_composing(reader=reader): + return True + if clock() >= deadline: + return False + sleep(float(interval_s)) + + +# IMM32 composition-string flag: read the in-progress composition (GCS_COMPSTR). +_GCS_COMPSTR = 0x0008 + + +def _read_composition(imm32: Any, himc: int) -> str: + """Read the in-progress composition string from an IME context.""" + import ctypes + byte_len = imm32.ImmGetCompositionStringW(himc, _GCS_COMPSTR, None, 0) + if byte_len <= 0: + return "" + buffer = ctypes.create_unicode_buffer(byte_len // 2) + imm32.ImmGetCompositionStringW(himc, _GCS_COMPSTR, buffer, byte_len) + return buffer.value + + +def _default_reader() -> Dict[str, Any]: + """Read the focused window's IME state from Windows IMM32 (read-only).""" + if not sys.platform.startswith("win"): + raise RuntimeError( + "IME state has no OS reader on this platform; pass reader=") + import ctypes + user32 = ctypes.windll.user32 + imm32 = ctypes.windll.imm32 + hwnd = user32.GetForegroundWindow() + himc = imm32.ImmGetContext(hwnd) + if not himc: + return {"open": False, "conversion": 0, "composition": ""} + try: + conversion = ctypes.c_uint(0) + sentence = ctypes.c_uint(0) + imm32.ImmGetConversionStatus( + himc, ctypes.byref(conversion), ctypes.byref(sentence)) + return { + "open": bool(imm32.ImmGetOpenStatus(himc)), + "conversion": int(conversion.value), + "composition": _read_composition(imm32, himc), + } + finally: + imm32.ImmReleaseContext(hwnd, himc) diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index 83df5f93..4e0ce256 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -2249,6 +2249,44 @@ def process_and_shell_tools() -> List[MCPTool]: handler=h.classify_lock_transitions, annotations=READ_ONLY, ), + MCPTool( + name="ac_ime_state", + description=("Read the focused window's live IME state (Windows " + "IMM32). Returns {open, composing, composition, " + "conversion, conversion_flags}."), + input_schema=schema({}), + handler=h.ime_state, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_is_composing", + description=("Whether the IME has an uncommitted composition " + "(unsafe to type / read the field). Returns " + "{composing}."), + input_schema=schema({}), + handler=h.is_composing, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_wait_for_composition_commit", + description=("Block until the IME finishes composing (or 'timeout' " + "seconds), polling every 'interval'. Returns " + "{committed}."), + input_schema=schema({"timeout": {"type": "number"}, + "interval": {"type": "number"}}), + handler=h.wait_for_composition_commit, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_decode_conversion_mode", + description=("Decode an IMM32 IME_CMODE_* conversion bitmask " + "('flags') into {native, katakana, full_shape, roman, " + "char_code} (pure)."), + input_schema=schema({"flags": {"type": "integer"}}, + required=["flags"]), + handler=h.decode_conversion_mode, + annotations=READ_ONLY, + ), ] diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index 613adf2d..62b0f307 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -644,6 +644,30 @@ def classify_lock_transitions(states): return _classify_lock_transitions(states) +def ime_state(): + from je_auto_control.utils.executor.action_executor import _ime_state + return _ime_state() + + +def is_composing(): + from je_auto_control.utils.executor.action_executor import _is_composing + return _is_composing() + + +def wait_for_composition_commit(timeout=5.0, interval=0.1): + from je_auto_control.utils.executor.action_executor import ( + _wait_for_composition_commit, + ) + return _wait_for_composition_commit(timeout, interval) + + +def decode_conversion_mode(flags): + from je_auto_control.utils.executor.action_executor import ( + _decode_conversion_mode, + ) + return _decode_conversion_mode(flags) + + def normalize_ext(target): from je_auto_control.utils.executor.action_executor import _normalize_ext return _normalize_ext(target) diff --git a/test/unit_test/headless/test_ime_state_batch.py b/test/unit_test/headless/test_ime_state_batch.py new file mode 100644 index 00000000..15974f6b --- /dev/null +++ b/test/unit_test/headless/test_ime_state_batch.py @@ -0,0 +1,142 @@ +"""Headless tests for ime_state (injected reader / clock).""" +import sys + +import je_auto_control as ac +from je_auto_control.utils.ime_state import ( + decode_conversion_mode, ime_state, is_composing, + wait_for_composition_commit, +) +from je_auto_control.utils.ime_state.ime_state import ( + IME_CMODE_FULLSHAPE, IME_CMODE_NATIVE, IME_CMODE_ROMAN, +) + + +# --- pure conversion-mode decode ------------------------------------------ + +def test_decode_conversion_mode_native_roman(): + flags = IME_CMODE_NATIVE | IME_CMODE_ROMAN + decoded = decode_conversion_mode(flags) + assert decoded["native"] is True + assert decoded["roman"] is True + assert decoded["full_shape"] is False + assert decoded["katakana"] is False + + +def test_decode_conversion_mode_zero_all_false(): + decoded = decode_conversion_mode(0) + assert decoded == {"native": False, "katakana": False, "full_shape": False, + "roman": False, "char_code": False} + + +# --- state via injected reader -------------------------------------------- + +def test_ime_state_composing(): + state = ime_state(reader=lambda: {"open": True, + "conversion": IME_CMODE_NATIVE, + "composition": "あ"}) + assert state["open"] is True + assert state["composing"] is True + assert state["composition"] == "あ" + assert state["conversion"]["native"] is True + assert state["conversion_flags"] == IME_CMODE_NATIVE + + +def test_ime_state_idle_not_composing(): + state = ime_state( + reader=lambda: {"open": False, "conversion": 0, "composition": ""}) + assert state["composing"] is False + assert state["composition"] == "" + + +def test_ime_state_tolerates_missing_keys(): + state = ime_state(reader=dict) # empty dict + assert state["open"] is False + assert state["composing"] is False + assert state["conversion_flags"] == 0 + + +def test_is_composing_reflects_reader(): + assert is_composing( + reader=lambda: {"composition": "한", "conversion": 0, + "open": True}) is True + assert is_composing( + reader=lambda: {"composition": "", "conversion": 0, + "open": True}) is False + + +# --- wait for commit ------------------------------------------------------ + +def _reader_sequence(compositions): + state = {"i": 0} + + def reader(): + i = min(state["i"], len(compositions) - 1) + state["i"] += 1 + return {"open": True, "conversion": 0, "composition": compositions[i]} + + return reader + + +def test_wait_for_composition_commit_returns_true(): + reader = _reader_sequence(["typ", "typi", ""]) # commits on 3rd read + clock = iter([0.0, 1.0, 2.0, 3.0, 4.0]) + ok = wait_for_composition_commit(reader=reader, timeout_s=10.0, + interval_s=1.0, + clock=lambda: next(clock), + sleep=lambda _s: None) + assert ok is True + + +def test_wait_for_composition_commit_times_out(): + reader = _reader_sequence(["forever"]) # never commits + times = iter([0.0, 0.0, 5.0, 10.0]) + ok = wait_for_composition_commit(reader=reader, timeout_s=5.0, + interval_s=1.0, + clock=lambda: next(times), + sleep=lambda _s: None) + assert ok is False + + +# --- wiring --------------------------------------------------------------- + +def test_executor_pure_decode_path(): + from je_auto_control.utils.executor.action_executor import ( + _decode_conversion_mode, + ) + assert _decode_conversion_mode(IME_CMODE_FULLSHAPE)["full_shape"] is True + + +def test_default_reader_raises_off_windows(): + from je_auto_control.utils.ime_state.ime_state import _default_reader + if not sys.platform.startswith("win"): + try: + _default_reader() + raised = False + except RuntimeError: + raised = True + assert raised is True + + +def test_wiring(): + known = set(ac.executor.known_commands()) + assert {"AC_ime_state", "AC_is_composing", + "AC_wait_for_composition_commit", + "AC_decode_conversion_mode"} <= known + from je_auto_control.utils.mcp_server.tools import ( + build_default_tool_registry, + ) + names = {t.name for t in build_default_tool_registry()} + assert {"ac_ime_state", "ac_is_composing", + "ac_wait_for_composition_commit", + "ac_decode_conversion_mode"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + specs = {s.command for s in _build_specs()} + assert {"AC_ime_state", "AC_is_composing", + "AC_wait_for_composition_commit", + "AC_decode_conversion_mode"} <= specs + + +def test_facade_exports(): + for name in ("ime_state", "is_composing", "wait_for_composition_commit", + "decode_conversion_mode"): + assert hasattr(ac, name) and name in ac.__all__