Skip to content

JarrettSJohnson/pyodide-with-pyqt6

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

10 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

PyQt6 on Pyodide

Run PyQt6 widgets in the browser via Pyodide and Qt6's WebAssembly support.

PyQt6 running in the browser

What this is

A build system that compiles Qt6 + PyQt6 to WebAssembly and links them into Pyodide, allowing Python GUI code like this to run in a browser:

from PyQt6.QtWidgets import QApplication, QLabel
app = QApplication([])
label = QLabel("Hello from PyQt6!")
label.show()

Qt renders to a <div> container element via Emscripten's HTML5 canvas backend.

Prerequisites

  • pixi (package manager)
  • ~10 GB disk space (Qt6 source + build artifacts)
  • macOS or Linux (tested on macOS arm64)

Quick start

pixi run build       # full build (~40 min first time)
pixi run test        # smoke test (verify PyQt6 imports)
pixi run serve       # serve on localhost:8080
# open http://localhost:8080/test.html

Individual build phases

pixi run build-qt      # Phase 1: Qt6 for WASM (~25 min)
pixi run build-pyqt    # Phase 2: SIP + PyQt6 modules (~5 min)
pixi run build-pyodide # Phase 3: Link into Pyodide (~5 min)
pixi run test          # Smoke test (Node.js)
pixi run package       # Create distributable zip
pixi run clean         # Remove all build artifacts

How it works

┌─────────────────────────────────────────────────────┐
│ Browser                                             │
│  ┌───────────────┐  ┌────────────────────────────┐  │
│  │ Pyodide (JS)  │──│ Python 3.13 (WASM)         │  │
│  │               │  │  ├─ PyQt6.QtCore            │  │
│  │               │  │  ├─ PyQt6.QtGui             │  │
│  │               │  │  ├─ PyQt6.QtWidgets         │  │
│  │               │  │  └─ ...                     │  │
│  └───────────────┘  └─────────┬──────────────────┘  │
│                               │                     │
│  ┌────────────────────────────▼──────────────────┐  │
│  │ Qt6 (WASM) ──► <div> container ──► Canvas     │  │
│  └───────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────┘
  1. Qt6 is compiled from source to WebAssembly using Emscripten
  2. PyQt6 (SIP-generated C++ bindings) is compiled against the WASM Qt6
  3. Everything is statically linked into a single Pyodide WASM binary
  4. Python modules are registered via PyImport_AppendInittab as builtins

All WASM compilation uses Pyodide's own Emscripten SDK to ensure ABI compatibility (wasm exceptions, PIC relocations, longjmp mode).

Browser usage

<div id="qt-container" style="width: 800px; height: 600px;"></div>
<script type="module">
  import { loadPyodide } from "./pyodide/pyodide.mjs";
  const pyodide = await loadPyodide();
  pyodide._module.qtContainerElements = [document.getElementById("qt-container")];
  pyodide.runPython(`
    from PyQt6.QtWidgets import QApplication, QLabel
    app = QApplication([])
    label = QLabel("Hello World!")
    label.show()
  `);
</script>

Key points:

  • Set qtContainerElements on the Pyodide module before creating QApplication
  • Qt renders into the container <div> elements
  • Don't call app.exec() — the browser event loop drives Qt

Available modules

Module Status
QtCore Working
QtGui Working
QtWidgets Working
QtSvg Working
QtSvgWidgets Working
QtXml Working
sip Working

Threading classes (QThread, QMutex, etc.) are excluded — WASM is single-threaded.

Version matrix

Component Version
Qt6 6.10.2
PyQt6 6.10.2
Pyodide 0.29.3
Python 3.13.2
Emscripten 4.0.9

Project structure

├── build.sh                      # Build pipeline (qt → pyqt → pyodide)
├── pixi.toml                     # Host tool dependencies
├── test.html                     # Browser test page (signals/slots demo)
├── .github/workflows/build.yml   # CI: build + test + release
├── scripts/
│   ├── smoke-test.mjs            # Node.js smoke test
│   ├── generate-patches.sh       # Generate .patch files from clean sources
│   └── apply-patches.sh          # Apply .patch files
├── patches/
│   ├── apply-wasm-patches.py     # WASM patches for PyQt6 + Pyodide
│   ├── qt_plugin_import.cpp      # Qt static plugin registration
│   ├── qt_wasm_stubs.c           # No-op stubs for pthread/idb functions
│   ├── qtimezone_stub.sip        # Minimal QTimeZone for non-timezone builds
│   └── pyqt6_import_hook.py      # PyQt6 namespace import hook
└── build/                        # Build artifacts (gitignored)
    ├── qt6-wasm/                 # Qt6 WASM install
    ├── pyqt6-build/              # PyQt6 module .a libraries
    ├── pyqt6-sip-build/          # PyQt6-sip .a library
    └── sources/                  # Cloned sources (Qt6, Pyodide, PyQt6)

Known limitations

  • No app.exec() — the browser event loop must drive Qt; blocking calls will freeze the page
  • No threads — Qt is built with -no-feature-thread; QThread/QMutex APIs unavailable
  • Single window — Qt WASM renders to container elements; multi-window requires multiple containers
  • Large binary — the WASM output is ~33 MB (could be reduced with -Oz and dead code stripping)
  • GPL v3 — PyQt6 is GPL-licensed

CI/CD

Builds run on every push and PR via GitHub Actions. Tagged releases (v*) automatically publish a distributable zip.

Acknowledgments

License

Build scripts and patches are MIT. PyQt6 itself is GPL v3.

About

Scripts and patches to build pyodide with pyqt6

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors