Skip to content

Commit ef98534

Browse files
committed
Add shell_open: open files with default app / URLs in browser
The framework could launch a literal .exe but not the most common hand- off step: open report.pdf with its registered app, print a document, or open a URL in the default browser. Route per-OS to os.startfile/open/ xdg-open/webbrowser. plan_open is a pure planner (classify URL vs file, scheme allow-list, realpath; Windows drive is a path not a scheme); open_path runs it through an injectable opener so the logic is testable without launching anything.
1 parent 0736593 commit ef98534

11 files changed

Lines changed: 342 additions & 0 deletions

File tree

WHATS_NEW.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# What's New — AutoControl
22

3+
## What's new (2026-06-25) — Open Files / URLs with the Default App
4+
5+
Hand a file to its default app, print it, or open a URL in the browser. Full reference: [`docs/source/Eng/doc/new_features/v203_features_doc.rst`](docs/source/Eng/doc/new_features/v203_features_doc.rst).
6+
7+
- **`open_path` / `plan_open`** (`AC_open_path`, `AC_plan_open`): the framework could launch a literal `.exe`, but not the most common "hand off to another app" step — open `report.pdf` with its registered app, `print` a document, or open a URL in the default browser. This routes per-OS to `os.startfile` / `open` / `xdg-open` / `webbrowser`. `plan_open` is a pure planner that classifies the target (URL vs file path), validates it (URL scheme allow-list; `realpath` for files — a Windows drive `C:\` is correctly a path, not a scheme) and returns the dispatch descriptor; `open_path` runs it through an injectable `opener` (the real OS call by default), so the logic is unit-testable without launching anything. First feature of the ROUND-15 cross-app OS lane. No `PySide6`.
8+
39
## What's new (2026-06-25) — Reactive UIA Event Wait (focus change)
410

511
Wait until focus lands on the dialog — a real, zero-latency UIA event, not polling. Full reference: [`docs/source/Eng/doc/new_features/v202_features_doc.rst`](docs/source/Eng/doc/new_features/v202_features_doc.rst).
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
Open Files / URLs with the Default App
2+
======================================
3+
4+
The framework could launch a literal executable (``start_exe`` / ``shell_process``),
5+
but not the single most common "hand off to another app" RPA step: open
6+
``report.pdf`` with whatever app is registered for it, ``print`` a document, or
7+
open a URL in the default browser. ``shell_open`` adds that, routed per-OS to
8+
``os.startfile`` / ``open`` / ``xdg-open`` / ``webbrowser``.
9+
10+
* :func:`plan_open` — pure planner: classify the target (URL vs file path),
11+
validate it (URL scheme allow-list; ``realpath`` for files) and return the
12+
dispatch descriptor,
13+
* :func:`open_path` — run the plan through an injectable ``opener`` sink (the real
14+
OS call by default).
15+
16+
Pure stdlib; the dispatch logic is unit-testable without launching anything via
17+
the injectable ``opener``. Imports no ``PySide6``.
18+
19+
Headless API
20+
------------
21+
22+
.. code-block:: python
23+
24+
from je_auto_control import open_path, plan_open
25+
26+
open_path("report.pdf") # default PDF viewer
27+
open_path("invoice.pdf", verb="print") # print it
28+
open_path("https://example.com") # default browser
29+
30+
plan_open("https://example.com")
31+
# {"kind": "url", "scheme": "https", "target": "...", "backend": "webbrowser",
32+
# "verb": "open"}
33+
plan_open("report.pdf")
34+
# {"kind": "file", "target": "<realpath>", "backend": "startfile", ...}
35+
36+
A ``scheme://`` target (or ``mailto:`` / ``tel:``) is opened as a URL — only the
37+
allow-listed schemes (``http`` / ``https`` / ``ftp`` / ``file`` / ``mailto`` /
38+
``tel``) are accepted, anything else raises ``ValueError``. Everything else is a
39+
file path (a Windows drive like ``C:\\…`` is correctly treated as a path, not a
40+
scheme) and is ``realpath``-resolved. ``verb`` (``open`` / ``print`` / ``edit``)
41+
applies to files on Windows.
42+
43+
Executor commands
44+
-----------------
45+
46+
``AC_open_path`` (``target`` / ``verb`` → ``{opened}``) and ``AC_plan_open``
47+
(``target`` / ``verb`` → the plan). They are exposed as the matching ``ac_*`` MCP
48+
tools (``open_path`` side-effect-only, ``plan_open`` read-only) and as Script
49+
Builder commands under **Shell**.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
以預設程式開啟檔案 / URL
2+
========================
3+
4+
框架原本能啟動字面執行檔(``start_exe`` / ``shell_process``),卻無法做最常見的「交接給另一個應用程式」
5+
RPA 步驟:用註冊的應用程式開啟 ``report.pdf``、``print`` 一份文件,或在預設瀏覽器開啟 URL。
6+
``shell_open`` 補上這點,依作業系統路由到 ``os.startfile`` / ``open`` / ``xdg-open`` /
7+
``webbrowser``。
8+
9+
* :func:`plan_open` ——純 planner:分類目標(URL 或檔案路徑)、驗證(URL scheme 白名單;檔案用
10+
``realpath``)並回傳分派描述子,
11+
* :func:`open_path` ——透過可注入的 ``opener`` 接縫執行計畫(預設為真正的 OS 呼叫)。
12+
13+
純標準庫;透過可注入的 ``opener``,分派邏輯可在不真正開啟任何東西的情況下單元測試。不匯入
14+
``PySide6``。
15+
16+
無頭 API
17+
--------
18+
19+
.. code-block:: python
20+
21+
from je_auto_control import open_path, plan_open
22+
23+
open_path("report.pdf") # 預設 PDF 檢視器
24+
open_path("invoice.pdf", verb="print") # 列印
25+
open_path("https://example.com") # 預設瀏覽器
26+
27+
plan_open("https://example.com")
28+
# {"kind": "url", "scheme": "https", "target": "...", "backend": "webbrowser",
29+
# "verb": "open"}
30+
plan_open("report.pdf")
31+
# {"kind": "file", "target": "<realpath>", "backend": "startfile", ...}
32+
33+
``scheme://`` 目標(或 ``mailto:`` / ``tel:``)會以 URL 開啟——只接受白名單 scheme
34+
(``http`` / ``https`` / ``ftp`` / ``file`` / ``mailto`` / ``tel``),其他則拋出 ``ValueError``。
35+
其餘皆視為檔案路徑(Windows 磁碟代號如 ``C:\\…`` 會正確視為路徑而非 scheme)並以 ``realpath``
36+
解析。``verb``(``open`` / ``print`` / ``edit``)在 Windows 上套用於檔案。
37+
38+
執行器指令
39+
----------
40+
41+
``AC_open_path``(``target`` / ``verb`` → ``{opened}``)與 ``AC_plan_open``(``target`` /
42+
``verb`` → 計畫)。皆以對應的 ``ac_*`` MCP 工具(``open_path`` 為僅副作用、``plan_open`` 為唯讀)
43+
及 Script Builder 指令(位於 **Shell** 分類下)形式提供。

je_auto_control/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@
9191
)
9292
# Reactive UIA event waits (focus-changed)
9393
from je_auto_control.utils.ax_events import wait_for_focus_change
94+
# Open a file with its default app / a URL in the default browser
95+
from je_auto_control.utils.shell_open import open_path, plan_open
9496
# Rich clipboard formats — RTF + CSV/TSV codecs and Windows get / set
9597
from je_auto_control.utils.clipboard_rich_formats import (
9698
build_rtf, csv_to_rows, get_clipboard_csv, get_clipboard_rtf, rows_to_csv,
@@ -1700,6 +1702,7 @@ def start_autocontrol_gui(*args, **kwargs):
17001702
"legacy_info", "legacy_default_action",
17011703
"get_selection", "list_views", "set_view",
17021704
"wait_for_focus_change",
1705+
"plan_open", "open_path",
17031706
"build_rtf", "rtf_to_text", "rows_to_csv", "csv_to_rows",
17041707
"set_clipboard_rtf", "get_clipboard_rtf",
17051708
"set_clipboard_csv", "get_clipboard_csv",

je_auto_control/gui/script_builder/command_schema.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4250,6 +4250,24 @@ def _add_work_queue_specs(specs: List[CommandSpec]) -> None:
42504250
"AC_shell_command", "Shell", "Shell Command",
42514251
fields=(FieldSpec("shell_command", FieldType.STRING),),
42524252
))
4253+
specs.append(CommandSpec(
4254+
"AC_open_path", "Shell", "Open File / URL (default app)",
4255+
fields=(
4256+
FieldSpec("target", FieldType.STRING,
4257+
placeholder="report.pdf or https://example.com"),
4258+
FieldSpec("verb", FieldType.STRING, optional=True, default="open",
4259+
placeholder="open / print / edit"),
4260+
),
4261+
description="Open a file with its default app, or a URL in the browser.",
4262+
))
4263+
specs.append(CommandSpec(
4264+
"AC_plan_open", "Shell", "Plan Open (classify)",
4265+
fields=(
4266+
FieldSpec("target", FieldType.STRING),
4267+
FieldSpec("verb", FieldType.STRING, optional=True, default="open"),
4268+
),
4269+
description="Classify how a file/URL would be opened (pure, no launch).",
4270+
))
42534271
specs.append(CommandSpec(
42544272
"AC_take_golden", "Report", "Capture Golden Image",
42554273
fields=(FieldSpec("path", FieldType.FILE_PATH),),

je_auto_control/utils/executor/action_executor.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2609,6 +2609,18 @@ def _wait_for_focus_change(timeout: Any = 5.0) -> Dict[str, Any]:
26092609
return {"changed": element is not None, "element": element}
26102610

26112611

2612+
def _plan_open(target: str, verb: str = "open") -> Dict[str, Any]:
2613+
"""Adapter: classify how a file path / URL would be opened (pure)."""
2614+
from je_auto_control.utils.shell_open import plan_open
2615+
return plan_open(str(target), verb=str(verb))
2616+
2617+
2618+
def _open_path(target: str, verb: str = "open") -> Dict[str, Any]:
2619+
"""Adapter: open a file with its default app / a URL in the browser."""
2620+
from je_auto_control.utils.shell_open import open_path
2621+
return {"opened": bool(open_path(str(target), verb=str(verb)))}
2622+
2623+
26122624
def _get_control_text(name: Optional[str] = None, role: Optional[str] = None,
26132625
app_name: Optional[str] = None,
26142626
automation_id: Optional[str] = None) -> Dict[str, Any]:
@@ -6599,6 +6611,8 @@ def __init__(self):
65996611
"AC_list_views": _list_views,
66006612
"AC_set_view": _set_view,
66016613
"AC_wait_for_focus_change": _wait_for_focus_change,
6614+
"AC_plan_open": _plan_open,
6615+
"AC_open_path": _open_path,
66026616
"AC_get_control_text": _get_control_text,
66036617
"AC_find_control_text": _find_control_text,
66046618
"AC_select_control_text": _select_control_text,

je_auto_control/utils/mcp_server/tools/_factories.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2084,6 +2084,28 @@ def process_and_shell_tools() -> List[MCPTool]:
20842084
handler=h.shell_command,
20852085
annotations=DESTRUCTIVE,
20862086
),
2087+
MCPTool(
2088+
name="ac_open_path",
2089+
description=("Open a file with its OS-registered default app (or a "
2090+
"'verb' like print), or a URL in the default browser. "
2091+
"'target' is a path or URL. Returns {opened}."),
2092+
input_schema=schema({"target": {"type": "string"},
2093+
"verb": {"type": "string"}},
2094+
required=["target"]),
2095+
handler=h.open_path,
2096+
annotations=SIDE_EFFECT_ONLY,
2097+
),
2098+
MCPTool(
2099+
name="ac_plan_open",
2100+
description=("Classify how a file path / URL would be opened without "
2101+
"opening it (pure): {kind, target, backend, verb} "
2102+
"(+scheme for URLs). Rejects non-allow-listed schemes."),
2103+
input_schema=schema({"target": {"type": "string"},
2104+
"verb": {"type": "string"}},
2105+
required=["target"]),
2106+
handler=h.plan_open,
2107+
annotations=READ_ONLY,
2108+
),
20872109
]
20882110

20892111

je_auto_control/utils/mcp_server/tools/_handlers.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,16 @@ def shell_command(command: str, timeout: float = 30.0
558558
}
559559

560560

561+
def open_path(target, verb="open"):
562+
from je_auto_control.utils.executor.action_executor import _open_path
563+
return _open_path(target, verb)
564+
565+
566+
def plan_open(target, verb="open"):
567+
from je_auto_control.utils.executor.action_executor import _plan_open
568+
return _plan_open(target, verb)
569+
570+
561571
def get_clipboard() -> str:
562572
from je_auto_control.utils.clipboard.clipboard import get_clipboard as _get
563573
return _get()
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
"""Open a file with its default app, or a URL in the default browser (per-OS)."""
2+
from je_auto_control.utils.shell_open.shell_open import open_path, plan_open
3+
4+
__all__ = ["plan_open", "open_path"]
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""Open a file with its default app, or a URL in the default browser.
2+
3+
The framework can launch a literal executable (``start_exe`` / ``shell_process``),
4+
but not the single most common "hand off to another app" RPA step: open ``report.pdf``
5+
with whatever app is registered for it, ``print`` a document, or open a URL in the
6+
default browser. ``shell_open`` adds that, routed per-OS to ``os.startfile`` /
7+
``open`` / ``xdg-open`` / ``webbrowser``.
8+
9+
:func:`plan_open` is a pure planner — it classifies the target (URL vs file path),
10+
validates it (URL scheme allowlist; ``realpath`` for files) and returns the
11+
dispatch descriptor, fully unit-testable. :func:`open_path` runs the plan through
12+
an injectable ``opener`` sink (the real OS call by default), so the dispatch logic
13+
is testable without launching anything. Imports no ``PySide6``.
14+
"""
15+
import os
16+
import re
17+
import sys
18+
from typing import Any, Callable, Dict, Optional
19+
20+
# URL schemes we will hand to the browser / OS — the safety allowlist.
21+
_URL_SCHEMES = frozenset({"http", "https", "ftp", "file", "mailto", "tel"})
22+
_SCHEME_AUTHORITY = re.compile(r"([a-zA-Z][a-zA-Z0-9+.\-]+)://")
23+
_SCHEME_OPAQUE = re.compile(r"(mailto|tel):", re.IGNORECASE)
24+
25+
# A plan dispatcher: maps a plan dict to the real open call → bool.
26+
Opener = Callable[[Dict[str, Any]], bool]
27+
28+
29+
def _scheme(target: str) -> Optional[str]:
30+
"""Return the URL scheme of ``target`` (e.g. ``https``), or None for a path.
31+
32+
Requires ``scheme://`` (so a Windows drive ``C:\\`` is not a scheme) or a known
33+
opaque scheme (``mailto:`` / ``tel:``).
34+
"""
35+
match = _SCHEME_AUTHORITY.match(target) or _SCHEME_OPAQUE.match(target)
36+
return match.group(1).lower() if match else None
37+
38+
39+
def _file_backend() -> str:
40+
if sys.platform.startswith("win"):
41+
return "startfile"
42+
if sys.platform == "darwin":
43+
return "open"
44+
return "xdg-open"
45+
46+
47+
def plan_open(target: str, *, verb: str = "open") -> Dict[str, Any]:
48+
"""Classify ``target`` and return how to open it (pure, no side effect).
49+
50+
Returns ``{kind, target, backend, verb}`` (plus ``scheme`` for URLs). Raises
51+
``ValueError`` for an empty target or a URL whose scheme isn't allow-listed.
52+
"""
53+
text = str(target).strip()
54+
if not text:
55+
raise ValueError("target is empty")
56+
scheme = _scheme(text)
57+
if scheme is not None:
58+
if scheme not in _URL_SCHEMES:
59+
raise ValueError(f"unsupported URL scheme: {scheme!r}")
60+
return {"kind": "url", "scheme": scheme, "target": text,
61+
"backend": "webbrowser", "verb": verb}
62+
path = os.path.realpath(os.path.expanduser(text))
63+
return {"kind": "file", "target": path, "backend": _file_backend(),
64+
"verb": verb}
65+
66+
67+
def _default_opener(plan: Dict[str, Any]) -> bool:
68+
backend, target = plan["backend"], plan["target"]
69+
if backend == "webbrowser":
70+
import webbrowser
71+
return bool(webbrowser.open(target))
72+
if backend == "startfile":
73+
if not sys.platform.startswith("win"):
74+
raise RuntimeError("startfile is only supported on Windows")
75+
# noqa: S606 / nosec B606 — verb is from the allow-listed plan, no shell
76+
os.startfile(target, plan.get("verb") or "open") # noqa: S606 # nosec B606
77+
return True
78+
import subprocess # nosec B404 # reason: argv list, no shell
79+
subprocess.Popen([backend, target]) # nosec B603 # reason: fixed backend + argv, no shell
80+
return True
81+
82+
83+
def open_path(target: str, *, verb: str = "open",
84+
opener: Optional[Opener] = None) -> bool:
85+
"""Open ``target`` (file → default app / verb; URL → default browser).
86+
87+
Pass an ``opener`` ``plan -> bool`` to intercept the dispatch (e.g. in tests);
88+
the default runs the real OS call. Returns True on success.
89+
"""
90+
plan = plan_open(target, verb=verb)
91+
dispatch = opener if opener is not None else _default_opener
92+
return bool(dispatch(plan))

0 commit comments

Comments
 (0)