Skip to content

Commit b6e2185

Browse files
authored
Merge pull request #428 from Integration-Automation/feat/file-assoc-batch
Add file_assoc: resolve the app registered for a file type
2 parents c096c05 + 55f1f81 commit b6e2185

11 files changed

Lines changed: 320 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) — Resolve the App Registered for a File Type
4+
5+
Find out *which* app opens a file type — assert "PDFs open in Acrobat, not the browser". Full reference: [`docs/source/Eng/doc/new_features/v205_features_doc.rst`](docs/source/Eng/doc/new_features/v205_features_doc.rst).
6+
7+
- **`normalize_ext` / `file_association`** (`AC_normalize_ext`, `AC_file_association`): `open_path` (`shell_open`) opens a file with whatever app is registered for it; this answers the inverse, read-only question — *which* app is that? Given `report.pdf` (or a bare `.pdf` / `pdf`) `file_association` returns the registered executable, friendly app name, open command line and MIME content type via the Windows `AssocQueryStringW` shell API. `normalize_ext` is the pure path/`.ext`/bare-`ext``.ext` helper. The assembly logic is unit-testable without Windows through an injectable `resolver` seam (the real shell API by default). The natural companion to `open_path`: one tells you what would open a file, the other opens it. Third feature of the ROUND-15 cross-app OS lane. No `PySide6`.
8+
39
## What's new (2026-06-25) — Idle Detection + Keep the Machine Awake
410

511
Run only when the user has stepped away, and stop an overnight run from sleeping. Full reference: [`docs/source/Eng/doc/new_features/v204_features_doc.rst`](docs/source/Eng/doc/new_features/v204_features_doc.rst).
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
Resolve the App Registered for a File Type
2+
==========================================
3+
4+
:func:`open_path` (``shell_open``) opens a file with whatever app is registered
5+
for it; ``file_assoc`` answers the inverse, read-only question — *which* app is
6+
that? Given ``report.pdf`` (or a bare ``.pdf`` / ``pdf``) it returns the
7+
registered executable, the friendly app name, the open command line and the MIME
8+
content type, via the Windows ``AssocQueryStringW`` shell API.
9+
10+
* :func:`normalize_ext` — pure helper turning a path / ``.ext`` / bare ``ext``
11+
into a lowercased ``.ext``,
12+
* :func:`file_association` — run the lookup through an injectable ``resolver``
13+
seam (the real shell API by default).
14+
15+
The assembly logic is unit-testable without Windows via the injectable
16+
``resolver``. Imports no ``PySide6``.
17+
18+
Headless API
19+
------------
20+
21+
.. code-block:: python
22+
23+
from je_auto_control import file_association, normalize_ext
24+
25+
normalize_ext("report.PDF") # ".pdf"
26+
normalize_ext("archive.tar.gz") # ".gz"
27+
28+
file_association("report.pdf")
29+
# {"ext": ".pdf", "command": "...AcroRd32.exe \"%1\"",
30+
# "exe": "...AcroRd32.exe", "friendly": "Adobe Acrobat",
31+
# "content_type": "application/pdf"}
32+
33+
The app fields are ``None`` when nothing is registered for the type. This is the
34+
natural companion to :func:`open_path`: ``file_association`` tells you *what*
35+
would open a file (assert "PDFs open in Acrobat, not the browser"), and
36+
``open_path`` actually opens it. The live lookup uses the Windows shell API; on
37+
other platforms pass your own ``resolver``.
38+
39+
Executor commands
40+
-----------------
41+
42+
``AC_normalize_ext`` (``target`` → ``{ext}``, pure) and ``AC_file_association``
43+
(``target`` → the association dict). They are exposed as the matching ``ac_*``
44+
MCP tools (both read-only) and as Script Builder commands under **Shell**.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
解析檔案類型已註冊的應用程式
2+
============================
3+
4+
:func:`open_path`(``shell_open``)用註冊的應用程式開啟檔案;``file_assoc`` 回答相反的唯讀問題——
5+
那個應用程式是「哪一個」?給定 ``report.pdf``(或裸的 ``.pdf`` / ``pdf``),它會透過 Windows
6+
``AssocQueryStringW`` shell API 回傳已註冊的執行檔、友善應用程式名稱、開啟命令列與 MIME 內容類型。
7+
8+
* :func:`normalize_ext` ——純輔助函式,把路徑 / ``.ext`` / 裸 ``ext`` 轉成小寫的 ``.ext``,
9+
* :func:`file_association` ——透過可注入的 ``resolver`` 接縫執行查詢(預設為真正的 shell API)。
10+
11+
組裝邏輯可透過可注入的 ``resolver`` 在非 Windows 上單元測試。不匯入 ``PySide6``。
12+
13+
無頭 API
14+
--------
15+
16+
.. code-block:: python
17+
18+
from je_auto_control import file_association, normalize_ext
19+
20+
normalize_ext("report.PDF") # ".pdf"
21+
normalize_ext("archive.tar.gz") # ".gz"
22+
23+
file_association("report.pdf")
24+
# {"ext": ".pdf", "command": "...AcroRd32.exe \"%1\"",
25+
# "exe": "...AcroRd32.exe", "friendly": "Adobe Acrobat",
26+
# "content_type": "application/pdf"}
27+
28+
當該類型未註冊任何應用程式時,應用程式欄位為 ``None``。這是 :func:`open_path` 的自然搭檔:
29+
``file_association`` 告訴你「什麼」會開啟檔案(可斷言「PDF 用 Acrobat 開,不是瀏覽器」),而
30+
``open_path`` 實際開啟它。即時查詢使用 Windows shell API;其他平台請傳入自己的 ``resolver``。
31+
32+
執行器指令
33+
----------
34+
35+
``AC_normalize_ext``(``target`` → ``{ext}``,純)與 ``AC_file_association``
36+
(``target`` → 關聯 dict)。皆以對應的 ``ac_*`` MCP 工具(皆唯讀)及 Script Builder 指令
37+
(位於 **Shell** 分類下)形式提供。

