Skip to content
Back to skills

Pyinstaller Desktop Packaging

BSecurity

Build and distribute Windows desktop apps with PyInstaller — Flask/FastAPI backend (uvicorn) + WebView or browser, bundled into a single .exe. Covers onefile vs onedir tradeoffs, the COLLECT wipes-dat

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 6, 2026
developmentpythongoshellbashsqlexpressfastapiflasktestingdebugging

Works with

  • cli
  • api

Security analysis

B89/100
  • mediumUses curl or wget to download content
  • highPerforms destructive filesystem operations

Pro shows the line behind each finding and how to fix it

Scanned October 6, 2026

npx -y skills add Kairos-ai-agent/kairos-code --skill pyinstaller-desktop-packaging --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Pyinstaller Desktop Packaging?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Pyinstaller Desktop Packaging
[![Security: B — Skills Directory](https://www.skillsdirectory.com/api/skills/kairos-ai-agent-pyinstaller-desktop-packaging/badge)](https://www.skillsdirectory.com/skills/kairos-ai-agent-pyinstaller-desktop-packaging)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: "pyinstaller-desktop-packaging"
description: "Build and distribute Windows desktop apps with PyInstaller — Flask/FastAPI backend (uvicorn) + WebView or browser, bundled into a single .exe. Covers onefile vs onedir tradeoffs, the COLLECT wipes-dat"
priority: 0.5
imported-from: "hermes"
source-path: "hermes/skills/software-development/pyinstaller-desktop-packaging/SKILL.md"
---
# PyInstaller Desktop App Packaging

Class: building **desktop applications** with PyInstaller — a Python backend (Flask/FastAPI) bundled with a web UI (WebView or browser), distributed as a single `.exe` to non-technical Windows users.

Covers the full lifecycle: spec authoring, asset vendoring, data dir design, build, and post-build data preservation.

---

## When to load this skill

- User wants to convert a Flask/FastAPI backend + WebView frontend into a Windows `.exe`
- Choosing between **onefile** (single `.exe`) and **onedir** (`.exe` + `_internal/` folder) packaging
- "Can you package everything into one file?" / "做成单文件" / "封装到主 exe"
- PyInstaller build wipes a folder containing real data
- Frontend HTML loads CSS/JS from a public CDN and that fails in the target region
- A rebuild needs to preserve user data in the dist folder
- Windowed EXE (no console) launches but exits silently → suspect the uvicorn `isatty()` crash
- Patched a Python file with `patch()`, the build still fails, and `ast.parse()` errors out — start with the patch-tool pitfall section
- **Tkinter / PySide6 app with no HTTP backend** — there is no port to `curl`; verify with a `--selftest` exit-code contract → "GUI-only apps: verify with `--selftest`" and `references/gui-selftest-and-headless-testing.md`
- Rebuild dies at the last step with `PermissionError [WinError 5]` (or at the first step with `[WinError 32]`) on `dist/<name>.exe` → a running instance holds the file lock; the rule applies to ANY onefile exe, not just FastAPI (see `references/fastapi-spa-shadow-and-windows-filelock.md`)
- A frozen GUI/tool app "starts but a feature silently does nothing" → suspect relative-path data files and `.env`/config that no longer resolve after freezing (see the spec-fields and writable-dir sections)

---

## Critical pitfall — PyInstaller COLLECT wipes target folder

**Symptom:** You rebuild with `pyinstaller spec.spec --distpath dist/赛光智能ERP_v2`, and PyInstaller's `COLLECT` step runs `Removing dir dist/赛光智能ERP_v2` first. Any user data (`data/sg_erp.db`, `uploads/`, `backups/`) inside that folder is **silently deleted**.

**Mandatory workflow before every rebuild:**

1. **Back up the data folder** from the dist target:
   ```bash
   cp -r dist/赛光智能ERP_v2/data /tmp/data_backup
   cp -r dist/赛光智能ERP_v2/uploads /tmp/uploads_backup
   cp -r dist/赛光智能ERP_v2/backups /tmp/backups_backup
   ```
2. Run pyinstaller (onefile or onedir, doesn't matter — both wipe).
3. **Restore data + side folders** after build:
   ```bash
   mkdir -p dist/赛光智能ERP_v2/data
   cp /tmp/data_backup/* dist/赛光智能ERP_v2/data/
   cp -r dist/SG_ERP/uploads dist/赛光智能ERP_v2/  # or wherever the templates live
   cp -r dist/SG_ERP/backups dist/赛光智能ERP_v2/
   cp dist/SG_ERP/sg_erp.ico dist/赛光智能ERP_v2/
   ```
4. For onefile mode, also delete the stale `_internal/` folder left over from the previous onedir build.

Or use the bundled helper script (see "Bundled resources" below) which does both halves:
```bash
# Before build
python scripts/backup_dist.py dist/赛光智能ERP_v2

# ... run pyinstaller ...

# After build
python scripts/backup_dist.py dist/赛光智能ERP_v2 --restore-from /tmp/pyinstaller_prebuild_赛光智能ERP_v2_<ts>
```

**Why this happens:** COLLECT is the onedir packing step. It cleans its output dir before writing. If your dist output dir happens to be the user's install dir, their data dies with it.

**Recovery if you forgot:** the previous build's `_internal/` is gone, but data often survives in `dist/<other-name>/data/` if you've ever built with a different spec name. Otherwise, the user is restoring from backups — they will be angry.

---

## Onefile vs onedir decision

| | onedir (default) | onefile |
|--|--|--|
| Output | `app.exe` + `_internal/` folder | single `app.exe` (~60-150MB) |
| Startup | instant | 1-3s self-extract to `%TEMP%\_MEIxxxx` |
| Asset access | `sys._MEIPASS` points at `_internal/` | same, just transparent |
| Distribute | zip the whole `app/` folder | copy one file |

**Pick onefile when:** the user wants "just one exe", the app is internal/limited-distribution, and 1-3s startup is acceptable.
**Pick onedir when:** startup speed matters, debugging is ongoing, or you want hot-swappable assets.

**Spec difference:**
- onedir uses both `EXE(...)` and `COLLECT(exe, a.binaries, a.zipfiles, a.datas, name=...)`
- onefile uses only `EXE(pyz, a.scripts, a.binaries, a.zipfiles, a.datas, ..., name=...)` — no COLLECT

---

## Read-only vs writable split

The `.exe` (and its `_internal/` if onedir) is **read-only at runtime** — PyInstaller unpacks to a temp folder that's not meant for user writes. Any user data MUST live next to the `.exe`.

**Standard layout:**
```
MyApp/
├── MyApp.exe               # read-only at runtime (extracted to temp)
├── app.ico                 # optional, used by Windows shell
├── data/                   # writable: SQLite DB, config
├── uploads/                # writable: user files
└── backups/                # writable: backups
```

**Pattern in code (desktop.py / launcher):**
```python
if getattr(sys, 'frozen', False):
    BUNDLE_DIR = sys._MEIPASS              # read-only, has templates/static
    APP_DIR = os.path.dirname(sys.executable)  # writable, next to exe
else:
    BUNDLE_DIR = os.path.dirname(os.path.abspath(__file__))
    APP_DIR = BUNDLE_DIR

# config: app.template_folder = os.path.join(BUNDLE_DIR, 'templates')
# db path: sqlite:///{APP_DIR}/data/app.db
```

Any Flask route that writes (DB writes, file uploads, exports) must use APP_DIR paths. Templates, static assets, Python source — BUNDLE_DIR.

**Same rule for `.env` / side config, and `load_dotenv()` does NOT do it for you.** After
freezing, `load_dotenv()` walks up from the caller's file — which lives in the temp
`_MEIPASS` directory — so it never finds the project's `.env`. The exe starts fine and any
feature behind that key silently falls back to a degraded path (invisible with
`console=False`). Load candidates explicitly, in priority order:

```python
candidates = []
if getattr(sys, 'frozen', False):
    candidates.append(Path(sys.executable).parent / '.env')   # next to the exe — document this
candidates.append(Path.cwd() / '.env')
appdata = os.getenv('APPDATA')
if appdata:
    candidates.append(Path(appdata) / 'AppName' / '.env')     # per-user, survives moving the exe
candidates.append(Path(__file__).resolve().parent.parent / '.env')  # dev checkout
for cand in candidates:
    if cand.is_file():
        load_dotenv(cand, override=False); break
```

Read the values **after** this runs (build config objects inside `main()`, not at import time
before the loader), and tell the user in the README where to drop the file — otherwise the
deployed app quietly loses the feature when it is copied to another machine.

---

## Vendor CDN assets for offline / China distribution

**Symptom:** HTML loads `https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/...`. User in China: CDN blocked → no CSS → entire page is unstyled/messy.

**Fix:** download assets to `static/`, change templates to local paths.

```python
import urllib.request, os
UA = 'Mozilla/5.0'
SOURCES = {
    'bootstrap.min.css': [
        'https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css',
        'https://unpkg.com/bootstrap@5.3.3/dist/css/bootstrap.min.css',
        'https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/css/bootstrap.min.css',
        'https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/5.3.3/css/bootstrap.min.css',  # China mirror
    ],
    'bootstrap-icons.min.css': [
        'https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css',
        # ... fallbacks
    ],
}
# Download with fallback chain — agent's host usually has CDN access even when user's browser doesn't
for fname, urls in SOURCES.items():
    for url in urls:
        try:
            urllib.request.urlretrieve(url, f'static/css/{fname}')
            break
        except: continue

# bootstrap-icons references fonts/ via relative URL — keep the same relative layout
urllib.request.urlretrieve(
    'https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/fonts/bootstrap-icons.woff2',
    'static/css/fonts/bootstrap-icons.woff2'
)
# bootstrap.bundle.min.js too
urllib.request.urlretrieve(
    'https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js',
    'static/js/bootstrap.bundle.min.js'
)
```

**Template changes:**
```html
<!-- before -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>

<!-- after -->
<link href="/static/css/bootstrap.min.css" rel="stylesheet">
<link href="/static/css/bootstrap-icons.min.css" rel="stylesheet">
<script src="/static/js/bootstrap.bundle.min.js"></script>
```

**After build, also patch the bundled `_internal/templates/*.html`** — PyInstaller's COLLECT step copies templates into `_internal/`, but the patches you've made to source `templates/` don't propagate automatically. Same for `static/` (it DOES propagate via the `datas` spec, but verify with a curl test).

---

## Spec file naming for non-default output paths

If the user has a custom exe name like `赛光智能ERP_v2` instead of the spec default, the build will go to `dist/<spec_name>/`. Two-step pattern:

```bash
cp sg_erp.spec sg_erp.spec.bak
sed -i "s/name='赛光智能ERP',/name='赛光智能ERP_v2',/g" sg_erp.spec
pyinstaller sg_erp.spec --distpath dist --workpath build --clean -y
# ... restore and clean up
mv sg_erp.spec.bak sg_erp.spec
rm -rf build
```

The pyinstaller output for onefile mode is `dist/<name>.exe` (single file, no subfolder) — move it into the user's expected install folder next to the data dirs:
```bash
mv dist/赛光智能ERP_v2.exe dist/赛光智能ERP_v2/赛光智能ERP_v2.exe
```

---

## Spec template (onefile, copy and edit)

```python
# -*- mode: python ; coding: utf-8 -*-
"""PyInstaller onefile spec — single .exe, all assets bundled."""
import os

block_cipher = None
base_dir = os.path.dirname(os.path.abspath(SPEC))

a = Analysis(
    ['desktop.py'],
    pathex=[base_dir],
    binaries=[],
    datas=[
        ('templates', 'templates'),  # bundled read-only
        ('static', 'static'),
        ('app.ico', '.'),
    ],
    hiddenimports=[...],  # list all imports used by code that PyInstaller can't auto-detect
    excludes=['tkinter', 'matplotlib', 'numpy', 'scipy', 'pandas'],
    cipher=block_cipher,
    noarchive=False,
)

pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)

exe = EXE(
    pyz, a.scripts, a.binaries, a.zipfiles, a.datas, [],
    name='YourAppName',
    debug=False,
    console=True,   # see "Console and runtime UX" section below
    disable_windowed_traceback=False,  # see below
    icon='app.ico',
)
```

**Spec fields that silently break the shipped app (check every rebuild):**

- **`datas=[]` — the classic.** If the code loads `config/*.json`, `templates/`, or any data file
  by a path relative to `__file__`, an empty `datas` builds perfectly and **crashes at launch**
  with `FileNotFoundError: <_MEIPASS>\modules\..\config\x.json`. `os.path.dirname(__file__)/..`
  resolves correctly under `_MEIPASS` — the only missing piece is the `datas` entry. Symptoms
  look like "the exe is broken" but the source runs fine.
- **Keep exactly ONE spec file as the source of truth.** A drawer of stale sibling specs
  (`App.spec`, `App_v2.spec`, `App_v3.spec`) guarantees that sooner or later the build runs the
  one whose `datas`/`hiddenimports` were stripped during debugging. Delete the dead ones, and
  make `build.py` call the spec instead of hand-writing PyInstaller flags — a typed flag list
  is where `"--icon", None`-style breakage lives.
- **`hiddenimports=[]`** mostly works (PyInstaller auto-detects top-level imports) but fails for
  dynamically imported modules; list your own package's modules explicitly.
- **Never bundle `.env` or credentials** — see "Read-only vs writable split" for where the frozen
  app must look for them instead.

## Console and runtime UX

Two flags control what the user sees when they double-click the exe:

| Flag | `True` (default for GUI) | `False` |
|--|--|--|
| `console` | A black `cmd` window opens behind/with the GUI. Flask logs, prints, tracebacks all visible there. | No console window. App is pure GUI. Flask logs/prints go to NUL. **Process is fully invisible** from a UX perspective — Task Manager shows just `YourApp.exe`. |
| `disable_windowed_traceback` | On unhandled exception, a Windows message box pops up with the traceback. User sees the crash. | Silent crash — process just exits. Safer for production (no scary traceback popup) but debugging requires you to set `console=True` first. |

**Recommended combo for shipped desktop apps:**

```python
console=False,                  # no black window, no visible cmd
disable_windowed_traceback=False,  # still show traceback popup if it crashes (better than silent exit)
```

**During development / debugging:**

```python
console=True,                   # see Flask request logs in real time
disable_windowed_traceback=False,
```

**Why `disable_windowed_traceback=False` is safer even in production:** with `console=False` and a real bug, the app just exits silently and the user thinks "the program is broken, weird". A traceback popup at least tells them something went wrong. Switch to `True` only if you've thoroughly QA'd and want a perfectly clean exit-on-crash UX.

**Note on Task Manager / `tasklist`:** Flask runs as a thread inside the exe (started in `desktop.py`'s `start_flask`), so the user only ever sees ONE process. Even with port 5000 open and serving requests, there's no separate `python.exe` or `flask.exe` to hide. Verify after build:

```bash
tasklist | grep -i "YourAppName"   # exactly one row
netstat -ano | grep ":5000"        # the same PID owns the port
```

## GUI-only apps (no HTTP backend): verify with `--selftest`

A GUI-only app (Tkinter, PySide6) has no endpoint to probe, and `console=False` means a
startup crash prints nothing anywhere. Verify it the same way you would verify a service —
with an exit code:

1. Add a headless `--selftest` branch to the entry point that loads bundled data and runs the
   core pipeline, returns 0/1, and **also writes the report to a file** (windowed EXEs lose
   stdout): `App.exe --selftest; echo "EXIT=$?"; cat selftest_result.txt`.
2. Run it from `build.py` right after packaging and fail the build if it does not print
   `RESULT: PASS` (delete the stale result file first).
3. Confirm the window itself starts — exit code alone does not prove the GUI opens:
   `powershell -NoProfile -Command "Get-Process AppName | Select-Object Id,Responding,MainWindowTitle"`
   → `Responding=True` + non-empty title.
4. Close the instance you launched before any further build (it locks `dist/App.exe`).

Full recipes — including a headless Tk driver that verifies UI/layout changes by driving the
real handlers and asserting on the produced artifact — are in
`references/gui-selftest-and-headless-testing.md`.

---

## Verification checklist after build

```bash
# 1. Static assets served?
for url in /static/css/*.css /static/js/*.js /static/img/*.png; do
  curl -s -o /dev/null -w "%{http_code} %{size_download}B $url\n" http://127.0.0.1:5000$url
done

# 2. No CDN refs in served HTML?
curl -s http://127.0.0.1:5000/login | grep -E "cdn|jsdelivr|unpkg" && echo "BAD" || echo "GOOD"

# 3. App responds to login flow?
curl -s -c /tmp/c.txt -X POST http://127.0.0.1:5000/login -d "username=admin&password=admin123"

# 4. DB still has data?
sqlite3 data/app.db "SELECT count(*) FROM <main_table>;"

# 5. Single process visible (Flask is a thread, not separate process)
tasklist | grep -i "YourAppName"          # exactly one row
netstat -ano | grep ":5000"               # the same PID owns the port
```

All five should pass. If 1-2 fail, you forgot to update the bundled `_internal/` (templates/static) — they don't auto-sync from source.

---

## Common `hiddenimports` for Flask + WebView stacks

```python
hiddenimports=[
    # pywebview EdgeChromium
    'clr', 'webview.platforms', 'webview.platforms.edgechromium',
    # Flask ecosystem
    'flask', 'flask_cors', 'flask_login', 'flask_sqlalchemy', 'flask_wtf',
    'werkzeug', 'sqlalchemy', 'sqlalchemy.sql.default_comparator',
    'jinja2', 'markupsafe', 'dateutil',
    # Reports/exports
    'fpdf', 'fpdf.fonts',
    'openpyxl', 'openpyxl.styles', 'openpyxl.cell', 'openpyxl.workbook',
    # Windows integration
    'win32com.client',
]
```

---

## Windowed mode + uvicorn isatty crash (FastAPI apps)

**Symptom:** FastAPI / Starlette app packaged with PyInstaller `--windowed` (or `--noconsole`) blows up at startup with:

```
AttributeError: 'NoneType' object has no attribute 'isatty'
  File "uvicorn\logging.py", line 42, in __init__
ValueError: Unable to configure formatter 'default'
  File "uvicorn\config.py", line 391, in configure_logging
  File "logging\config.py", line 823, in dictConfig
```

The launcher exits silently — no window, no log, no message.

**Why:** `runw.exe` (the PyInstaller windowed bootloader) gives the embedded Python interpreter **no real stdin/stdout/stderr handles** — `sys.stdout` / `sys.stderr` are literally `None`. uvicorn's default `LOGGING_CONFIG["formatters"]["default"]` is `uvicorn.logging.DefaultFormatter`, whose `__init__` does:

```python
self.use_colors = sys.stdout.isatty()  # AttributeError when stdout is None
```

`logging.dictConfig` catches the AttributeError, then raises `ValueError: Unable to configure formatter 'default'`, which kills `uvicorn.Config(...)` before the server can start.

**Why `--windowed` and not `--console`:** `--console` works around it by giving the process a real console (so `sys.stdout` is a real file), but it pops a black `cmd` window behind your GUI — useless for desktop apps. `--windowed` is the right choice; the launcher just has to compensate.

**Fix — pass a custom `log_config` to `uvicorn.Config`** that forces `use_colors=False` (skipping the isatty probe) and routes handlers to a safe stream:

```python
import sys, os
from pathlib import Path

def _build_log_config(log_dir: Path) -> dict:
    # If stderr exists (run-from-console dev), use it. Otherwise fall back
    # to a file under the user log dir so the EXE never crashes on no-console.
    if sys.stderr is not None:
        stream = "ext://sys.stderr"
    else:
        log_dir.mkdir(parents=True, exist_ok=True)
        stream = str(log_dir / "uvicorn.log")
    return {
        "version": 1,
        "disable_existing_loggers": False,
        "formatters": {
            "default": {
                "()": "uvicorn.logging.DefaultFormatter",
                "fmt": "%(levelprefix)s %(message)s",
                "use_colors": False,   # <-- critical, skips isatty()
            },
            "access": {
                "()": "uvicorn.logging.AccessFormatter",
                "fmt": '%(levelprefix)s %(client_addr)s - '
                       '"%(request_line)s" %(status_code)s',
                "use_colors": False,
            },
        },
        "handlers": {
            "default": {"formatter": "default", "class": "logging.StreamHandler", "stream": stream},
            "access":  {"formatter": "access",  "class": "logging.StreamHandler", "stream": stream},
        },
        "loggers": {
            "uvicorn":        {"handlers": ["default"], "level": "INFO", "propagate": False},
            "uvicorn.error":  {"level": "INFO"},
            "uvicorn.access": {"handlers": ["access"],  "level": "INFO", "propagate": False},
        },
    }

# In your launcher's main():
config = uvicorn.Config(
    app, host=args.host, port=port,
    log_level="info", access_log=False,
    log_config=_build_log_config(log_dir),   # <-- the fix
)
```

**Also fix your launcher's `print()` calls.** `print()` raises `ValueError: I/O operation on closed file` when `sys.stdout` is None. Wrap them in a helper that tries stdout, falls back to stderr, and finally swallows:

```python
def _emit(line: str) -> None:
    try:
        print(line); return
    except Exception:
        pass
    try:
        if sys.stderr is not None:
            sys.stderr.write(line + "\n")
            sys.stderr.flush()
    except Exception:
        pass
```

**How to reproduce in a dev env** (so you catch this BEFORE shipping):

```python
# Run in a subprocess with stdio detached
import subprocess, os, sys
src = '''
import sys
sys.stdout = None   # simulate runw.exe
sys.stderr = None
sys.stdin  = None
import uvicorn
from api.app import app
try:
    c = uvicorn.Config(app, host="127.0.0.1", port=8900)
    with open("windowed_test.log", "w") as f:
        f.write("Config OK\\n")
except Exception as e:
    import traceback
    with open("windowed_test.log", "w") as f:
        traceback.print_exc(file=f)
'''
subprocess.run([sys.executable, "-c", src],
               stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
               stderr=subprocess.DEVNULL, check=False)
```

If `windowed_test.log` contains `Config OK`, your log_config is safe. If it contains an `AttributeError` traceback, the bug is live.

**Related symptom:** a few other libraries also probe `sys.stdout.isatty()` on import — `rich`, `colorama`, `click` in some modes. If you ship those in a windowed EXE, expect them to either silently disable colors (best case) or crash (worst case). Test with `sys.stdout = sys.stderr = None` once before shipping.

---

## Patch-tool indentation pitfall (Python)

Three ways the `patch` tool can produce a Python file that `ast.parse()` rejects — and a quick check that catches all of them before you ship.

### A) Over-indented body (most common)

**Symptom:** You use `patch(mode='replace')` on a Python file (e.g. `app.py`) to insert a new function. After the patch, `python -c "import ast; ast.parse(...)"` reports `IndentationError: unexpected indent` on lines you just added. Every line in your new content is exactly **4 spaces too far right**.

**Why this happens:** The Hermes `patch` tool's `new_string` carries over the indentation of the line that matched `old_string`'s last line. If you paste a fresh `def foo():` block (at 4-space top-level indent), but the patch tool anchors it next to a line that was at 12 spaces deep inside some other function, every line of your new block gets +4 spaces for each level of nesting the anchor lived in. The lint warning at the bottom of the patch result tells you — don't ignore it.

**Reproduction (sg-erp case):** I patched `app.py` to insert a new `@app.route` + `def api_*():` block. The anchor was at 4-space indent (module level inside `create_app`). My `new_string` was already correctly indented at 4 spaces for `def`. But every **body line** of the new block — already at 8 spaces — came out at **12 spaces** because the patch tool added one level of nesting. Result: `IndentationError`.

**Fix — three patterns:**

1. **Quickest:** after every patch on Python code, run `python -c "import ast; ast.parse(open('app.py').read())"` (or equivalent). If it fails, dedent the patched region by 4 spaces:

   ```python
   # Find the patched region (lines start_at..end_at in the diff)
   with open('app.py', 'r', encoding='utf-8') as f:
       lines = f.readlines()
   for j in range(start_at, end_at):
       if lines[j].startswith('            '):  # 12 -> 8
           lines[j] = lines[j][4:]
       elif lines[j].startswith('                '):  # 16 -> 12
           lines[j] = lines[j][4:]
       with open('app.py', 'w', encoding='utf-8') as f:
           f.writelines(lines)
   ```

   Dedent by however many levels the patch tool over-indented (usually 1 = 4 spaces).

2. **Defensive (if `old_string` was clean):** paste the new block **at the same exact indent** as the line below where it'll go, so the patch tool's "anchor" lands on a line at the right depth. e.g. if you're inserting after a 4-space `@app.route`, make sure your `new_string`'s first line is also at 4 spaces.

3. **For multi-line replacements where you can't get the anchor right:** rewrite the entire target function/file via `write_file` instead of `patch`. Slower but no indentation drift.

**Why this matters for desktop packaging work specifically:** every packaging change tends to involve patching `app.py` (new endpoints, route additions, model field changes). You'll hit this every rebuild cycle if you don't check syntax after each patch.

### B) `module.__getattr__` doesn't rescue class-statement base expressions

**Symptom:** You added a `__getattr__` to break a circular import — `class Coder(KairosAgent):` references `KairosAgent`, but you lazy-loaded it via:

```python
def __getattr__(name):
    if name in ['KairosAgent']:
        import kairos.agents.base as _m
        return getattr(_m, name)
```

Now `import kairos.agents.roles.coder` raises `NameError: name 'KairosAgent' is not defined`.

**Why:** module-level `__getattr__` only fires for **attribute access** on the module (`module.foo`). It does **not** fire for **name lookups inside a class statement**. When Python evaluates `class Coder(KairosAgent):`, it looks up `KairosAgent` in the module's globals dict — not via attribute access. The `__getattr__` shim never gets a chance to run.

The same trap fires for any name referenced in a class body: base classes, decorators that are bare names, metaclass= arguments.

**Fix:** if a class-statement needs a name, the name **must be a top-level binding** in the module. Either:

- Restore the direct import: `from kairos.agents.base import KairosAgent` (simplest — works unless there's a real circular dependency, in which case the fix is to break the cycle in the dependency, not to defer the name lookup).
- Or move the lazy machinery to **import time** via a metaclass / `__init_subclass__` / dynamic `type(name, bases, dict)` call — but those are much heavier than just fixing the import.

**Why this matters for PyInstaller work:** the lazy `__getattr__` pattern gets used during packaging to dodge PyInstaller's eager-import analyzer. If the lazy-loaded name is then referenced in a class statement, the analyzer's lazy-by-default vs. eager-on-class-statement asymmetry will bite you — and it only shows up at module import time inside the EXE, never in a normal dev run (where the imports happen in dependency order).

### C) Literal `\n` in `new_string` makes it through as escaped text

**Symptom:** Your `patch(mode='replace')` lands cleanly (no lint warning), but `python -c "import ast; ast.parse(...)"` errors with `SyntaxError: unexpected character after line continuation character` somewhere in the patched region. The patched source contains literal two-character sequences `\` + `n` instead of newlines.

**Why:** if you write a multi-line `old_string` or `new_string` as a Python string literal that itself contains `\n` escapes, those escapes **do get interpreted as newlines** — but only at the **outer Python layer** that produces them. Inside the patch tool's transport, if the JSON envelope gets any backslash-escaped form, you may end up writing the literal characters `\` and `n` into the file instead of a newline.

**Fix patterns that keep you safe:**

- Pass the entire patch payload through `json.dumps(...)` first to see what gets sent — every `\n` should appear as a literal newline character, not as the two characters `\` `n`.
- After the patch, run the syntax check. If it fails with "line continuation", search for backslash-newline in the patched region and replace with literal newlines:

  ```python
  src = open(p, encoding='utf-8').read()
  # Where the patch landed, replace accidental "\n" with real newlines
  fixed = src.replace('\\\n', '\n')  # backslash-newline -> newline
  open(p, 'w', encoding='utf-8').write(fixed)
  ```

- For long multi-element list literals (a common case where this bites — e.g. constructing a long CLI argv list), prefer **breaking the list across multiple lines** in `new_string` and using actual newlines rather than embedding `\n` inside a single-line string. The patch tool handles multi-line `new_string` reliably; the issue is specifically with embedded escape sequences.

### Universal safety net

After **every** `patch()` on a Python file, run this before rebuilding:

```bash
python -c "import ast; ast.parse(open('PATH/TO/file.py', encoding='utf-8').read())" \
  && echo OK || echo FAIL
```

Three seconds, catches all three pitfalls above.

For the full project (catches syntax errors hiding in unvisited modules too):

```python
import os, ast
for dp, _, fn in os.walk('.'):
    if '__pycache__' in dp: continue
    for f in fn:
        if f.endswith('.py'):
            p = os.path.join(dp, f)
            try: ast.parse(open(p, encoding='utf-8').read())
            except SyntaxError as e:
                print(f'{p}:{e.lineno}  {e.msg}')
```

---

## Absorbed GUI-build sub-skills (consolidated umbrella)

This is the umbrella for **building & distributing Windows desktop Python apps** to non-technical users, and covers the shared concern of the smart `.bat` launcher that handles multi-Python-version environments. Two sibling GUI-build approaches were consolidated here; their full bodies live under `references/`:

- **PySide6 / Qt native GUI** — transparent frameless windows, click-through masks, system tray, multi-monitor, mouse-drag state machines, and the smart `.bat` launcher for multi-Python (hermes venv 3.11 + MS Store 3.13). → `references/pyside6-windows-gui-app/`
- **Local AI model GUI (Gradio)** — package Flux / SD / LLM as a standalone Gradio desktop app; Windows setup, low-VRAM (6GB) optimization, China mirror config, batch launcher, smart environment bootstrap. → `references/local-ai-gui/`

## Bundled resources

- **`scripts/backup_dist.py`** — pre-build backup / post-build restore helper for the COLLECT-wipes-target pitfall. Run `python scripts/backup_dist.py dist/MyApp` before each `pyinstaller` invocation, then `python scripts/backup_dist.py dist/MyApp --restore-from /tmp/pyinstaller_prebuild_MyApp_<ts>` after.
- **`references/sg-erp-case-study.md`** — concrete worked example of this whole pattern against a real Flask + pywebview ERP app (赛光智能ERP_v2). Includes the actual spec file, the CDN-vendoring fix for China, and the data-wipe recovery procedure.
- **`references/gui-selftest-and-headless-testing.md`** — verifying a GUI-only app (Tkinter/PySide6, no HTTP backend): the `--selftest` exit-code contract wired into `build.py`, the headless Tk driver that tests UI changes without a human, the three-layer grep for removed form fields, and the `Get-Process` liveness check.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…