Skip to content

Commit acbb14e

Browse files
committed
Add the Python binding: ctypes over the C ABI, driven from asyncio
bindings/python is a pure-ctypes package, so one wheel per platform serves every Python 3 version. `./codegen` generates it from the IR (tools/codegen/bindings/src/python.rs): nativeapi/_capi.py mirrors the C ABI one to one, and each header gets a public module with IntEnum/IntFlag enums, dataclasses for values and events, and NativeObject classes for handles. Only _library.py (loading the library) and _runtime.py (ownership, marshalling, callbacks, the event loop) are hand-written. CMake builds core into a shared library together with a small event loop shim, so `await Application.run_async()` pumps the platform loop from asyncio and tasks, timers and I/O keep running; `Application.run()` blocks as in the other bindings. scikit-build-core builds the wheel. Example: python_window_example (a uv project). python-ci.yml lints, tests and builds the wheel on macOS, Windows and Linux.
1 parent e97ee2f commit acbb14e

56 files changed

Lines changed: 9855 additions & 11 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/python-ci.yml‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
name: Python CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
paths:
7+
- "bindings/python/**"
8+
- "core"
9+
- "examples/python_*/**"
10+
- ".github/workflows/python-ci.yml"
11+
pull_request:
12+
branches: [main]
13+
paths:
14+
- "bindings/python/**"
15+
- "core"
16+
- "examples/python_*/**"
17+
- ".github/workflows/python-ci.yml"
18+
workflow_dispatch:
19+
20+
permissions:
21+
contents: read
22+
23+
concurrency:
24+
group: ci-${{ github.workflow }}-${{ github.ref }}
25+
cancel-in-progress: true
26+
27+
jobs:
28+
build-test:
29+
name: Build & Test (${{ matrix.os }})
30+
runs-on: ${{ matrix.os }}
31+
timeout-minutes: 30
32+
strategy:
33+
fail-fast: false
34+
matrix:
35+
os: [ubuntu-latest, macos-latest, windows-latest]
36+
steps:
37+
- uses: actions/checkout@v4
38+
- name: Checkout core
39+
run: git submodule update --init core
40+
- uses: astral-sh/setup-uv@v6
41+
- name: Install Linux dependencies
42+
if: runner.os == 'Linux'
43+
run: sudo apt-get update && sudo apt-get install -y cmake pkg-config libgtk-3-dev libx11-dev libxi-dev xvfb
44+
- name: Lint
45+
working-directory: bindings/python
46+
run: uvx ruff check nativeapi tests --select E,F,W,B,UP,I
47+
- name: Build the library
48+
working-directory: bindings/python
49+
run: |
50+
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
51+
cmake --build build --config Release
52+
- name: Test
53+
working-directory: bindings/python
54+
shell: bash
55+
env:
56+
PYTHONPATH: .
57+
run: |
58+
cmd="uvx --with pytest pytest"
59+
if [ "$RUNNER_OS" = "Linux" ]; then xvfb-run -a $cmd; else $cmd; fi
60+
- name: Build the wheel
61+
working-directory: bindings/python
62+
run: uv build --wheel

‎AGENTS.md‎

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# libnativeapi workspace
22

