[AdvancedPaste] Python scripts: docs, custom folder, auto-import detection, better errors

1. Script header documentation (doc/devdocs/modules/advancedpaste-python-scripts.md)
   - Complete reference for all @advancedpaste: header tags
   - Windows and WSL/Linux execution mode protocols
   - Declaring dependencies, security trust model, error handling
   - Example scripts for both platforms

2. Custom scripts folder setting in Settings UI
   - Added ScriptsFolder property to AdvancedPasteViewModel
   - Added SettingsCard with TextBox + Browse folder dialog in XAML
   - Added localization strings for the new setting

3. Auto-detect missing Python modules from import statements
   - Scans script body for import/from-import statements
   - Filters Python stdlib modules (CPython 3.12 set)
   - Well-known import-to-pip mapping table (pywin32, Pillow, opencv-python, etc.)
   - Merges auto-detected imports with explicit @advancedpaste:requires entries
   - Explicit requires always take precedence

4. Better error messages for Python script failures
   - Parses stderr to extract the final Python exception line
   - User-friendly summaries for ModuleNotFoundError, SyntaxError, etc.
   - ModuleNotFoundError includes pip install hint from the mapping table
   - Full traceback available in Details section of the error UI

Added 12 unit tests for MergeWithAutoDetectedImports and ParsePythonError.
Fixed IntegrationTestUserSettings mock to implement IUserSettings Python members.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Muyuan Li
2026-04-02 16:56:42 +08:00
parent 879163f48e
commit 4c7bf3df79
8 changed files with 792 additions and 15 deletions

View File

