Skip to content

adamdeprince/witty_for_python

Repository files navigation

witty_for_python

CI

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 layouts WBoxLayout (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
  • 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

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 (identity and subtype preserved), 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 same re-arm after transfer pattern is applied across the bindings — see docs/binding_design.md §4 for the binding-side rules (every widget class is built via heap_init so it can transfer to unique_ptr; every add_*/set_*/insert_* method calls nb::inst_set_state to mark the source wrapper non-owning after the transfer).
  • Returning a WApplication from an entry-point factory hands ownership to the Wt session manager (the factory is invoked through a hand-written closure that pins WEnvironment to rv_policy::reference so 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::cast with rv_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.

About

No description, website, or topics provided.

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors

Languages