Python bindings for the Wt (Web Toolkit) C++ library, generated with nanobind. Built wheels bundle Wt (GPLv2), TinyMCE 6.8.4 (MIT), and link against permissively-licensed system libraries (Boost, libharu, libpng, zlib) — see THIRD_PARTY_LICENSES.md for details.
Project description
witty_for_python
Python bindings for Wt (Web Toolkit) — a C++ widget-tree web framework — generated with nanobind and built against C++23.
Independent, unofficial wrapper. witty_for_python is a personal project by Adam DePrince. It is not produced by, endorsed by, sponsored by, or otherwise affiliated with Emweb bv, the authors and copyright holders of Wt. "Wt" is referenced here only in its descriptive sense — to identify the library this software wraps — and remains the property of Emweb. For Wt itself (source, official binaries, support, commercial licensing), go directly to www.webtoolkit.eu/wt.
Status
Pre-alpha scaffold. Initial bindings cover:
- Lifecycle:
WServer,WApplication,WEnvironment,EntryPointType - Containers:
WContainerWidget, plus layoutsWBoxLayout(WHBoxLayout/WVBoxLayout),WGridLayout - Widgets:
WText,WPushButton,WLineEdit,WCheckBox,WAnchor,WImage,WTable(WTableCell,WTableRow,WTableColumn) - Signals:
Signal,EventSignal,MouseEventSignal,KeyEventSignal— Python callables, GIL-aware
Requirements
- C++23 toolchain (gcc ≥ 13, clang ≥ 17)
- CMake ≥ 3.26
- Python ≥ 3.10 (or a free-threaded 3.13t / 3.14t — auto-detected)
- Boost dev headers + zlib + libharu + libpng (Wt's build-time deps; libharu backs
WPdfImage)- Debian / Ubuntu:
sudo apt install libboost-dev libboost-system-dev libboost-thread-dev libboost-filesystem-dev libboost-program-options-dev zlib1g-dev libhpdf-dev libpng-dev
- Debian / Ubuntu:
- Node ≥ 16 + Yarn 1.x (to build the vendored TinyMCE; only needed when
WITTY_FOR_PYTHON_BUILD_TINYMCE=ON, the default)- On Ubuntu with Corepack:
sudo corepack enable yarn
- On Ubuntu with Corepack:
Two third-party libraries are vendored as git submodules and built as part of pip install — you do not install either of them separately:
| Submodule | Version | License | Bundled as |
|---|---|---|---|
extern/wt |
Wt 4.13.2 | GPLv2 | _libs/libwt.so, libwthttp.so, _wt_resources/* |
extern/tinymce |
TinyMCE 6.8.4 | MIT | _wt_resources/tinymce/ (powers WTextEdit) |
See THIRD_PARTY_LICENSES.md for attribution details and the upstream license texts that ride along inside each wheel.
Build & install
git clone --recursive git@github.com:adamdeprince/witty_for_python.git
cd witty_for_python
pip install --no-build-isolation -e ".[test]" # editable + pytest
For a non-editable install: pip install --no-build-isolation ..
scikit-build-core drives CMake; CMake builds Wt from the extern/wt submodule, builds TinyMCE from the extern/tinymce submodule (via yarn build), links our extension against Wt, and bundles libwt.so + libwthttp.so + Wt's static resources + the TinyMCE distribution into the package directory. The extension's RPATH is $ORIGIN/_libs, so import witty_for_python works without any LD_LIBRARY_PATH or ~/.local setup.
First build takes ~13 minutes cold (Wt ≈ 8 min, TinyMCE ≈ 5 min). Both are cached across rebuilds — incremental edits to our own sources finish in under a minute. Pass -DWITTY_FOR_PYTHON_BUILD_TINYMCE=OFF to skip the TinyMCE step (the wheel still builds; WTextEdit won't have a working asset path).
See docs/building_wt.md for details on the vendored-Wt layout, the CMake options we set on it, and how to bump the pin.
Run the example
python examples/gallery.py --docroot . \
--http-address 127.0.0.1 --http-port 8080
Then open http://localhost:8080. The static resources Wt serves to the browser are bundled with the package; the example finds them via witty_for_python.resources_dir.
Project layout
CMakeLists.txt
pyproject.toml
ext/ # C++ source — one bind_*.cpp per topic
common.hpp # nanobind includes + WString <-> str caster
module.cpp # NB_MODULE entrypoint
bind_signals.cpp # Signal, EventSignal, MouseEventSignal, KeyEventSignal
bind_application.cpp # WObject, WWidget, WInteractWidget, WFormWidget, WApplication, WEnvironment
bind_container.cpp # WContainerWidget
bind_widgets.cpp # WText, WPushButton, WLineEdit, WCheckBox, WAnchor, WImage, WLink
bind_table.cpp # WTable, WTableCell, WTableRow, WTableColumn
bind_layout.cpp # WLayout, WBoxLayout, WHBoxLayout, WVBoxLayout, WGridLayout
bind_server.cpp # WServer, EntryPointType
src/witty_for_python/ # Python package (compiled extension installed here)
examples/ # Sample Wt apps written in Python
Ownership model
Wt 4 uses std::unique_ptr for widget ownership; the bindings mirror this:
- A widget you construct in Python is owned by Python.
container.add_widget(widget)transfers ownership of the underlying C++ instance to the container, then re-arms the Python wrapper as a non-owning alias. The wrapper stays usable, the call returns the same Python object (identityandsubtypepreserved), and the fluent shape works:btn = wt.WPushButton("ok") same = container.add_widget(btn) assert same is btn # identity preserved same.clicked.connect(handler) # wrapper still usable container.add_widget(wt.WPushButton("x")).clicked.connect(other) # chains
The samere-arm after transferpattern is applied across the bindings — see docs/binding_design.md §4 for the binding-side rules (every widget class is built viaheap_initso it can transfer tounique_ptr; everyadd_*/set_*/insert_*method callsnb::inst_set_stateto mark the source wrapper non-owning after the transfer).- Returning a
WApplicationfrom an entry-point factory hands ownership to the Wt session manager (the factory is invoked through a hand-written closure that pinsWEnvironmenttorv_policy::referenceso the non-copyable env isn't copied — see §4.3 of the binding-design doc).
Static typing: nanobind's stubgen sees the fluent methods as (widget: object) -> object because the C++ binding takes nb::object. scripts/regenerate_stubs.py runs a post-processing pass that rewrites those signatures to (widget: _T) -> _T with an unbounded TypeVar, so:
btn: wt.WPushButton = container.add_widget(wt.WPushButton("ok")) # type-checker keeps WPushButton
container.add_widget(wt.WPushButton("x")).clicked.connect(handler) # .clicked resolves
The rewrite covers add_widget/add_widgets/add_item/add_items/add_marker/add_popup/add_tooltip/add_button/add_series/bind_widget/add_tab and a handful of others — anything matching (self, …, x: object) -> object or (self, …, xs: list) -> list in the raw stub. The bulk variants lift to list[_T] -> list[_T].
Signal binding
Signals expose a single connect(callable) method. The callable's positional-arg count is inspected once at connect time:
- 0 args → the slot is invoked with no arguments and any payload is dropped.
- 1+ args → the payload is forwarded through
nb::castwithrv_policy::copy, so the Python object survives outside the synchronous slot call.
# Both forms work — witty_for_python introspects each callable.
button.clicked.connect(lambda: print("clicked"))
button.clicked.connect(lambda evt: print(evt.widget.x, evt.widget.y))
The same connect works for Signal[<T>] payloads (IntSignal, BoolSignal, DoubleSignal, StringSignal, DialogCodeSignal, StandardButtonSignal, MenuItemSignal) and EventSignal types (EventSignal, MouseEventSignal, KeyEventSignal). Slot exceptions are surfaced via PyErr_WriteUnraisable rather than swallowed.
Shutdown warnings
If you ever see this on interpreter exit:
nanobind: leaked N instances!
nanobind: leaked M types!
nanobind: leaked K functions!
...
nanobind: this is likely caused by a reference counting issue in the binding code.
it is cosmetic — the OS reclaims the memory normally; nothing actually leaks at runtime. It is nanobind's liveness check at module finalization: any bound C++ instance still alive when its module is torn down is reported. The root cause is structural: Wt signals hold Python callables (via std::shared_ptr<nb::object> in the connection slot lambdas), and that holder chain keeps both the callables and any bound widgets they capture alive past the point where nanobind takes its census.
To prevent the warning, the bindings maintain a process-wide registry of every connection opened through witty_for_python's connect() and expose two helpers:
witty_for_python._live_connection_count() # diagnostic — how many slot holders we keep
witty_for_python._cleanup_all_connections() # disconnect every one of them
witty_for_python/__init__.py registers _cleanup_all_connections() as an atexit handler, so under normal interpreter shutdown the connection registry is flushed before nanobind's finalizer runs. The slot lambdas are destroyed, their shared_ptr<nb::object> holders drop their references to the Python callables, Python's module-clear pass then reclaims the bound wrappers, and the leak check finds nothing.
In practice this means: under a clean sys.exit(0) or end-of-script termination, the warning does not appear. It can still surface if you crash hard, call os._exit() (which skips atexit), or unregister our handler. You can verify the cleanup is wired up by inspecting witty_for_python._live_connection_count() in your own atexit handler.
You may also call witty_for_python._cleanup_signal_slots() directly between tests, or call signal.disconnect_all_slots() on an individual signal — both go through the same registry.
License
witty_for_python is licensed under the GNU General Public License, Version 2 only — the same restriction Wt itself imposes ("Other versions of the GPL do not apply"). The full text is in LICENSE.
A Wt commercial license obtained from Emweb does not grant any commercial license to witty_for_python. The two are independent works with independent copyright holders; a license to one is not a license to the other. witty_for_python is currently available only under GPLv2.
Bundled third-party software
Built wheels redistribute several upstream open-source projects — see THIRD_PARTY_LICENSES.md for full attribution and license texts.
Vendored at source level (git submodules under extern/, so the exact source for any binary we ship is traceable to a specific upstream commit):
- Wt 4.13.2 — GPL-2.0-only ("Wt OSS license"). The licensing peer of this project.
- TinyMCE 6.8.4 — MIT license. Backs
WTextEdit.
Linked transitively (pulled from the build environment's system packages; the resulting .so files end up inside the wheel via auditwheel repair during the manylinux release pipeline):
- Boost — Boost Software License 1.0. Used by Wt for threading, filesystem, and command-line parsing.
- libharu — zlib/libpng license. Backs
WPdfImage. - libpng — libpng license. Pulled in by libharu for PNG image embedding.
- zlib — zlib license. Compression used pervasively across Wt + libharu.
All linked libraries are permissively licensed and combine cleanly with this project's GPL-2.0-only license; the wheel can be redistributed under our license without additional restrictions from these libraries.
Copyright (C) 2026 Adam DePrince. All rights reserved.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distributions
Built Distributions
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file witty_for_python-0.1.2-cp312-abi3-manylinux_2_34_x86_64.whl.
File metadata
- Download URL: witty_for_python-0.1.2-cp312-abi3-manylinux_2_34_x86_64.whl
- Upload date:
- Size: 14.2 MB
- Tags: CPython 3.12+, manylinux: glibc 2.34+ x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9984516c6288c1d8b1cab1c5bd7c905455559e8bc09274aa74bb41538ee4d12f
|
|
| MD5 |
9a593bafc2f2f3baf8ab07607e6b2909
|
|
| BLAKE2b-256 |
fa53a2d10960fe38bdea52ec483a5701eaad79b565904d7de0799b250f9c58e2
|
File details
Details for the file witty_for_python-0.1.2-cp312-abi3-manylinux_2_34_aarch64.whl.
File metadata
- Download URL: witty_for_python-0.1.2-cp312-abi3-manylinux_2_34_aarch64.whl
- Upload date:
- Size: 13.3 MB
- Tags: CPython 3.12+, manylinux: glibc 2.34+ ARM64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d66ef0f2bb859f52a9934d303fdc21edc20a069ec555c64c7fc5714a51a0322b
|
|
| MD5 |
beb2c1b5ef92d5aa6938412625718105
|
|
| BLAKE2b-256 |
c082097132b57ffe57e0ad5a85376ae6f9f910d45fda851fda4acae88e4adff6
|
File details
Details for the file witty_for_python-0.1.2-cp312-abi3-macosx_15_0_arm64.whl.
File metadata
- Download URL: witty_for_python-0.1.2-cp312-abi3-macosx_15_0_arm64.whl
- Upload date:
- Size: 10.1 MB
- Tags: CPython 3.12+, macOS 15.0+ ARM64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1e060fd76284650a2a98c63525f8e81e54eb206c7335dc6fd6c8adc775e4d20e
|
|
| MD5 |
0a8c68b6cd48141951325cabd6a319fb
|
|
| BLAKE2b-256 |
6e36797e1272fd918b880b593142c18856e5e47671c1ecc229ec8942feb75421
|