3-
This is the workspace repo (`nativeapi-workspace`, formerly `nativeapi-flutter`) for the [libnativeapi](https://github.com/libnativeapi) project family. All three bindings (`bindings/dart/`, `bindings/rust/`, `bindings/csharp/`), the code generator (`tools/codegen/`), the `./codegen` script, the specs and the shared tooling live directly in this repo (the Rust and C# histories were merged in from `nativeapi-rust` and `nativeapi-csharp`); only `core/` is a git submodule of an independent repository. Work inside `core/` is committed and pushed from that subdirectory; everything else is committed here.
3+
This is the workspace repo (`nativeapi-workspace`, formerly `nativeapi-flutter`) for the [libnativeapi](https://github.com/libnativeapi) project family. Every binding (`bindings/dart/`, `bindings/rust/`, `bindings/csharp/`, `bindings/js/`, `bindings/python/`), the code generator (`tools/codegen/`), the `./codegen` script, the specs and the shared tooling live directly in this repo (the Rust and C# histories were merged in from `nativeapi-rust` and `nativeapi-csharp`); only `core/` is a git submodule of an independent repository. Work inside `core/` is committed and pushed from that subdirectory; everything else is committed here.
44

55
## Layout
66

@@ -9,8 +9,10 @@ core/ # submodule: nativeapi-core — the C++ core library
99
bindings/
1010
├── dart/ # the Dart binding: nativeapi/, cnativeapi/, nativeapi_flutter/
1111
├── rust/ # the Rust binding: crates/{nativeapi,cnativeapi}
12-
└── csharp/ # the C# binding: src/, tests/, NativeAPI.slnx
13-
examples/ # every binding's example apps, prefixed flutter_*, rust_*, csharp_*
12+
├── csharp/ # the C# binding: src/, tests/, NativeAPI.slnx
13+
├── js/ # the JS/TS binding: a Node-API addon (src/) + TypeScript (lib/)
14+
└── python/ # the Python binding: ctypes package (nativeapi/) + native shim (src/)
15+
examples/ # every binding's example apps, prefixed flutter_*, rust_*, csharp_*, js_*, python_*
1416
pubspec.yaml # pub workspace + melos root: Dart packages and Flutter examples
1517
Cargo.toml # cargo workspace root: Rust crates and examples
1618
tools/codegen/ # in-repo Rust workspace: the code generator
@@ -23,8 +25,8 @@ codegen # Python entry point orchestrating the generators
2325
## Architecture
2426

2527
- `core` — the C++ core library (repo: `nativeapi-core`). The source of truth for the native API surface (windows, tray icons, menus, displays, keyboard, dialogs, storage, etc.) with per-platform implementations (macOS/Windows/Linux).
26-
- `tools/codegen` — three crates: `shared` (libclang parser, IR, naming), `capi` (C ABI + umbrella header), `bindings` (Rust/Dart/C# generators, consuming the IR JSON emitted by `capi`). Only `capi` depends on libclang. See tools/codegen/README.md.
27-
- `bindings/*` — language bindings wrapping the core library. All live in this repo and build against the `core/` submodule directly (Rust `build.rs`, the Dart `cnativeapi` package's build hook, the C# native CMake). Only a published package carries its own copy of core, in `cxx_impl/`, which the release workflows vendor and never commit. The Rust binding layers `nativeapi` (safe API) over `cnativeapi` (FFI).
28+
- `tools/codegen` — three crates: `shared` (libclang parser, IR, naming), `capi` (C ABI + umbrella header), `bindings` (Rust/Dart/C#/JS/Python generators, consuming the IR JSON emitted by `capi`). Only `capi` depends on libclang. See tools/codegen/README.md.
29+
- `bindings/*` — language bindings wrapping the core library. All live in this repo and build against the `core/` submodule directly (Rust `build.rs`, the Dart `cnativeapi` package's build hook, the C# native CMake, the JS addon's and the Python binding's `CMakeLists.txt`). Only a published package carries its own copy of core, in `cxx_impl/`, which the release workflows vendor and never commit. The Rust binding layers `nativeapi` (safe API) over `cnativeapi` (FFI). The Python binding has no compiled extension: generated `ctypes` code (`nativeapi/_capi.py` plus one module per header) calls a shared library built from core and a small event loop shim, which `Application.run_async()` pumps from asyncio.
2830

2931
## Design specs
3032

@@ -48,12 +50,12 @@ breaks). When one is resolved, edit the spec text itself.
4850
Always drive the generators through `./codegen` at the workspace root:
4951

5052
- `./codegen` — full run: C ABI, then all bindings
51-
- `./codegen capi` / `./codegen bindings [--lang rust,dart,csharp]`
53+
- `./codegen capi` / `./codegen bindings [--lang rust,dart,csharp,js,python]`
5254
- `./codegen check` — read-only verification, non-zero exit when stale (CI mode)
5355
- `./codegen readme` — copy the shared README sections (`tools/readme/*.md`, e.g. Contributing) into core and every binding; `check` flags drift, `sync` runs it. Edit the snippet, never the copies.
5456
- `./codegen sync [-m "msg"] [--push]` — full downstream propagation, see below
5557

56-
Generated files start with `// AUTO-GENERATED. DO NOT EDIT.` — change the C++ headers in `core/src/` and regenerate instead of editing outputs. Files without that banner are hand-written and never overwritten. The header list (`API_HEADERS`) lives in `tools/codegen/shared/src/lib.rs`.
58+
Generated files start with `// AUTO-GENERATED. DO NOT EDIT.` (`#` in Python) — change the C++ headers in `core/src/` and regenerate instead of editing outputs. Files without that banner are hand-written and never overwritten. The header list (`API_HEADERS`) lives in `tools/codegen/shared/src/lib.rs`.
5759

5860
## Changing the core API
5961

@@ -68,7 +70,7 @@ It regenerates everything, reruns `bindgen` (Rust raw FFI) and `ffigen` (Dart ra
6870
Manual follow-ups sync cannot do (details in tools/codegen/README.md):
6971

7072
- New handle types need an `IdTypeTag<T>` entry in `core/src/foundation/id_allocator.h` (append only; a miss is a compile error, not silent).
71-
- Hand-written files in the bindings (exports, re-exports, changelogs, examples) are never touched by the generators. Rust's `pub mod` list is generated (`modules.rs`), and so is Dart's (`nativeapi/lib/src/generated.dart`); `nativeapi_flutter`'s exports are not.
73+
- Hand-written files in the bindings (exports, re-exports, changelogs, examples) are never touched by the generators. Rust's `pub mod` list is generated (`modules.rs`), and so is Dart's (`nativeapi/lib/src/generated.dart`) and Python's (`nativeapi/__init__.py`); `nativeapi_flutter`'s exports are not.
7274

7375
The `core-api-change` skill walks the whole flow, including what to check before `sync` commits.
7476

@@ -93,8 +95,8 @@ scenarios for *this project's* examples live in [tools/gui/](tools/gui/README.md
9395
- `core` tracks `branch = main`. Use `make sync` to fast-forward it; `make status` to see dirty state everywhere; `make bump` to stage its pointer.
9496
- The leanflutter packages built on nativeapi (`tray_manager`, `window_manager`, `launch_at_startup`, …) live in their own repos under github.com/leanflutter and depend on the published `nativeapi`; they are not part of this repo. To try one against local changes, point a `dependency_overrides` entry in that package at `bindings/dart/nativeapi_flutter` (and `nativeapi`, `cnativeapi`) and never commit the override.
9597
- Commit workspace submodule pointer updates only when the combination is compatible (a known-good snapshot).
96-
- Examples live in `examples/<binding>_<name>_example` (`flutter_`, `rust_`, `csharp_`), not inside the bindings; a new Flutter or Rust example must also be listed in the root `pubspec.yaml` / `Cargo.toml`, a C# one in `bindings/csharp/NativeAPI.slnx`. Only the pub.dev package examples (`bindings/dart/*/example`) stay inside their package.
97-
- CI is one workflow per binding (`dart-ci.yml`, `rust-ci.yml`, `csharp-ci.yml`), each running only for changes under its `bindings/<lang>/` and its examples (`examples/flutter_*` for Dart, `examples/rust_*`, `examples/csharp_*`). Release tags are per binding: `v*` publishes the Dart packages (`dart-release.yml`), `rust-v*` publishes the crates (`rust-release.yml`); never push a bare `v*` tag for anything but the Dart packages.
98+
- Examples live in `examples/<binding>_<name>_example` (`flutter_`, `rust_`, `csharp_`, `js_`, `python_`), not inside the bindings; a new Flutter or Rust example must also be listed in the root `pubspec.yaml` / `Cargo.toml`, a C# one in `bindings/csharp/NativeAPI.slnx`, a JS one in the root `package.json`. A Python example is a standalone uv project whose `pyproject.toml` points `nativeapi` at `../../bindings/python` (`uv run main.py` builds the wheel). Only the pub.dev package examples (`bindings/dart/*/example`) stay inside their package.
99+
- CI is one workflow per binding (`dart-ci.yml`, `rust-ci.yml`, `csharp-ci.yml`, `js-ci.yml`, `python-ci.yml`), each running only for changes under its `bindings/<lang>/` and its examples (`examples/flutter_*` for Dart, `examples/rust_*`, `examples/csharp_*`, `examples/js_*`, `examples/python_*`). Release tags are per binding: `v*` publishes the Dart packages (`dart-release.yml`), `rust-v*` publishes the crates (`rust-release.yml`); never push a bare `v*` tag for anything but the Dart packages. The Python package is not published yet.
98100
- The Dart packages `cnativeapi`, `nativeapi` and `nativeapi_flutter` share one version: a release bumps all three pubspecs and CHANGELOGs, because pub.dev's automated publishing matches the tag `v<version>` against each package's own version. `cnativeapi` and `nativeapi` are plain Dart (no Flutter dependency; a build hook compiles core); `nativeapi_flutter` is the Flutter-facing package, holding the widgets, the `dart:ui` conversions and `windowing.dart`, and re-exporting `nativeapi` minus the names that clash with Flutter's.
99101
- Never commit in a submodule while on a detached HEAD — check out `main` first (`./codegen sync` enforces this).
100102
- Do not add Co-Authored-By trailers to commits.

‎bindings/python/.gitignore‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/build/
2+
/dist/
3+
/cxx_impl/
4+
*.egg-info/
5+
.venv/

‎bindings/python/CMakeLists.txt‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Builds the shared library the Python binding loads with ctypes: the whole
2+
# core static archive plus the event loop shim for Application.run_async().
3+
#
4+
# Built by scikit-build-core when the wheel is built (`pip install .`), which
5+
# installs the library into the `nativeapi` package; or by hand for a source
6+
# checkout (`cmake -S . -B build && cmake --build build`), where
7+
# nativeapi/_library.py finds it in build/.
8+
cmake_minimum_required(VERSION 3.24)
9+
10+
project(nativeapi_python LANGUAGES CXX C)
11+
if(APPLE)
12+
enable_language(OBJCXX)
13+
endif()
14+
15+
set(CMAKE_CXX_STANDARD 17)
16+
set(CMAKE_CXX_STANDARD_REQUIRED ON)
17+
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
18+
19+
# The published sdist vendors core into cxx_impl/; in this repository the
20+
# binding builds against the core/ submodule directly.
21+
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/cxx_impl/src/CMakeLists.txt")
22+
set(NATIVEAPI_CORE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/cxx_impl")
23+
else()
24+
set(NATIVEAPI_CORE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../core")
25+
endif()
26+
add_subdirectory("${NATIVEAPI_CORE_DIR}/src" nativeapi_core)
27+
28+
set(SOURCES src/event_loop_common.cc)
29+
if(APPLE)
30+
list(APPEND SOURCES src/event_loop_macos.mm)
31+
elseif(WIN32)
32+
list(APPEND SOURCES src/event_loop_windows.cc)
33+
else()
34+
list(APPEND SOURCES src/event_loop_linux.cc)
35+
endif()
36+
37+
add_library(nativeapi_python SHARED ${SOURCES})
38+
target_include_directories(nativeapi_python PRIVATE "${NATIVEAPI_CORE_DIR}/src")
39+
# Re-export every C ABI symbol of the archive, not just the ones the shim uses.
40+
target_link_libraries(nativeapi_python PRIVATE
41+
$<LINK_LIBRARY:WHOLE_ARCHIVE,nativeapi>)
42+
if(WIN32)
43+
target_link_libraries(nativeapi_python PRIVATE ole32 uuid)
44+
endif()
45+
set_target_properties(nativeapi_python PROPERTIES
46+
OUTPUT_NAME nativeapi
47+
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}"
48+
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}"
49+
)
50+
51+
if(SKBUILD)
52+
install(TARGETS nativeapi_python
53+
LIBRARY DESTINATION nativeapi
54+
RUNTIME DESTINATION nativeapi
55+
)
56+
endif()

‎bindings/python/README.md‎

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# nativeapi (Python)
2+
3+
Native desktop APIs — windows, tray icons, menus, displays, global shortcuts,
4+
dialogs, preferences and secure storage — for **Python 3.10+**, on macOS,
5+
Windows and Linux.
6+
7+
Pure `ctypes` over the [libnativeapi](https://github.com/libnativeapi) C ABI:
8+
no compiled extension module, so one wheel per platform serves every Python 3
9+
version. The typed Python layer is generated from the C++ headers of core by
10+
`./codegen`; only `nativeapi/_library.py`, `nativeapi/_runtime.py` and the
11+
event loop shim in `src/` are hand-written.
12+
13+
> **Status: prototype.** The whole API surface is generated, but it has not
14+
> been published and the API may still change.
15+
16+
```python
17+
import asyncio
18+
19+
from nativeapi import Application, Size, Window, WindowClosedEvent, WindowManager
20+
21+
22+
async def main() -> int:
23+
window = Window()
24+
window.set_title("Hello")
25+
window.set_size(Size(800, 600), False)
26+
window.center()
27+
28+
def on_event(event):
29+
if isinstance(event, WindowClosedEvent):
30+
Application.quit()
31+
32+
WindowManager.add_listener(on_event)
33+
return await Application.run_async(window) # asyncio keeps running
34+
35+
36+
asyncio.run(main())
37+
```
38+
39+
## Model
40+
41+
- **Objects** (`Window`, `Menu`, `TrayIcon`, …) wrap a handle. The default
42+
constructor is `Window()`; other constructors are class methods
43+
(`Preferences.with_scope("app")`). A failed creation raises
44+
`NativeApiError`. The reference is released by `dispose()` / `with`, or when
45+
the wrapper is garbage collected. Calls on a released handle fail safely.
46+
- **Getters** without arguments are properties (`window.title`,
47+
`window.bounds`, `window.is_visible`); everything else is a method
48+
(`window.set_title(...)`).
49+
- **Values** (`Point`, `Size`, `Rectangle`, `Color`, …) are dataclasses.
50+
- **Enums** are `IntEnum`s (`TitleBarStyle.HIDDEN`); bit sets are `IntFlag`s
51+
(`ModifierKey.SHIFT | ModifierKey.ALT`).
52+
- **Events** are frozen dataclasses, one subclass per kind, so they work
53+
with `match`: `case WindowMovedEvent(window_id=id, new_position=p)`.
54+
- **Singletons** (`Application`, `WindowManager`, `DisplayManager`, …) are
55+
classes with static methods.
56+
57+
## The event loop
58+
59+
- `Application.run(window=None)` blocks in the platform loop and returns the
60+
exit code. Listeners run on the main thread in between. Ctrl+C terminates
61+
the process.
62+
- `await Application.run_async(window=None)` pumps the platform loop from the
63+
running asyncio loop instead, so tasks, timers and I/O keep running while
64+
windows are up. It resolves with the exit code once `Application.quit(code)`
65+
is called. An exception raised in a listener goes to the asyncio loop's
66+
exception handler.
67+
68+
Both must be called on the main thread.
69+
70+
## Building
71+
72+
```bash
73+
cd examples/python_window_example && uv run main.py # builds the wheel, runs the example
74+
```
75+
76+
For work on the binding itself, build the library in place and run from the
77+
source tree; `nativeapi/_library.py` finds it in `build/`:
78+
79+
```bash
80+
cmake -S bindings/python -B bindings/python/build && cmake --build bindings/python/build
81+
cd bindings/python && PYTHONPATH=. uvx --with pytest pytest
82+
```
83+
84+
`NATIVEAPI_LIBRARY=/path/to/libnativeapi.dylib` overrides the lookup.
85+
86+
## Contributing
87+
88+
Development happens in [nativeapi-workspace](https://github.com/libnativeapi/nativeapi-workspace), which holds every binding and the code generator and checks out the core library as a submodule:
89+
90+
```bash
91+
git clone --recursive https://github.com/libnativeapi/nativeapi-workspace.git
92+
```
93+
94+
Files marked `AUTO-GENERATED. DO NOT EDIT.` are generated from the C++ headers in [nativeapi](https://github.com/libnativeapi/nativeapi-core). To change the API, send a pull request there; maintainers regenerate the bindings.
95+
96+
- API requests and native behavior bugs → [nativeapi-core issues](https://github.com/libnativeapi/nativeapi-core/issues)
97+
- Bugs specific to one binding → [nativeapi-workspace issues](https://github.com/libnativeapi/nativeapi-workspace/issues)
98+
- Not sure → [nativeapi-core issues](https://github.com/libnativeapi/nativeapi-core/issues)

0 commit comments

Comments
 (0)