@@ -0,0 +1,203 @@
# Advanced Paste Python Scripts
Advanced Paste supports user-defined Python scripts that transform clipboard content. Scripts are
discovered automatically from a configurable folder and appear as actions in the Advanced Paste UI.
## Quick start
1. Open the scripts folder — by default `%LOCALAPPDATA%\Microsoft\PowerToys\AdvancedPaste\Scripts`.
You can change this in **Settings → Advanced Paste → Python scripts → Scripts folder**.
2. Drop a `.py` file into the folder.
3. Add the required header comments at the top (see [Header format](#header-format)).
4. Open the Advanced Paste UI (`Win+Shift+V`) — your script will appear in the action list.
## Header format
Every script must start with one or more **header comment lines**. Each line follows the pattern:
```
# @advancedpaste:<tag> <value>
```
The parser reads the first 50 lines of each file; only lines beginning with `#` are inspected.
### Supported tags
| Tag | Required | Description |
|-----|----------|-------------|
| `name` | **Yes** | Display name shown in the Advanced Paste UI. |
| `desc` | No | Short description / tooltip. |
| `formats` | No | Comma-separated list of supported clipboard formats. Defaults to **all** formats when omitted. |
| `platform` | No | `windows` (default) or `linux`. Determines the execution mode (see below). |
| `version` | No | Free-form version string (reserved for future use). |
| `requires` | No | Space-separated Python package requirements. See [Declaring dependencies](#declaring-dependencies). |
### Formats
| Value | Clipboard content |
|-------|--------------------|
| `text` | Plain or Unicode text (`CF_UNICODETEXT`) |
| `html` | HTML fragment (`CF_HTML`) |
| `image` | Bitmap / PNG image |
| `audio` | Audio file(s) |
| `video` | Video file(s) |
| `files` or `file` | File paths (`CF_HDROP` / `StorageItems`) |
| `any` | All of the above |
Multiple values can be combined with commas:
```python
# @advancedpaste:formats text,html
```
## Execution modes
### Windows mode (`platform windows`)
The script runs directly on Windows via the configured Python interpreter.
It **owns the clipboard** — use a library like `pywin32` (`win32clipboard`) to read
and write clipboard data.
**Invocation:**
```
python.exe "<script.py>" --format <detected_format> --work-dir "<temp_dir>"
```
**Minimal example — reverse text:**
```python
# @advancedpaste:name Reverse text
# @advancedpaste:formats text
# @advancedpaste:platform windows
import win32clipboard
win32clipboard.OpenClipboard()
text = win32clipboard.GetClipboardData(win32clipboard.CF_UNICODETEXT)
win32clipboard.EmptyClipboard()
win32clipboard.SetClipboardData(win32clipboard.CF_UNICODETEXT, text[::-1])
win32clipboard.CloseClipboard()
```
After the script exits with code 0, Advanced Paste re-reads the clipboard and pastes
the result. A non-zero exit code signals failure; stderr is shown in the error UI.
### WSL / Linux mode (`platform linux`)
The script runs inside WSL via `wsl.exe bash -l -c "python3 -X utf8 <script>"`.
Instead of direct clipboard access, data is exchanged via **JSON**:
| Direction | Channel | Schema |
|-----------|---------|--------|
| **Input** (C# → Python) | `stdin` (JSON) | See [Input payload](#input-payload) |
| **Output** (Python → C#) | `stdout` (JSON) | See [Output payload](#output-payload) |
#### Input payload
```jsonc
{
"version": 2,
"format": ["text"], // array of detected clipboard format names
"work_dir": "/mnt/c/...", // writable temp directory (WSL path)
"text": "Hello, world!", // present when clipboard has text
"html": "<b>Hello</b>", // present when clipboard has HTML
"image_path": "/mnt/c/.../input.png", // present when clipboard has an image
"file_paths": ["/mnt/c/.../file.txt"] // present when clipboard has files
}
```
#### Output payload
```jsonc
{
"result_type": "text", // "text" | "html" | "image" | "file" | "files"
"text": "HELLO, WORLD!", // for result_type "text"
"html": "<b>HELLO</b>", // for result_type "html"
"image_path": "/mnt/c/.../output.png", // for result_type "image"
"file_paths": ["/mnt/c/.../out.txt"] // for result_type "file"/"files"
}
```
> **Note:** File paths in the output must use `/mnt/<drive>/...` format so that
> Advanced Paste can map them back to Windows paths.
**Minimal example — uppercase text (WSL):**
```python
# @advancedpaste:name WSL Upper Case
# @advancedpaste:formats text
# @advancedpaste:platform linux
import sys, json
data = json.load(sys.stdin)
text = data.get("text", "")
json.dump({"result_type": "text", "text": text.upper()}, sys.stdout)
```
## Declaring dependencies
Use `requires` to declare Python packages the script needs:
```python
# @advancedpaste:requires markitdown='markitdown[all]'
# @advancedpaste:requires cv2=opencv-python-headless numpy requests
```
Each token is either:
- **`import_name`** — the pip package is assumed to have the same name (e.g. `requests`).
- **`import_name=pip_package`** — when the import name differs from the pip package
(e.g. `cv2=opencv-python-headless`, `PIL=Pillow`).
Multiple tokens on one line are space-separated. You can also use multiple `requires` lines.
### Automatic import detection
Advanced Paste also scans the script body for `import` and `from ... import` statements
and cross-references them against the Python standard library. Any non-stdlib import
that is not already installed triggers a prompt to install it automatically.
A built-in mapping table handles common mismatches (e.g. `win32clipboard``pywin32`,
`cv2``opencv-python`, `PIL``Pillow`). For uncommon packages where the import name
differs from the pip name, add an explicit `requires` entry.
## Security — script trust
The first time a script is executed (or after it has been modified), Advanced Paste
shows a confirmation dialog. Upon approval the SHA-256 hash of the script is stored.
Subsequent runs of the unchanged file skip the dialog.
## Error handling
When a script fails, Advanced Paste extracts the Python traceback from stderr and
displays a user-friendly summary in the UI:
- **ModuleNotFoundError** — identifies the missing module and suggests installing it.
- **SyntaxError** — shows the file and line number.
- **Timeout** — shows the configured timeout value (default 30 s; configurable in Settings).
- **Other errors** — shows the last line of the traceback as a summary, with the full
traceback available in the expandable *Details* section.
## Settings
The following settings are available under **Settings → Advanced Paste → Python scripts**:
| Setting | Description | Default |
|---------|-------------|---------|
| Python interpreter | Path to the Python executable. Leave blank for auto-detection. | *(auto-detect)* |
| Scripts folder | Folder to scan for `.py` scripts. | `%LOCALAPPDATA%\Microsoft\PowerToys\AdvancedPaste\Scripts` |
## Tips
- Put reusable helper functions in a separate `.py` file without a `# @advancedpaste:name`
header — it will be ignored by the script discovery and can be imported by other scripts.
- For complex WSL scripts that need packages not available via `apt`, consider using
a virtual environment. The script can re-exec itself with the venv interpreter:
```python
import os, sys
venv = os.path.expanduser("~/my_env/bin/python3")
if os.path.exists(venv) and sys.executable != venv:
os.execv(venv, [venv] + sys.argv)
```
- The `--work-dir` argument (Windows mode) and `work_dir` JSON field (WSL mode) point to
a temporary directory that is cleaned up after execution. Use it for intermediate files.