je_auto_control/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@
9898
allow_sleep, idle_seconds, is_idle, keep_awake, keep_awake_on,
9999
plan_keep_awake,
100100
)
101+
# Resolve which application is registered to open a given file type
102+
from je_auto_control.utils.file_assoc import file_association, normalize_ext
101103
# Rich clipboard formats — RTF + CSV/TSV codecs and Windows get / set
102104
from je_auto_control.utils.clipboard_rich_formats import (
103105
build_rtf, csv_to_rows, get_clipboard_csv, get_clipboard_rtf, rows_to_csv,
@@ -1710,6 +1712,7 @@ def start_autocontrol_gui(*args, **kwargs):
17101712
"plan_open", "open_path",
17111713
"idle_seconds", "is_idle", "plan_keep_awake",
17121714
"keep_awake", "keep_awake_on", "allow_sleep",
1715+
"normalize_ext", "file_association",
17131716
"build_rtf", "rtf_to_text", "rows_to_csv", "csv_to_rows",
17141717
"set_clipboard_rtf", "get_clipboard_rtf",
17151718
"set_clipboard_csv", "get_clipboard_csv",

je_auto_control/gui/script_builder/command_schema.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4302,6 +4302,22 @@ def _add_work_queue_specs(specs: List[CommandSpec]) -> None:
43024302
fields=(),
43034303
description="Release a previously-started keep-awake.",
43044304
))
4305+
specs.append(CommandSpec(
4306+
"AC_normalize_ext", "Shell", "Normalize Extension",
4307+
fields=(
4308+
FieldSpec("target", FieldType.STRING,
4309+
placeholder="report.pdf or .pdf or pdf"),
4310+
),
4311+
description="Lowercased file extension (with dot) of a path / ext.",
4312+
))
4313+
specs.append(CommandSpec(
4314+
"AC_file_association", "Shell", "File Association (default app)",
4315+
fields=(
4316+
FieldSpec("target", FieldType.STRING,
4317+
placeholder="report.pdf or .pdf"),
4318+
),
4319+
description="Which app is registered to open a file type (Windows).",
4320+
))
43054321
specs.append(CommandSpec(
43064322
"AC_take_golden", "Report", "Capture Golden Image",
43074323
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
@@ -2652,6 +2652,18 @@ def _allow_sleep() -> Dict[str, Any]:
26522652
return {"released": bool(allow_sleep())}
26532653

26542654

2655+
def _normalize_ext(target: str) -> Dict[str, Any]:
2656+
"""Adapter: the lowercased extension of a path / bare ext (pure)."""
2657+
from je_auto_control.utils.file_assoc import normalize_ext
2658+
return {"ext": normalize_ext(str(target))}
2659+
2660+
2661+
def _file_association(target: str) -> Dict[str, Any]:
2662+
"""Adapter: the app registered to open ``target``'s file type."""
2663+
from je_auto_control.utils.file_assoc import file_association
2664+
return file_association(str(target))
2665+
2666+
26552667
def _get_control_text(name: Optional[str] = None, role: Optional[str] = None,
26562668
app_name: Optional[str] = None,
26572669
automation_id: Optional[str] = None) -> Dict[str, Any]:
@@ -6649,6 +6661,8 @@ def __init__(self):
66496661
"AC_plan_keep_awake": _plan_keep_awake,
66506662
"AC_keep_awake_on": _keep_awake_on,
66516663
"AC_allow_sleep": _allow_sleep,
6664+
"AC_normalize_ext": _normalize_ext,
6665+
"AC_file_association": _file_association,
66526666
"AC_get_control_text": _get_control_text,
66536667
"AC_find_control_text": _find_control_text,
66546668
"AC_select_control_text": _select_control_text,
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Resolve which application is registered to open a given file type."""
2+
from je_auto_control.utils.file_assoc.file_assoc import (
3+
file_association, normalize_ext,
4+
)
5+
6+
__all__ = ["normalize_ext", "file_association"]
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""Resolve which application is registered to open a given file type.
2+
3+
:func:`shell_open` opens a file with whatever app is registered for it;
4+
``file_assoc`` answers the inverse, read-only question — *which* app is that?
5+
Given ``report.pdf`` (or a bare ``.pdf`` / ``pdf``) it returns the registered
6+
executable, the friendly app name, the open command line and the MIME content
7+
type, via the Windows ``AssocQueryStringW`` shell API.
8+
9+
:func:`normalize_ext` is a pure helper (path / bare-extension -> ``.ext``),
10+
fully unit-testable. :func:`file_association` runs the lookup through an
11+
injectable ``resolver`` seam (the real shell API by default), so the assembly
12+
logic is testable without Windows. Imports no ``PySide6``.
13+
"""
14+
import ctypes
15+
import os
16+
import sys
17+
from typing import Any, Callable, Dict, Optional
18+
19+
# ASSOCSTR query ids (shlwapi AssocQueryStringW) -> result-dict keys.
20+
_ASSOC_FIELDS = {
21+
"command": 1, # ASSOCSTR_COMMAND
22+
"exe": 2, # ASSOCSTR_EXECUTABLE
23+
"friendly": 4, # ASSOCSTR_FRIENDLYAPPNAME
24+
"content_type": 12, # ASSOCSTR_CONTENTTYPE
25+
}
26+
27+
# A resolver: ext (".pdf") -> {command, exe, friendly, content_type}.
28+
AssocResolver = Callable[[str], Dict[str, Any]]
29+
30+
31+
def normalize_ext(target: str) -> str:
32+
"""Return the lowercased extension (with leading dot) of a path or ext.
33+
34+
Accepts ``report.pdf``, ``C:\\x\\y.PDF``, ``.pdf`` or bare ``pdf``. Raises
35+
``ValueError`` for an empty target or one with no resolvable extension.
36+
"""
37+
text = str(target).strip()
38+
if not text:
39+
raise ValueError("target is empty")
40+
_, ext = os.path.splitext(os.path.basename(text))
41+
if not ext:
42+
ext = text if text.startswith(".") else "." + text
43+
ext = ext.lower()
44+
if len(ext) < 2 or any(char in ext for char in "/\\ \t"):
45+
raise ValueError(f"no file extension in target: {target!r}")
46+
return ext
47+
48+
49+
def _assoc_query(ext: str, assoc_str: int) -> Optional[str]:
50+
"""Run one AssocQueryStringW lookup; return the string or None."""
51+
shlwapi = ctypes.windll.shlwapi
52+
size = ctypes.c_ulong(0)
53+
shlwapi.AssocQueryStringW(0, assoc_str, ext, None, None, ctypes.byref(size))
54+
if size.value == 0:
55+
return None
56+
buf = ctypes.create_unicode_buffer(size.value)
57+
result = shlwapi.AssocQueryStringW(0, assoc_str, ext, None, buf,
58+
ctypes.byref(size))
59+
return buf.value if result == 0 else None
60+
61+
62+
def _default_resolver(ext: str) -> Dict[str, Any]:
63+
"""Resolve ``ext``'s registered app via the Windows shell API."""
64+
if not sys.platform.startswith("win"):
65+
raise RuntimeError(
66+
"file association lookup is Windows-only; pass resolver=")
67+
return {key: _assoc_query(ext, assoc_str)
68+
for key, assoc_str in _ASSOC_FIELDS.items()}
69+
70+
71+
def file_association(target: str, *,
72+
resolver: Optional[AssocResolver] = None) -> Dict[str, Any]:
73+
"""Return the app registered to open ``target``'s file type.
74+
75+
Returns ``{ext, command, exe, friendly, content_type}`` (the app fields are
76+
None when nothing is registered). Pass ``resolver`` (``ext -> dict``) to
77+
intercept the lookup in tests; the default uses the Windows shell API.
78+
"""
79+
ext = normalize_ext(target)
80+
resolve = resolver if resolver is not None else _default_resolver
81+
info = resolve(ext)
82+
return {"ext": ext,
83+
"command": info.get("command"),
84+
"exe": info.get("exe"),
85+
"friendly": info.get("friendly"),
86+
"content_type": info.get("content_type")}

je_auto_control/utils/mcp_server/tools/_factories.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2149,6 +2149,25 @@ def process_and_shell_tools() -> List[MCPTool]:
21492149
handler=h.allow_sleep,
21502150
annotations=SIDE_EFFECT_ONLY,
21512151
),
2152+
MCPTool(
2153+
name="ac_normalize_ext",
2154+
description=("Return the lowercased file extension (with leading "
2155+
"dot) of a path or bare extension (pure): {ext}."),
2156+
input_schema=schema({"target": {"type": "string"}},
2157+
required=["target"]),
2158+
handler=h.normalize_ext,
2159+
annotations=READ_ONLY,
2160+
),
2161+
MCPTool(
2162+
name="ac_file_association",
2163+
description=("Which application is registered to open a file type. "
2164+
"'target' is a path / .ext / bare ext. Returns {ext, "
2165+
"command, exe, friendly, content_type} (Windows)."),
2166+
input_schema=schema({"target": {"type": "string"}},
2167+
required=["target"]),
2168+
handler=h.file_association,
2169+
annotations=READ_ONLY,
2170+
),
21522171
]
21532172

21542173

je_auto_control/utils/mcp_server/tools/_handlers.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,16 @@ def allow_sleep():
593593
return _allow_sleep()
594594

595595

596+
def normalize_ext(target):
597+
from je_auto_control.utils.executor.action_executor import _normalize_ext
598+
return _normalize_ext(target)
599+
600+
601+
def file_association(target):
602+
from je_auto_control.utils.executor.action_executor import _file_association
603+
return _file_association(target)
604+
605+
596606
def get_clipboard() -> str:
597607
from je_auto_control.utils.clipboard.clipboard import get_clipboard as _get
598608
return _get()

0 commit comments

Comments
 (0)