diff --git a/pywry/README.md b/pywry/README.md index 15ef952..4c05fd0 100644 --- a/pywry/README.md +++ b/pywry/README.md @@ -1,100 +1,50 @@
-![PyWry](./pywry/frontend/assets/PyWry.png) +![PyWry](https://github.com/deeleeramone/PyWry/blob/82db0c977a8ec812bf8652c0be14bf62b66b66a1/pywry/pywry/frontend/assets/PyWry.png?raw=true) -**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: -[![PyPI](https://img.shields.io/pypi/v/pywry?color=blue)](https://pypi.org/project/pywry/) -[![Python](https://img.shields.io/pypi/pyversions/pywry)](https://pypi.org/project/pywry/) -[![License](https://img.shields.io/github/license/deeleeramone/PyWry)](LICENSE) -[![Docs](https://img.shields.io/badge/docs-live-brightgreen)](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 });",