-
+
-**Blazingly fast rendering library for native desktop windows, Jupyter widgets, and browser tabs.**
+
-Full bidirectional Python ↔ JavaScript communication. Get started in minutes, not hours.
+PyWry is a cross-platform rendering engine and desktop UI toolkit for Python. One API, three output targets:
-[](https://pypi.org/project/pywry/)
-[](https://pypi.org/project/pywry/)
-[](LICENSE)
-[](https://deeleeramone.github.io/PyWry/)
+- **Native window** — OS webview via [PyTauri](https://pypi.org/project/pytauri/). Not Qt, not Electron.
+- **Jupyter widget** — anywidget + FastAPI + WebSocket, works in JupyterLab, VS Code, and Colab.
+- **Browser tab** — FastAPI server with Redis state backend for horizontal scaling.
-
+## Installation
----
-
-PyWry is **not** a web dashboard framework. It is a **rendering engine** that targets three output paths from one unified API:
-
-| Mode | Where It Runs | Backend |
-|------|---------------|---------|
-| `NEW_WINDOW` / `SINGLE_WINDOW` / `MULTI_WINDOW` | Native OS window | PyTauri (Tauri/Rust) subprocess using OS webview |
-| `NOTEBOOK` | Jupyter / VS Code / Colab | anywidget or IFrame + FastAPI + WebSocket |
-| `BROWSER` | System browser tab | FastAPI server + WebSocket + Redis |
-
-Built on [PyTauri](https://pypi.org/project/pytauri/) (Rust's [Tauri](https://tauri.app/) framework), it uses the OS webview instead of bundling a browser engine — a few MBs versus Electron's 150MB+ overhead.
-
-
-Features at a Glance
-
-| Feature | What It Does |
-|---------|--------------|
-| **Native Windows** | Lightweight OS webview windows (not Electron) |
-| **Jupyter Widgets** | Works in notebooks via anywidget with traitlet sync |
-| **Browser Mode** | Deploy to web with FastAPI + WebSocket |
-| **Toolbar System** | 18 declarative Pydantic components with 7 layout positions |
-| **Two-Way Events** | Python ↔ JavaScript with pre-wired Plotly/AgGrid events |
-| **Modals** | Overlay dialogs with toolbar components inside |
-| **AgGrid Tables** | Pandas → AgGrid conversion with pre-wired grid events |
-| **Plotly Charts** | Plotly rendering with custom modebar buttons and plot events |
-| **Toast Notifications** | Built-in alert system with configurable positioning |
-| **Theming & CSS** | Light/dark modes, 60+ CSS variables, hot reload |
-| **Secrets Handling** | Server-side password storage, never rendered in HTML |
-| **Security** | Token auth, CSP headers, production presets |
-| **Configuration** | Layered TOML files, env vars, security presets |
-| **Hot Reload** | Live CSS injection and JS updates during development |
-| **Deploy Mode** | Redis state backend for horizontal scaling |
-| **MCP Server** | AI agent integration via Model Context Protocol |
-
-
+Python 3.10–3.14, virtual environment recommended.
-## Installation
+```bash
+pip install pywry
+```
-Requires Python 3.10–3.14. Install in a virtual environment.
+| Extra | When to use |
+|-------|-------------|
+| `pip install 'pywry[notebook]'` | Jupyter / anywidget integration |
+| `pip install 'pywry[mcp]'` | MCP server for AI agents |
+| `pip install 'pywry[freeze]'` | PyInstaller hook for standalone executables |
+| `pip install 'pywry[all]'` | Everything above |
-
-Linux Prerequisites
+**Linux only** — install system webview dependencies first:
```bash
-# Ubuntu/Debian
sudo apt-get install libwebkit2gtk-4.1-dev libgtk-3-dev libglib2.0-dev \
libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 \
libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 \
libxcb-shape0 libgl1 libegl1
```
-
-
-```bash
-pip install pywry
-```
-
-| Extra | Command | Description |
-|-------|---------|-------------|
-| **notebook** | `pip install 'pywry[notebook]'` | anywidget for Jupyter integration |
-| **mcp** | `pip install 'pywry[mcp]'` | Model Context Protocol server for AI agents |
-| **all** | `pip install 'pywry[all]'` | All optional dependencies |
-
-> See [Installation Guide](https://deeleeramone.github.io/PyWry/getting-started/installation/) for full details.
-
----
-
## Quick Start
-### Hello World
-
```python
from pywry import PyWry
app = PyWry()
-
app.show("Hello World!")
-
-app.block() # block the main thread until the window closes
+app.block()
```
-### Interactive Toolbar
+### Toolbar + callbacks
```python
from pywry import PyWry, Toolbar, Button
@@ -102,117 +52,82 @@ from pywry import PyWry, Toolbar, Button
app = PyWry()
def on_click(data, event_type, label):
- app.emit("pywry:set-content", {"selector": "h1", "text": "Toolbar Works!"}, label)
+ app.emit("pywry:set-content", {"selector": "h1", "text": "Clicked!"}, label)
-toolbar = Toolbar(
- position="top",
- items=[Button(label="Update Text", event="app:click")]
-)
-
-handle = app.show(
- "
Hello, World!
",
- toolbars=[toolbar],
+app.show(
+ "
Hello
",
+ toolbars=[Toolbar(position="top", items=[Button(label="Click me", event="app:click")])],
callbacks={"app:click": on_click},
)
+app.block()
```
-### DataFrame → AgGrid
+### Pandas DataFrame → AgGrid
```python
from pywry import PyWry
import pandas as pd
app = PyWry()
-
df = pd.DataFrame({"name": ["Alice", "Bob", "Carol"], "age": [30, 25, 35]})
def on_select(data, event_type, label):
names = ", ".join(row["name"] for row in data["rows"])
- app.emit("pywry:alert", {"message": f"Selected: {names}" if names else "None selected"}, label)
+ app.emit("pywry:alert", {"message": f"Selected: {names}"}, label)
-handle = app.show_dataframe(df, callbacks={"grid:row-selected": on_select})
+app.show_dataframe(df, callbacks={"grid:row-selected": on_select})
+app.block()
```
-### Plotly Chart
+### Plotly chart
```python
-from pywry import PyWry, Toolbar, Button
+from pywry import PyWry
import plotly.express as px
app = PyWry(theme="light")
-
fig = px.scatter(px.data.iris(), x="sepal_width", y="sepal_length", color="species")
-
-handle = app.show_plotly(
- fig,
- toolbars=[Toolbar(position="top", items=[Button(label="Reset Zoom", event="app:reset")])],
- callbacks={"app:reset": lambda d, e, l: app.emit("plotly:reset-zoom", {}, l)},
-)
+app.show_plotly(fig)
+app.block()
```
-> See [Quick Start Guide](https://deeleeramone.github.io/PyWry/getting-started/quickstart/) and [Examples](https://deeleeramone.github.io/PyWry/examples/) for more.
-
----
-
-## Components
-
-PyWry includes 18 declarative toolbar components, all Pydantic models with 7 layout positions (`header`, `footer`, `top`, `bottom`, `left`, `right`, `inside`):
-
-| Component | Description |
-|-----------|-------------|
-| **Button** | Clickable button — primary, secondary, neutral, ghost, outline, danger, warning, icon |
-| **Select** | Dropdown select with `Option` items |
-| **MultiSelect** | Multi-select dropdown with checkboxes |
-| **TextInput** | Text input with debounce support |
-| **SecretInput** | Secure password input — values stored server-side, never in HTML |
-| **TextArea** | Multi-line text area |
-| **SearchInput** | Search input with debounce |
-| **NumberInput** | Numeric input with min/max/step |
-| **DateInput** | Date picker |
-| **SliderInput** | Slider with optional value display |
-| **RangeInput** | Dual-handle range slider |
-| **Toggle** | Boolean toggle switch |
-| **Checkbox** | Boolean checkbox |
-| **RadioGroup** | Radio button group |
-| **TabGroup** | Tab navigation |
-| **Div** | Container element for content/HTML |
-| **Marquee** | Scrolling ticker — scroll, alternate, slide, static |
-| **Modal** | Overlay dialog supporting all toolbar components |
-
-> See [Components Documentation](https://deeleeramone.github.io/PyWry/components/) for live previews, attributes, and usage examples.
-
----
-
-## Documentation
-
-Full documentation is available at **[deeleeramone.github.io/PyWry](https://deeleeramone.github.io/PyWry/)**.
-
-| Section | Topics |
-|---------|--------|
-| [Getting Started](https://deeleeramone.github.io/PyWry/getting-started/) | Installation, Quick Start, Rendering Paths |
-| [Concepts](https://deeleeramone.github.io/PyWry/getting-started/) | `app.show()`, HtmlContent, Events, Configuration, State & RBAC, Hot Reload |
-| [UI](https://deeleeramone.github.io/PyWry/getting-started/) | Toolbar System, Modals, Toasts & Alerts, Theming & CSS |
-| [Integrations](https://deeleeramone.github.io/PyWry/getting-started/) | Plotly Charts, AgGrid Tables |
-| [Hosting](https://deeleeramone.github.io/PyWry/getting-started/) | Browser Mode, Deploy Mode |
-| [Components](https://deeleeramone.github.io/PyWry/components/) | Live previews for all 18 toolbar components + Modal |
-| [API Reference](https://deeleeramone.github.io/PyWry/reference/) | Auto-generated API docs for every class and function |
-| [MCP Server](https://deeleeramone.github.io/PyWry/mcp/) | AI agent integration via Model Context Protocol |
+## Features
----
+- **18 toolbar components** — `Button`, `Select`, `MultiSelect`, `TextInput`, `SecretInput`, `SliderInput`, `RangeInput`, `Toggle`, `Checkbox`, `RadioGroup`, `TabGroup`, `Marquee`, `Modal`, and more. All Pydantic models, 7 layout positions.
+- **Two-way events** — `app.emit()` and `app.on()` bridge Python and JavaScript in both directions. Pre-wired Plotly and AgGrid events included.
+- **Theming** — light/dark modes, 60+ CSS variables, hot reload during development.
+- **Security** — token auth, CSP headers, `SecuritySettings.strict()` / `.permissive()` / `.localhost()` presets. `SecretInput` stores values server-side, never in HTML.
+- **Standalone executables** — PyInstaller hook ships with `pywry[freeze]`. No `.spec` edits or `--hidden-import` flags required.
+- **MCP server** — 25 tools, 8 skills, 20+ resources for AI agent integration.
## MCP Server
-PyWry includes a built-in [Model Context Protocol](https://modelcontextprotocol.io/) server for AI agent integration — 25 tools, 8 skills, and 20+ resources.
-
```bash
pip install 'pywry[mcp]'
pywry mcp --transport stdio
```
-> See [MCP Documentation](https://deeleeramone.github.io/PyWry/mcp/) for setup with Claude Desktop, tool reference, and examples.
+See the [MCP docs](https://deeleeramone.github.io/PyWry/mcp/) for Claude Desktop setup and tool reference.
+
+## Standalone Executables
+
+```bash
+pip install 'pywry[freeze]'
+pyinstaller --windowed --name MyApp my_app.py
+```
+
+The output in `dist/MyApp/` is fully self-contained. Target machines need no Python installation — only the OS webview (WebView2 on Windows 10 1803+, WKWebView on macOS, libwebkit2gtk on Linux).
+
+## Documentation
+
+**[deeleeramone.github.io/PyWry](https://deeleeramone.github.io/PyWry/)**
----
+- [Getting Started](https://deeleeramone.github.io/PyWry/getting-started/) — installation, quick start, rendering paths
+- [Concepts](https://deeleeramone.github.io/PyWry/getting-started/) — events, configuration, state, hot reload, RBAC
+- [Components](https://deeleeramone.github.io/PyWry/components/) — live previews for all toolbar components
+- [API Reference](https://deeleeramone.github.io/PyWry/reference/) — auto-generated docs for every class and function
+- [MCP Server](https://deeleeramone.github.io/PyWry/mcp/) — AI agent integration
## License
-Apache 2.0 — see [LICENSE](LICENSE) for details.
+Apache 2.0 — see [LICENSE](LICENSE).
diff --git a/pywry/docs/docs/components/secretinput.md b/pywry/docs/docs/components/secretinput.md
index 8ad21f0..6c88c3a 100644
--- a/pywry/docs/docs/components/secretinput.md
+++ b/pywry/docs/docs/components/secretinput.md
@@ -22,14 +22,17 @@ Action buttons (left-to-right): Edit (pencil) enters edit mode, Copy (clipboard)
### Edit Mode
-Clicking the edit button switches the masked input to a resizable textarea with confirm/cancel buttons. Confirm sends the value; cancel restores the mask.
+Clicking the edit button hides the masked input and replaces it with a resizable textarea — always **empty** (the current secret is never pre-filled). Confirm (✓) and cancel (✗) buttons appear overlaid at the top-right corner of the textarea.
+
+- **Confirm** — `Ctrl+Enter` or click ✓ — transmits the new value (base64-encoded) and restores the mask.
+- **Cancel** — `Escape` or click ✗ — discards the input and restores the previous mask.
API Key:
-
-
-
+
+
+
diff --git a/pywry/docs/docs/guides/standalone-executable.md b/pywry/docs/docs/guides/standalone-executable.md
new file mode 100644
index 0000000..09f96a3
--- /dev/null
+++ b/pywry/docs/docs/guides/standalone-executable.md
@@ -0,0 +1,226 @@
+# Standalone Executable
+
+Build your PyWry application as a self-contained executable that runs on machines without Python installed. PyWry ships with a PyInstaller hook that handles everything automatically — no `.spec` file edits or manual `--hidden-import` flags required.
+
+## Quick Start
+
+```bash
+pip install pywry[freeze]
+pyinstaller --windowed --name MyApp my_app.py
+```
+
+The output in `dist/MyApp/` is a fully portable directory you can zip and distribute.
+
+## How It Works
+
+When you `pip install pywry`, a [PyInstaller hook](https://pyinstaller.org/en/stable/hooks.html) is registered via the `pyinstaller40` entry point. The next time you run `pyinstaller`, it automatically:
+
+1. **Bundles data files** — frontend HTML/JS/CSS, gzipped AG Grid and Plotly libraries, Tauri configuration (`Tauri.toml`), capability manifests, icons, and MCP skill files.
+2. **Includes hidden imports** — the Tauri subprocess entry point, native extension modules (`.pyd` / `.so`), pytauri plugins, IPC command handlers, and runtime dependencies like `anyio` and `importlib_metadata`.
+3. **Collects native binaries** — the `pytauri_wheel` shared library for the current platform.
+
+### Subprocess Re-entry
+
+PyWry runs Tauri in a subprocess. In a normal Python install this subprocess is `python -m pywry`. In a frozen executable there is no Python interpreter — the bundled `.exe` **is** the app.
+
+PyWry solves this transparently:
+
+- `runtime.start()` detects the frozen environment and re-launches `sys.executable` (your app) with `PYWRY_IS_SUBPROCESS=1` in the environment.
+- `freeze_support()`, called automatically when you `import pywry`, intercepts the child process on startup and routes it to the Tauri event loop — your application code never runs a second time in the subprocess.
+
+No special code is needed in your app. A minimal freezable application looks like this:
+
+```python
+from pywry import PyWry, WindowMode
+
+app = PyWry(mode=WindowMode.SINGLE_WINDOW, title="My App")
+app.show("
Hello from a standalone executable!
")
+app.block()
+```
+
+## Build Options
+
+### PyInstaller — One-directory (recommended)
+
+```bash
+pyinstaller --windowed --name MyApp my_app.py
+```
+
+`--onedir` is the default and gives the best startup time. The `--windowed` flag prevents a console window from appearing on Windows.
+
+### PyInstaller — One-file
+
+```bash
+pyinstaller --onefile --windowed --name MyApp my_app.py
+```
+
+One-file builds are simpler to distribute but have slower startup because PyInstaller extracts everything to a temp directory at launch.
+
+### Custom icon
+
+```bash
+pyinstaller --windowed --icon=icon.ico --name MyApp my_app.py
+```
+
+On macOS use `--icon=icon.icns`; on Linux, `--icon=icon.png`.
+
+### Nuitka
+
+```bash
+pip install nuitka
+nuitka --standalone --include-package=pywry --output-dir=dist my_app.py
+```
+
+Nuitka compiles Python to C and produces a native binary. The `--include-package=pywry` flag ensures all data files and submodules are included.
+
+## Target Platform Requirements
+
+The output executable is native to the build platform. End users need only the OS-level WebView runtime:
+
+| Platform | Requirement |
+|:---|:---|
+| Windows 10 (1803+) / 11 | WebView2 — pre-installed |
+| macOS 11+ | WKWebView — built-in |
+| Linux | `libwebkit2gtk-4.1` (`apt install libwebkit2gtk-4.1-0`) |
+
+No Python installation is required on the target machine.
+
+## Example Application
+
+```python
+"""Minimal PyWry app that can be built as a standalone distributable."""
+
+from pywry import PyWry, WindowMode
+
+app = PyWry(mode=WindowMode.SINGLE_WINDOW, title="Standalone App")
+app.show(
+ """
+
+
+
+
Hello from a distributable executable!
+
No Python installation required on the target machine.
+
+
+
+ """
+)
+app.block()
+```
+
+Build it:
+
+```bash
+pip install pywry[freeze]
+pyinstaller --windowed --name StandaloneDemo standalone_demo.py
+```
+
+## Advanced Topics
+
+### Explicit `freeze_support()` Call
+
+The interception is automatic on `import pywry`. For extra safety — ensuring no application code runs before interception — you can call `freeze_support()` at the very top of your entry point:
+
+```python
+if __name__ == "__main__":
+ from pywry import freeze_support
+ freeze_support()
+
+ # ... rest of application ...
+```
+
+This is only necessary if you have expensive top-level initialization that you want to skip in the subprocess.
+
+### Debugging Frozen Builds
+
+Enable debug logging to see subprocess communication:
+
+```bash
+# Windows
+set PYWRY_DEBUG=1
+dist\MyApp\MyApp.exe
+
+# Linux / macOS
+PYWRY_DEBUG=1 ./dist/MyApp/MyApp
+```
+
+### Extra Tauri Plugins
+
+If your app uses additional Tauri plugins beyond the defaults (`dialog`, `fs`), configure them before `app.show()`:
+
+```python
+from pywry import PyWry
+
+app = PyWry(title="My App")
+app.tauri_plugins = ["dialog", "fs", "notification", "clipboard-manager"]
+app.show("
With extra plugins
")
+app.block()
+```
+
+The PyInstaller hook automatically collects all `pytauri_plugins` submodules, so no manual `--hidden-import` is needed.
+
+### Extra Capabilities
+
+For Tauri capability permissions beyond the defaults:
+
+```python
+app.extra_capabilities = ["shell:allow-execute"]
+```
+
+### Custom `.spec` File
+
+For complex builds you can generate a `.spec` file and customize it:
+
+```bash
+pyinstaller --windowed --name MyApp my_app.py --specpath .
+```
+
+Then edit `MyApp.spec` to add extra data files, change paths, etc. Rebuild with:
+
+```bash
+pyinstaller MyApp.spec
+```
+
+The PyWry hook still runs automatically — the `.spec` file is additive.
+
+## Troubleshooting
+
+### Window doesn't appear
+
+- Verify `--windowed` was used (otherwise the subprocess may not get focus).
+- Run with `PYWRY_DEBUG=1` and check stderr for errors.
+- On Linux, ensure `libwebkit2gtk-4.1` is installed.
+
+### Missing assets (blank window)
+
+If the window opens but shows a blank page, the frontend assets may not be bundled. Verify the `dist/` directory contains `pywry/frontend/`:
+
+```bash
+# Windows
+dir /s dist\MyApp\_internal\pywry\frontend
+
+# Linux / macOS
+find dist/MyApp/_internal/pywry/frontend -type f
+```
+
+If empty, ensure pywry is installed (not just editable-linked) so `collect_data_files` can find the package files.
+
+### `ModuleNotFoundError: pytauri_wheel`
+
+This means the native Tauri runtime wasn't bundled. Ensure you installed pywry from a platform wheel (not a pure-Python sdist):
+
+```bash
+pip install --force-reinstall pywry
+```
+
+### App runs twice (code executes in subprocess)
+
+This should never happen with the automatic `freeze_support()`. If it does, add the explicit call at the very top of your script:
+
+```python
+if __name__ == "__main__":
+ from pywry import freeze_support
+ freeze_support()
+```
diff --git a/pywry/docs/docs/guides/tray.md b/pywry/docs/docs/guides/tray.md
index b37041a..b2d75ef 100644
--- a/pywry/docs/docs/guides/tray.md
+++ b/pywry/docs/docs/guides/tray.md
@@ -361,3 +361,4 @@ tray = TrayProxy.from_config(TrayIconConfig(
MenuItemConfig(id="quit", text="Quit", handler=quit_app),
]),
))
+```
diff --git a/pywry/docs/mkdocs.yml b/pywry/docs/mkdocs.yml
index 9113119..8bf408f 100644
--- a/pywry/docs/mkdocs.yml
+++ b/pywry/docs/mkdocs.yml
@@ -186,6 +186,7 @@ nav:
- Hosting:
- Browser Mode: guides/browser-mode.md
- Deploy Mode: guides/deploy-mode.md
+ - Standalone Executable: guides/standalone-executable.md
- Features: features.md
- Changelog: changelog.md
@@ -228,27 +229,28 @@ nav:
- CSS: reference/css.md
- Modal: reference/modal.md
- Toolbar Functions: reference/toolbar-functions.md
- - Button: reference/components/button.md
- - Checkbox: reference/components/checkbox.md
- - DateInput: reference/components/dateinput.md
- - Div: reference/components/div.md
- - Marquee: reference/components/marquee.md
- - MultiSelect: reference/components/multiselect.md
- - NumberInput: reference/components/numberinput.md
- - Option: reference/components/option.md
- - RadioGroup: reference/components/radiogroup.md
- - RangeInput: reference/components/rangeinput.md
- - SearchInput: reference/components/searchinput.md
- - SecretInput: reference/components/secretinput.md
- - Select: reference/components/select.md
- - SliderInput: reference/components/sliderinput.md
- - TabGroup: reference/components/tabgroup.md
- - TextArea: reference/components/textarea.md
- - TextInput: reference/components/textinput.md
- - TickerItem: reference/components/tickeritem.md
- - Toggle: reference/components/toggle.md
- - Toolbar: reference/components/toolbar.md
- - ToolbarItem: reference/components/toolbaritem.md
+ - Components:
+ - Button: reference/components/button.md
+ - Checkbox: reference/components/checkbox.md
+ - DateInput: reference/components/dateinput.md
+ - Div: reference/components/div.md
+ - Marquee: reference/components/marquee.md
+ - MultiSelect: reference/components/multiselect.md
+ - NumberInput: reference/components/numberinput.md
+ - Option: reference/components/option.md
+ - RadioGroup: reference/components/radiogroup.md
+ - RangeInput: reference/components/rangeinput.md
+ - SearchInput: reference/components/searchinput.md
+ - SecretInput: reference/components/secretinput.md
+ - Select: reference/components/select.md
+ - SliderInput: reference/components/sliderinput.md
+ - TabGroup: reference/components/tabgroup.md
+ - TextArea: reference/components/textarea.md
+ - TextInput: reference/components/textinput.md
+ - TickerItem: reference/components/tickeritem.md
+ - Toggle: reference/components/toggle.md
+ - Toolbar: reference/components/toolbar.md
+ - ToolbarItem: reference/components/toolbaritem.md
- Integrations:
- Plotly: reference/plotly-config.md
- Grid: reference/grid.md
diff --git a/pywry/examples/pywry_demo_freeze.py b/pywry/examples/pywry_demo_freeze.py
new file mode 100644
index 0000000..d0eb3b1
--- /dev/null
+++ b/pywry/examples/pywry_demo_freeze.py
@@ -0,0 +1,43 @@
+"""Minimal PyWry app that can be built as a standalone distributable.
+
+No special code is required — pywry handles frozen executable detection
+automatically when imported.
+
+Build with PyInstaller (recommended ``--onedir`` for best startup time)::
+
+ pip install pywry[freeze]
+ pyinstaller --windowed --name MyApp pywry_demo_freeze.py
+
+Build with Nuitka::
+
+ pip install nuitka
+ nuitka --standalone --include-package=pywry pywry_demo_freeze.py
+
+The output directory (``dist/MyApp/``) is fully self-contained and can
+be distributed to machines that have no Python installation.
+
+System requirements for the target machine:
+- Windows: WebView2 runtime (pre-installed on Windows 10 1803+ / 11)
+- Linux: libwebkit2gtk-4.1
+- macOS: None (WKWebView is built-in)
+"""
+
+from pywry import PyWry, WindowMode
+
+
+app = PyWry(mode=WindowMode.SINGLE_WINDOW, title="Standalone App")
+app.show(
+ """
+
+
+
+
Hello from a distributable executable!
+
No Python installation required on the target machine.
+
+
+
+ """
+)
+app.block()
diff --git a/pywry/pyproject.toml b/pywry/pyproject.toml
index c3ffb60..a737170 100644
--- a/pywry/pyproject.toml
+++ b/pywry/pyproject.toml
@@ -43,6 +43,9 @@ dependencies = [
[project.scripts]
pywry = "pywry.cli:main"
+[project.entry-points."pyinstaller40"]
+hook-dirs = "pywry._pyinstaller_hook:get_hook_dirs"
+
[project.optional-dependencies]
dev = [
"pytauri-wheel>=0.8.0",
@@ -59,6 +62,7 @@ dev = [
"testcontainers>=4.14.0",
"fastmcp>=2.14.5,<3",
"ipykernel>=7.2.0",
+ "pyinstaller>=6.0",
]
notebook = [
"anywidget>=0.9.0",
@@ -77,6 +81,9 @@ all = [
"ipykernel>=7.2.0",
"keyring>=24.0",
]
+freeze = [
+ "pyinstaller>=6.0",
+]
[project.urls]
Homepage = "https://github.com/deeleeramone/PyWry"
@@ -110,5 +117,5 @@ warn_unused_configs = true
exclude = ["tests/"]
[[tool.mypy.overrides]]
-module = ["plotly.*", "anywidget", "anywidget.*", "ipywidgets", "ipywidgets.*"]
+module = ["plotly.*", "anywidget", "anywidget.*", "ipywidgets", "ipywidgets.*", "pyinstaller", "pyinstaller.*"]
ignore_missing_imports = true
diff --git a/pywry/pywry/__init__.py b/pywry/pywry/__init__.py
index be77772..9fc2aef 100644
--- a/pywry/pywry/__init__.py
+++ b/pywry/pywry/__init__.py
@@ -4,6 +4,18 @@
windows with support for Plotly.js, AG Grid, and custom event handling.
"""
+# Frozen-executable subprocess interception — MUST run before any other
+# pywry import. In a frozen distributable (PyInstaller / Nuitka / cx_Freeze),
+# when the parent process spawns itself as the Tauri subprocess, this call
+# enters the Tauri event loop and exits immediately so the developer's
+# application code never runs a second time. It is a complete no-op in
+# every other situation (normal Python, frozen parent process, etc.).
+from ._freeze import freeze_support
+
+
+freeze_support()
+
+# pylint: disable=wrong-import-position
# Inline notebook module - import functions directly
from . import inline
from .app import PyWry
@@ -174,6 +186,7 @@
"block",
"build_grid_config",
"detect_notebook_environment",
+ "freeze_support",
"get_asset_loader",
"get_lifecycle",
"get_registry",
diff --git a/pywry/pywry/_freeze.py b/pywry/pywry/_freeze.py
new file mode 100644
index 0000000..af8ccdb
--- /dev/null
+++ b/pywry/pywry/_freeze.py
@@ -0,0 +1,135 @@
+"""Frozen-app (distributable executable) support for PyWry.
+
+When a developer builds their PyWry application as a standalone executable
+using PyInstaller, Nuitka, or cx_Freeze, the Tauri subprocess can no longer
+be launched with ``python -m pywry`` because ``sys.executable`` points to
+the developer's bundled ``.exe``, not a Python interpreter.
+
+This module solves that by:
+
+1. Detecting frozen environments (``sys.frozen`` is set by all major freezers).
+2. Providing ``get_subprocess_command()`` so ``runtime.start()`` spawns the
+ frozen executable *itself* with ``PYWRY_IS_SUBPROCESS=1`` in the env.
+3. Pre-registering the pytauri native extension module to bypass entry-point
+ discovery (which fails when ``.dist-info`` metadata is absent).
+4. Providing ``freeze_support()`` which, when called at import time from
+ ``pywry/__init__.py``, intercepts the child process and routes it directly
+ to the Tauri event loop — preventing the developer's application code
+ from executing a second time in the subprocess.
+
+Developers do **not** need to call anything from this module directly.
+The interception happens automatically on ``import pywry``. For maximum
+safety (ensuring no developer code runs before the interception), they can
+optionally call ``freeze_support()`` at the very top of their entry point::
+
+ if __name__ == "__main__":
+ from pywry import freeze_support
+
+ freeze_support()
+
+ # ... rest of application ...
+"""
+
+from __future__ import annotations
+
+import importlib
+import os
+import sys
+
+
+def is_frozen() -> bool:
+ """Return ``True`` if running inside a frozen executable.
+
+ PyInstaller, Nuitka (standalone), and cx_Freeze all set
+ ``sys.frozen = True``.
+ """
+ return bool(getattr(sys, "frozen", False))
+
+
+def get_subprocess_command() -> list[str]:
+ """Return the command to spawn the PyWry Tauri subprocess.
+
+ Normal install::
+
+ [sys.executable, "-u", "-m", "pywry"]
+
+ Frozen executable::
+
+ [sys.executable]
+
+ In frozen mode the ``PYWRY_IS_SUBPROCESS=1`` environment variable
+ (set by ``runtime.start()``) tells the child to enter the Tauri
+ event loop instead of running the developer's application. The
+ ``-u`` flag is replaced by ``PYTHONUNBUFFERED=1`` in the env.
+ """
+ if is_frozen():
+ return [sys.executable]
+ return [sys.executable, "-u", "-m", "pywry"]
+
+
+def _setup_pytauri_standalone() -> None:
+ """Pre-register the pytauri native extension module.
+
+ In frozen builds, package entry-point metadata (``.dist-info/entry_points.txt``)
+ may not be preserved. ``pytauri`` discovers its native Rust extension via
+ ``importlib_metadata.entry_points(group="pytauri", name="ext_mod")``, which
+ fails when the metadata is absent.
+
+ This function directly imports the extension module and registers it
+ using pytauri's built-in standalone mechanism (``sys._pytauri_standalone``),
+ bypassing entry-point discovery entirely.
+
+ Must be called **before** any ``import pytauri`` statement.
+ """
+ if getattr(sys, "_pytauri_standalone", False):
+ return # Already set up
+
+ ext_mod = None
+ for mod_name in (
+ "pywry._vendor.pytauri_wheel.ext_mod",
+ "pytauri_wheel.ext_mod",
+ ):
+ try:
+ ext_mod = importlib.import_module(mod_name)
+ break
+ except ImportError:
+ continue
+
+ if ext_mod is None:
+ return # Can't find it; let the normal entry-point path try
+
+ sys.modules["__pytauri_ext_mod__"] = ext_mod
+ sys._pytauri_standalone = True # type: ignore[attr-defined]
+
+
+def freeze_support() -> None:
+ """Handle subprocess re-entry in frozen executables.
+
+ Called automatically at the top of ``pywry/__init__.py``. When all
+ of the following are true the function enters the Tauri event loop
+ and calls ``sys.exit()`` — the developer's application code never
+ executes in the child process:
+
+ 1. ``sys.frozen`` is truthy (we are inside a frozen executable).
+ 2. ``PYWRY_IS_SUBPROCESS`` environment variable is ``"1"``
+ (we were spawned by ``runtime.start()`` as the Tauri subprocess).
+
+ In every other situation (normal Python, frozen parent process, etc.)
+ this function is a no-op and returns immediately.
+ """
+ if not is_frozen():
+ return
+
+ # Pre-register pytauri's native extension to bypass entry-point
+ # discovery, which fails in frozen builds when .dist-info metadata
+ # is not preserved. This must run BEFORE any ``import pytauri``.
+ _setup_pytauri_standalone()
+
+ if os.environ.get("PYWRY_IS_SUBPROCESS") != "1":
+ return
+
+ # We are the frozen child process. Import the Tauri entry point
+ # and run it, then hard-exit so the developer's code never executes.
+ from pywry.__main__ import main
+
+ sys.exit(main())
diff --git a/pywry/pywry/_pyinstaller_hook/__init__.py b/pywry/pywry/_pyinstaller_hook/__init__.py
new file mode 100644
index 0000000..864faf8
--- /dev/null
+++ b/pywry/pywry/_pyinstaller_hook/__init__.py
@@ -0,0 +1,16 @@
+"""PyInstaller hook registration for pywry.
+
+PyInstaller discovers this via the ``pyinstaller40`` entry point in
+``pyproject.toml``. When a developer runs ``pyinstaller myapp.py``,
+PyInstaller automatically applies the hook in ``hook-pywry.py`` without
+any manual ``.spec`` file configuration.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+
+def get_hook_dirs() -> list[str]:
+ """Return the directory containing PyInstaller hooks for pywry."""
+ return [str(Path(__file__).parent)]
diff --git a/pywry/pywry/_pyinstaller_hook/hook-pywry.py b/pywry/pywry/_pyinstaller_hook/hook-pywry.py
new file mode 100644
index 0000000..e6ba36c
--- /dev/null
+++ b/pywry/pywry/_pyinstaller_hook/hook-pywry.py
@@ -0,0 +1,89 @@
+# pylint: disable=invalid-name
+"""PyInstaller hook for pywry.
+
+Automatically applied when ``pywry`` is used as a dependency in a
+PyInstaller build. Handles:
+
+- **Data files** — frontend assets (HTML, JS, CSS, gzipped libraries,
+ icons), Tauri configuration (``Tauri.toml``), capability manifests,
+ and MCP skill markdown files.
+- **Hidden imports** — dynamically imported modules that PyInstaller's
+ static analysis cannot trace (subprocess entry point, pytauri plugins,
+ vendored native bindings, IPC command handlers, entry-point-loaded
+ native extensions).
+- **Native binaries** — the vendored ``pytauri_wheel`` shared library
+ (``.pyd`` on Windows, ``.so`` on Linux, ``.dylib`` on macOS).
+"""
+
+from __future__ import annotations
+
+import contextlib
+
+from PyInstaller.utils.hooks import ( # type: ignore[import-untyped] # pylint: disable=import-error
+ collect_data_files,
+ collect_dynamic_libs,
+ collect_submodules,
+ copy_metadata,
+)
+
+
+# ── Data files ────────────────────────────────────────────────────────
+# collect_data_files finds every non-.py file inside the package tree.
+# This captures frontend/, Tauri.toml, capabilities/*.toml, mcp/skills/*.md,
+# frontend/assets/*.gz, icons, CSS, JS — everything context_factory() and
+# the asset loaders need at runtime.
+datas = collect_data_files("pywry")
+
+# Bundle package metadata (.dist-info) so that importlib.metadata.entry_points()
+# can discover the pytauri native extension at runtime if needed.
+# pytauri's _ext_mod._load_ext_mod() uses entry_points(group="pytauri",
+# name="ext_mod") to find pytauri_wheel.ext_mod. Without the dist-info
+# this lookup fails.
+# Belt-and-suspenders: _freeze._setup_pytauri_standalone() also bypasses
+# entry-point discovery via sys._pytauri_standalone, but keeping metadata
+# makes importlib.metadata introspection work in general.
+for _pkg in ("pytauri_wheel", "pytauri", "pytauri_plugins"):
+ with contextlib.suppress(Exception):
+ datas += copy_metadata(_pkg)
+
+# ── Hidden imports ────────────────────────────────────────────────────
+# These modules are imported dynamically (inside functions, try/except
+# blocks, importlib.import_module, entry-point lookups, or string-based
+# references) and are invisible to PyInstaller's static import graph.
+hiddenimports: list[str] = [
+ # Subprocess entry point — spawned at runtime, never imported statically
+ "pywry.__main__",
+ # Freeze detection — imported inside guards
+ "pywry._freeze",
+ # IPC command handlers — registered at runtime in __main__.main()
+ "pywry.commands",
+ "pywry.commands.window_commands",
+ "pywry.window_dispatch",
+ # Vendored Tauri runtime — imported inside try/except in __main__
+ # collect_submodules captures __init__, lib, AND the native ext_mod .pyd
+ *collect_submodules("pywry._vendor.pytauri_wheel"),
+ # Non-vendored fallback (editable / dev installs)
+ # collect_submodules captures pytauri_wheel.ext_mod (the native .pyd)
+ # which is loaded at runtime via entry_points(group="pytauri")
+ *collect_submodules("pytauri_wheel"),
+ # pytauri and all its plugins — loaded dynamically by _load_plugins()
+ *collect_submodules("pytauri"),
+ *collect_submodules("pytauri_plugins"),
+ # importlib_metadata — used by pytauri.ffi._ext_mod to discover the
+ # native extension via entry_points(); it's a backport package that
+ # PyInstaller may not trace from the dynamic lookup code
+ "importlib_metadata",
+ # anyio backend — selected by name string at runtime
+ "anyio._backends._asyncio",
+ # setproctitle — optional, guarded import
+ "setproctitle",
+]
+
+# ── Native binaries ───────────────────────────────────────────────────
+# collect_dynamic_libs finds .dll / .so / .dylib files (NOT .pyd extension
+# modules — those are handled via hiddenimports above).
+# This catches any non-Python shared libraries that pytauri_wheel may
+# depend on (e.g., C runtime libraries bundled alongside the extension).
+binaries = collect_dynamic_libs("pywry._vendor.pytauri_wheel")
+if not binaries:
+ binaries = collect_dynamic_libs("pytauri_wheel")
diff --git a/pywry/pywry/runtime.py b/pywry/pywry/runtime.py
index dbed12a..fe5b37c 100644
--- a/pywry/pywry/runtime.py
+++ b/pywry/pywry/runtime.py
@@ -944,11 +944,23 @@ def start() -> bool:
_ready_event.clear()
_running = True
pywry_dir = get_pywry_dir()
- python_exe = sys.executable
- cmd = [python_exe, "-u", "-m", "pywry"]
+
+ # In frozen executables (PyInstaller/Nuitka), sys.executable is the
+ # developer's bundled app — not a Python interpreter. We re-launch
+ # the same executable and set PYWRY_IS_SUBPROCESS=1 so freeze_support()
+ # routes it to the Tauri event loop on import.
+ from ._freeze import get_subprocess_command
+
+ cmd = get_subprocess_command()
env = os.environ.copy()
env["PYTHONUNBUFFERED"] = "1"
env["PYTHONUTF8"] = "1" # Force UTF-8
+ # Tell the frozen child process to enter the Tauri event loop
+ # instead of running the developer's application code.
+ from ._freeze import is_frozen
+
+ if is_frozen():
+ env["PYWRY_IS_SUBPROCESS"] = "1"
env["PYWRY_ON_WINDOW_CLOSE"] = _ON_WINDOW_CLOSE # Pass close behavior to subprocess
env["PYWRY_WINDOW_MODE"] = _WINDOW_MODE # Pass window mode to subprocess
env["PYWRY_TAURI_PLUGINS"] = _TAURI_PLUGINS # Tauri plugins to initialise
diff --git a/pywry/ruff.toml b/pywry/ruff.toml
index a9c671a..b95a20d 100644
--- a/pywry/ruff.toml
+++ b/pywry/ruff.toml
@@ -84,6 +84,7 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
]
"__init__.py" = [
+ "E402",
"F401",
"D104",
]
@@ -131,6 +132,10 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
"TRY300", # Return in try block is intentional
]
+"pywry/_pyinstaller_hook/hook-pywry.py" = [
+ "N999", # Hyphenated module name required by PyInstaller convention
+]
+
[lint.isort]
known-first-party = ["pywry"]
known-third-party = ["pytauri", "pydantic", "pydantic_settings", "anyio", "watchdog"]
diff --git a/pywry/tests/conftest.py b/pywry/tests/conftest.py
index f17eade..90b8925 100644
--- a/pywry/tests/conftest.py
+++ b/pywry/tests/conftest.py
@@ -14,11 +14,13 @@
import pytest
from tests.constants import (
+ DEFAULT_RETRIES,
DEFAULT_TIMEOUT,
JS_RESULT_RETRIES,
REDIS_ALPINE_IMAGE,
REDIS_IMAGE,
REDIS_TEST_TTL,
+ RETRY_DELAY,
SHORT_TIMEOUT,
)
@@ -179,11 +181,13 @@ def show_and_wait_ready(
app: Any,
content: Any,
timeout: float = DEFAULT_TIMEOUT,
+ retries: int = DEFAULT_RETRIES,
**kwargs: Any,
) -> str:
"""Show content and wait for window to be ready.
This registers the ready callback BEFORE calling show() to avoid race conditions.
+ Retries on transient subprocess/timeout failures for CI stability.
Parameters
----------
@@ -193,6 +197,8 @@ def show_and_wait_ready(
HTML content to display.
timeout : float
Maximum time to wait for window ready.
+ retries : int
+ Number of retry attempts on transient failures.
**kwargs
Additional arguments passed to app.show().
@@ -206,59 +212,79 @@ def show_and_wait_ready(
TimeoutError
If window doesn't become ready within timeout.
"""
- waiter = ReadyWaiter(timeout=timeout)
+ last_error: Exception | None = None
+ for attempt in range(retries):
+ waiter = ReadyWaiter(timeout=timeout)
- # Merge callbacks if provided
- callbacks = kwargs.pop("callbacks", {}) or {}
- callbacks["pywry:ready"] = waiter.on_ready
+ # Merge callbacks if provided
+ cb = (kwargs.pop("callbacks", {}) or {}).copy()
+ cb["pywry:ready"] = waiter.on_ready
- widget = app.show(content, callbacks=callbacks, **kwargs)
- label = widget.label if hasattr(widget, "label") else str(widget)
+ widget = app.show(content, callbacks=cb, **kwargs)
+ label = widget.label if hasattr(widget, "label") else str(widget)
- if not waiter.wait():
- raise TimeoutError(f"Window '{label}' did not become ready within {timeout}s")
+ if waiter.wait():
+ return label
- return label
+ last_error = TimeoutError(f"Window '{label}' did not become ready within {timeout}s")
+ if attempt < retries - 1:
+ time.sleep(RETRY_DELAY * (attempt + 1))
+
+ raise last_error # type: ignore[misc]
def show_plotly_and_wait_ready(
app: Any,
figure: Any,
timeout: float = DEFAULT_TIMEOUT,
+ retries: int = DEFAULT_RETRIES,
**kwargs: Any,
) -> str:
"""Show Plotly figure and wait for window to be ready."""
- waiter = ReadyWaiter(timeout=timeout)
- callbacks = kwargs.pop("callbacks", {}) or {}
- callbacks["pywry:ready"] = waiter.on_ready
+ last_error: Exception | None = None
+ for attempt in range(retries):
+ waiter = ReadyWaiter(timeout=timeout)
+ cb = (kwargs.pop("callbacks", {}) or {}).copy()
+ cb["pywry:ready"] = waiter.on_ready
- widget = app.show_plotly(figure, callbacks=callbacks, **kwargs)
- label = widget.label if hasattr(widget, "label") else str(widget)
+ widget = app.show_plotly(figure, callbacks=cb, **kwargs)
+ label = widget.label if hasattr(widget, "label") else str(widget)
- if not waiter.wait():
- raise TimeoutError(f"Window '{label}' did not become ready within {timeout}s")
+ if waiter.wait():
+ return label
- return label
+ last_error = TimeoutError(f"Window '{label}' did not become ready within {timeout}s")
+ if attempt < retries - 1:
+ time.sleep(RETRY_DELAY * (attempt + 1))
+
+ raise last_error # type: ignore[misc]
def show_dataframe_and_wait_ready(
app: Any,
data: Any,
timeout: float = DEFAULT_TIMEOUT,
+ retries: int = DEFAULT_RETRIES,
**kwargs: Any,
) -> str:
"""Show DataFrame and wait for window to be ready."""
- waiter = ReadyWaiter(timeout=timeout)
- callbacks = kwargs.pop("callbacks", {}) or {}
- callbacks["pywry:ready"] = waiter.on_ready
+ last_error: Exception | None = None
+ for attempt in range(retries):
+ waiter = ReadyWaiter(timeout=timeout)
+ cb = (kwargs.pop("callbacks", {}) or {}).copy()
+ cb["pywry:ready"] = waiter.on_ready
- widget = app.show_dataframe(data, callbacks=callbacks, **kwargs)
- label = widget.label if hasattr(widget, "label") else str(widget)
+ widget = app.show_dataframe(data, callbacks=cb, **kwargs)
+ label = widget.label if hasattr(widget, "label") else str(widget)
- if not waiter.wait():
- raise TimeoutError(f"Window '{label}' did not become ready within {timeout}s")
+ if waiter.wait():
+ return label
+
+ last_error = TimeoutError(f"Window '{label}' did not become ready within {timeout}s")
+ if attempt < retries - 1:
+ time.sleep(RETRY_DELAY * (attempt + 1))
- return label
+ raise last_error # type: ignore[misc]
def wait_for_result(
diff --git a/pywry/tests/test_alerts.py b/pywry/tests/test_alerts.py
index e0aeb52..c23ae03 100644
--- a/pywry/tests/test_alerts.py
+++ b/pywry/tests/test_alerts.py
@@ -14,6 +14,7 @@
from __future__ import annotations
import time
+
from typing import Any
import pytest
diff --git a/pywry/tests/test_freeze.py b/pywry/tests/test_freeze.py
new file mode 100644
index 0000000..2c8970e
--- /dev/null
+++ b/pywry/tests/test_freeze.py
@@ -0,0 +1,252 @@
+"""Tests for frozen-app subprocess detection and command routing."""
+
+from __future__ import annotations
+
+import inspect
+import sys
+import types
+
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+from pywry._freeze import (
+ _setup_pytauri_standalone,
+ freeze_support,
+ get_subprocess_command,
+ is_frozen,
+)
+
+
+def _make_fake_main_module(return_code: int = 0) -> types.ModuleType:
+ """Create a fake ``pywry.__main__`` module with a mock ``main()``.
+
+ Avoids importing the real ``pywry.__main__`` which reconfigures
+ ``sys.stdin``/``sys.stdout``/``sys.stderr`` on Windows and breaks
+ pytest's capture system.
+ """
+ mod = types.ModuleType("pywry.__main__")
+ mod.main = lambda: return_code # type: ignore[attr-defined]
+ return mod
+
+
+# ── is_frozen() ───────────────────────────────────────────────────────
+
+
+class TestIsFrozen:
+ """Tests for is_frozen() detection."""
+
+ def test_false_in_normal_python(self) -> None:
+ """Normal interpreter should not be detected as frozen."""
+ assert not is_frozen()
+
+ def test_true_when_sys_frozen_set(self) -> None:
+ """PyInstaller / Nuitka / cx_Freeze set sys.frozen = True."""
+ with patch.object(sys, "frozen", True, create=True):
+ assert is_frozen()
+
+ def test_false_when_sys_frozen_false(self) -> None:
+ """Explicitly False should not trigger."""
+ with patch.object(sys, "frozen", False, create=True):
+ assert not is_frozen()
+
+
+# ── get_subprocess_command() ──────────────────────────────────────────
+
+
+class TestGetSubprocessCommand:
+ """Tests for subprocess command generation."""
+
+ def test_normal_mode_uses_module_flag(self) -> None:
+ """Normal Python: [python, -u, -m, pywry]."""
+ cmd = get_subprocess_command()
+ assert cmd == [sys.executable, "-u", "-m", "pywry"]
+
+ def test_frozen_mode_uses_bare_executable(self) -> None:
+ """Frozen: [sys.executable] — no -u, -m, or pywry args."""
+ with patch.object(sys, "frozen", True, create=True):
+ cmd = get_subprocess_command()
+ assert cmd == [sys.executable]
+
+ def test_frozen_command_has_no_python_flags(self) -> None:
+ """Frozen executable is not a Python interpreter — no -u or -m."""
+ with patch.object(sys, "frozen", True, create=True):
+ cmd = get_subprocess_command()
+ assert "-u" not in cmd
+ assert "-m" not in cmd
+ assert "pywry" not in cmd
+
+
+# ── freeze_support() ─────────────────────────────────────────────────
+
+
+class TestFreezeSupport:
+ """Tests for the freeze_support() interception function."""
+
+ def test_noop_when_not_frozen(self) -> None:
+ """Normal Python — should return immediately without side effects."""
+ # Must not raise or call sys.exit
+ freeze_support()
+
+ def test_noop_when_frozen_but_no_env_var(self) -> None:
+ """Frozen parent process — PYWRY_IS_SUBPROCESS not set → no-op."""
+ with (
+ patch.object(sys, "frozen", True, create=True),
+ patch.dict("os.environ", {}, clear=True),
+ ):
+ freeze_support() # must not raise or exit
+
+ def test_noop_when_env_var_but_not_frozen(self) -> None:
+ """Non-frozen process with env var set — should be ignored."""
+ with patch.dict("os.environ", {"PYWRY_IS_SUBPROCESS": "1"}):
+ freeze_support() # must not raise or exit
+
+ def test_exits_when_frozen_with_env_var(self) -> None:
+ """Frozen subprocess with PYWRY_IS_SUBPROCESS=1 → calls main() and exits."""
+ fake_mod = _make_fake_main_module(return_code=0)
+ with (
+ patch.object(sys, "frozen", True, create=True),
+ patch.dict("os.environ", {"PYWRY_IS_SUBPROCESS": "1"}),
+ patch.dict("sys.modules", {"pywry.__main__": fake_mod}),
+ pytest.raises(SystemExit) as exc_info,
+ ):
+ freeze_support()
+ assert exc_info.value.code == 0
+
+ def test_exit_propagates_nonzero_return(self) -> None:
+ """Non-zero return from main() propagates through sys.exit."""
+ fake_mod = _make_fake_main_module(return_code=1)
+ with (
+ patch.object(sys, "frozen", True, create=True),
+ patch.dict("os.environ", {"PYWRY_IS_SUBPROCESS": "1"}),
+ patch.dict("sys.modules", {"pywry.__main__": fake_mod}),
+ pytest.raises(SystemExit) as exc_info,
+ ):
+ freeze_support()
+ assert exc_info.value.code == 1
+
+
+# ── runtime.start() integration ──────────────────────────────────────
+
+
+class TestRuntimeIntegration:
+ """Verify that runtime.start() uses get_subprocess_command()."""
+
+ def test_start_uses_get_subprocess_command(self) -> None:
+ """runtime.start() must delegate to get_subprocess_command()."""
+ from pywry import runtime
+
+ source = inspect.getsource(runtime.start)
+ assert "get_subprocess_command" in source
+
+ def test_start_no_longer_hardcodes_python_m_pywry(self) -> None:
+ """The old hardcoded [python, -m, pywry] pattern must be gone."""
+ from pywry import runtime
+
+ source = inspect.getsource(runtime.start)
+ assert '"-m", "pywry"' not in source
+
+ def test_start_sets_pywry_is_subprocess_for_frozen(self) -> None:
+ """In frozen mode, PYWRY_IS_SUBPROCESS=1 must be set in env."""
+ from pywry import runtime
+
+ source = inspect.getsource(runtime.start)
+ assert "PYWRY_IS_SUBPROCESS" in source
+
+
+# ── pytauri standalone setup ──────────────────────────────────────────
+
+
+class TestPytauriStandaloneSetup:
+ """Verify that _setup_pytauri_standalone bypasses entry-point discovery."""
+
+ def test_registers_ext_mod_in_sys_modules(self) -> None:
+ """Must place the native module in sys.modules['__pytauri_ext_mod__']."""
+ # Clean up any previous state
+ old_standalone = getattr(sys, "_pytauri_standalone", None)
+ old_mod = sys.modules.pop("__pytauri_ext_mod__", None)
+ try:
+ if hasattr(sys, "_pytauri_standalone"):
+ del sys._pytauri_standalone
+ _setup_pytauri_standalone()
+ assert getattr(sys, "_pytauri_standalone", False) is True
+ assert "__pytauri_ext_mod__" in sys.modules
+ finally:
+ # Restore original state
+ if old_standalone is not None:
+ sys._pytauri_standalone = old_standalone # type: ignore[attr-defined]
+ elif hasattr(sys, "_pytauri_standalone"):
+ del sys._pytauri_standalone
+ if old_mod is not None:
+ sys.modules["__pytauri_ext_mod__"] = old_mod
+ else:
+ sys.modules.pop("__pytauri_ext_mod__", None)
+
+ def test_idempotent(self) -> None:
+ """Calling _setup_pytauri_standalone twice must not fail."""
+ old_standalone = getattr(sys, "_pytauri_standalone", None)
+ old_mod = sys.modules.pop("__pytauri_ext_mod__", None)
+ try:
+ if hasattr(sys, "_pytauri_standalone"):
+ del sys._pytauri_standalone
+ _setup_pytauri_standalone()
+ _setup_pytauri_standalone() # second call should be idempotent
+ assert getattr(sys, "_pytauri_standalone", False) is True
+ finally:
+ if old_standalone is not None:
+ sys._pytauri_standalone = old_standalone # type: ignore[attr-defined]
+ elif hasattr(sys, "_pytauri_standalone"):
+ del sys._pytauri_standalone
+ if old_mod is not None:
+ sys.modules["__pytauri_ext_mod__"] = old_mod
+ else:
+ sys.modules.pop("__pytauri_ext_mod__", None)
+
+ def test_freeze_support_calls_setup_in_frozen_mode(self) -> None:
+ """freeze_support() must call _setup_pytauri_standalone when frozen."""
+ source = inspect.getsource(freeze_support)
+ assert "_setup_pytauri_standalone" in source
+
+
+# ── PyInstaller hook validation ───────────────────────────────────────
+
+
+class TestPyInstallerHook:
+ """Verify that the PyInstaller hook captures all required assets."""
+
+ def test_hook_dirs_returns_valid_path(self) -> None:
+ """get_hook_dirs() must return a list with the hook directory."""
+ from pywry._pyinstaller_hook import get_hook_dirs
+
+ dirs = get_hook_dirs()
+ assert len(dirs) == 1
+ hook_dir = Path(dirs[0])
+ assert hook_dir.is_dir()
+ assert (hook_dir / "hook-pywry.py").is_file()
+
+ def test_hook_collects_data_files(self) -> None:
+ """The hook must collect Tauri.toml, frontend/, capabilities/."""
+ from PyInstaller.utils.hooks import collect_data_files
+
+ datas = collect_data_files("pywry")
+ src_files = {src for src, _ in datas}
+ # Must include Tauri.toml and index.html at minimum
+ assert any("Tauri.toml" in f for f in src_files)
+ assert any("index.html" in f for f in src_files)
+ assert any("default.toml" in f for f in src_files)
+
+ def test_hook_includes_native_ext_mod(self) -> None:
+ """hiddenimports must include pytauri_wheel.ext_mod (the .pyd)."""
+ from PyInstaller.utils.hooks import collect_submodules
+
+ # The hook uses collect_submodules which should find ext_mod
+ submodules = collect_submodules("pytauri_wheel")
+ assert "pytauri_wheel.ext_mod" in submodules
+
+ def test_hook_includes_importlib_metadata(self) -> None:
+ """importlib_metadata must be a hidden import (used by pytauri)."""
+ # Read the hook source and verify importlib_metadata is listed
+ hook_path = Path(__file__).parent.parent / "pywry" / "_pyinstaller_hook" / "hook-pywry.py"
+ source = hook_path.read_text(encoding="utf-8")
+ assert "importlib_metadata" in source
diff --git a/pywry/tests/test_window_modes.py b/pywry/tests/test_window_modes.py
index 4b9d6b2..cef2c12 100644
--- a/pywry/tests/test_window_modes.py
+++ b/pywry/tests/test_window_modes.py
@@ -476,11 +476,12 @@ def test_eval_js_works_in_all_modes(self, mode):
label = show_and_wait_ready(app, "
Original
")
- # Use eval_js to modify content
+ # Use app.eval_js to mutate; then query in the same IPC sequence.
+ # The IPC queue is FIFO so the mutation is guaranteed to execute before
+ # the read — no sleep needed (and a sleep would introduce jitter).
app.eval_js("document.getElementById('target').textContent = 'Modified';")
- time.sleep(0.3)
- # Verify modification
+ # Query immediately after: ordering guarantees mutation has run first.
result = wait_for_result(
label,
"pywry.result({ text: document.getElementById('target')?.textContent });",