FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

feat!: overhaul native runtime contracts and lifecycle ownership (#86) · pythonnative/pythonnative@0b21046 · GitHub

feat!: overhaul native runtime contracts and lifecycle ownership (#86) · pythonnative/pythonnative@0b21046 · GitHub
Skip to content

Navigation Menu

Commit 0b21046

Browse files
authored
feat!: overhaul native runtime contracts and lifecycle ownership (#86)
1 parent 778a993 commit 0b21046

147 files changed

Lines changed: 5812 additions & 1301 deletions

File tree

Some content is hidden

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

‎.github/workflows/ci.yml‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,16 @@ jobs:
4444
- name: Run tests
4545
run: uv run pytest -q
4646

47+
browser:
48+
runs-on: ubuntu-latest
49+
steps:
50+
- uses: actions/checkout@v4
51+
- uses: astral-sh/setup-uv@v10.0.1
52+
with:
53+
python-version: '3.13'
54+
- run: uv sync --locked --group dev
55+
- run: uv run python scripts/run-browser-tests.py
56+
4757
# The Swift half of the rendering core: decoders, component managers,
4858
# gestures, and module promises run under XCTest on a simulator.
4959
native-ios:
@@ -73,12 +83,21 @@ jobs:
7383
')"
7484
echo "Using simulator $UDID"
7585
xcodebuild test \
76-
-scheme PythonNativeKit \
86+
-project Tests/NativeTests.xcodeproj \
87+
-scheme NativeTests \
88+
-parallel-testing-enabled NO \
7789
-destination "platform=iOS Simulator,id=$UDID" \
7890
-derivedDataPath "$RUNNER_TEMP/pnkit" \
79-
-quiet
91+
-resultBundlePath "$RUNNER_TEMP/pnkit-results.xcresult"
92+
93+
- name: Upload native test results
94+
if: failure()
95+
uses: actions/upload-artifact@v4
96+
with:
97+
name: native-ios-results
98+
path: ${{ runner.temp }}/pnkit-results.xcresult
8099

81-
# The Kotlin half has no Chaquopy dependency, so plain JUnit covers it.
100+
# JUnit covers contracts; Robolectric exercises native widget lifecycles.
82101
native-android:
83102
runs-on: ubuntu-latest
84103
steps:

‎.github/workflows/e2e.yml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ jobs:
5353
suites: navigation gestures layout styling
5454
- name: anim-misc
5555
suites: animations misc
56+
- name: inbox
57+
suites: inbox
5658

5759
name: e2e-android (${{ matrix.name }})
5860

@@ -164,6 +166,8 @@ jobs:
164166
suites: navigation gestures layout styling
165167
- name: anim-misc
166168
suites: animations misc
169+
- name: inbox
170+
suites: inbox
167171

168172
name: e2e-ios (${{ matrix.name }})
169173

‎.gitignore‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,3 +170,6 @@ cython_debug/
170170
**/.gradle/
171171
**/.kotlin/
172172
**/.swiftpm/
173+
174+
# Built sample extension wheels
175+
examples/inbox/vendor/

‎CHANGELOG.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,24 @@
11
# CHANGELOG
22

3+
## Unreleased
4+
5+
- Make element snapshots immutable and preserve ordinary Python component
6+
argument binding, async provider context, stable setters, and deferred updates.
7+
- Stage reconciliation publication and roll back failed renders; retire surfaces
8+
after native mount failures. Validate commits without copying unrelated records.
9+
- Enforce shared component, event, command, and service contracts, including
10+
nested values, property resets, recreation, and native result types.
11+
- Use one list request protocol on every backend, stable dataset and item
12+
revisions, bounded row ownership, and public indices that exclude headers.
13+
- Complete nested native stack ownership, input composition and selection,
14+
dynamic text sizing, cancelable native work, and durable Android notifications.
15+
- Test the real browser renderer and native lifecycle behavior. Package the Inbox
16+
extension separately and run reference-app acceptance on both mobile CI jobs.
17+
- Remove permissive transaction decoders, alternate headless list rendering,
18+
obsolete compatibility branches, and unrestricted device-option dictionaries.
19+
These pre-1.0 changes intentionally break superseded APIs.
20+
21+
322

423
## v0.42.0 (2026-09-08)
524

‎CONTRIBUTING.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ uv run black src examples tests # format
106106
uv run --group docs mkdocs serve # preview the docs site locally
107107

108108
# native rendering core (macOS with Xcode for the Swift package; JDK 17 for Gradle)
109-
(cd src/pythonnative/native/ios && xcodebuild test -scheme PythonNativeKit -destination 'platform=iOS Simulator,name=iPhone 15 Pro')
109+
(cd src/pythonnative/native/ios && xcodebuild test -project Tests/NativeTests.xcodeproj -scheme NativeTests -destination 'platform=iOS Simulator,name=iPhone 15 Pro')
110110
(cd src/pythonnative/native/android && ../../templates/android_template/gradlew testDebugUnitTest)
111111
```
112112

‎docs/concepts/bridge.md‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@ A surface commit has this shape:
4848
The renderer validates the entire operation sequence before mutation: operation
4949
arity, live tags, insertion bounds, cycles, typed values, and finite geometry.
5050
An accepted commit returns `ok`, `application`, `surface`, and the exact
51-
`revision`. Python advances its native bookkeeping only after that acknowledgement.
51+
`revision`, plus optional native timing metrics. Python advances its bookkeeping
52+
only after that acknowledgment. A rejection with `failed: false` reports
53+
validation failure before mutation and preserves the previous revision.
5254
A failed surface rejects further incremental updates. A new application identity
5355
and a complete remount establish a clean surface; replaying a partial commit
5456
isn't a recovery strategy. The current app host uses one surface.
@@ -73,14 +75,24 @@ core, and the browser preview uses the corresponding Yoga WebAssembly package.
7375

7476
The application sends styles as props. Native leaf managers measure text,
7577
controls, and images next to their widgets. One `Layout.compute` request returns
76-
a batch of changed frames. Python doesn't make one measurement RPC per leaf.
78+
a batch of changed frames with `application`, `surface`, and `revision`.
79+
Geometry from another surface or revision is ignored. Drawing-only prop updates
80+
skip the request entirely. Python doesn't make one measurement RPC per leaf.
81+
82+
Commit and required layout acknowledgments precede ref and effect publication.
83+
Native navigation animations and later platform layout changes may continue after
84+
that boundary; their geometry returns through the same identified layout channel.
7785

7886
A native screen, portal, modal, or recycled row can be physically attached to a
7987
platform container while remaining a child in the same Python component tree.
8088
Context, error boundaries, suspense boundaries, and task ownership follow that
8189
logical tree. Lists use stable keys and data revisions to validate row requests.
8290
UIKit collection views and Android recycler views own physical cells; a bounded
83-
window of ordinary keyed Python row components supplies their contents.
91+
window of ordinary keyed Python row components supplies their contents. Headless
92+
tests simulate the same viewport and row requests. Dataset revisions change when
93+
data or rendering inputs change; per-item revisions avoid reloading unchanged
94+
cells. Global and section headers don't change public item indices. Row instances
95+
can unmount outside the window, so persistent item state belongs in app data.
8496

8597
## Modules, contracts, and animations
8698

@@ -99,3 +111,12 @@ Animation graphs describe values, arithmetic, interpolation, colors, and
99111
clamping. Native timing, spring, and decay drivers evaluate connected bindings
100112
on the UI thread. Scroll and gesture mappings feed graph values directly, so
101113
visual updates can continue while the Python application thread is busy.
114+
115+
## Profiling the boundary
116+
117+
`PN_PROFILE` traces separate rendering, validation, transport, native mutation,
118+
and layout. Native duration samples appear on a separate trace track, placed at
119+
host receipt without assuming synchronized clocks. Work counters report copied
120+
validation records, operations, native views visited, and changed frames. A
121+
one-property regression test uses 10,000 records to check work growth directly.
122+
These counters and host timings aren't device frame-rate claims.

‎docs/concepts/reconciliation.md‎

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,19 @@ tree:
3434
as needed using [`InsertOp`][pythonnative.mutations.InsertOp] /
3535
[`DestroyOp`][pythonnative.mutations.DestroyOp].
3636

37-
The reconciler runs each render function synchronously to completion on the
38-
application thread. Expensive Python work delays callbacks and commits.
37+
The reconciler runs synchronous render functions to completion on the
38+
application thread. Async components capture their provider environment across
39+
awaits. Changes to their inputs cancel obsolete work before it can publish.
40+
Expensive Python work delays callbacks and commits.
3941
Native scrolling and supported animation drivers run independently; see
4042
[Runtime limits](architecture.md#runtime-limits).
4143

4244
## Commits are transactions
4345

44-
The diff phase is pure: it only accumulates ops. At the end of the
45-
pass the staged mutations are applied through a single
46+
Rendering records changes in an undo journal and stages native operations,
47+
callbacks, refs, and effects. A render that fails restores the committed tree
48+
without publishing those changes. At the end of the pass, staged mutations are
49+
applied through a single
4650
[`apply_mutations`][pythonnative.native_views.NativeViewRegistry.apply_mutations]
4751
call. The renderer validates the versioned transaction before applying it
4852
and acknowledges the committed revision. Layout then runs beside the native
@@ -182,3 +186,22 @@ bottleneck.
182186
- Browse the algorithm in code: [Reconciler API](../api/reconciler.md).
183187
- Understand handlers underneath the diff: [Native views](native-views.md).
184188
- See how mounting interacts with hooks: [Lifecycle](lifecycle.md).
189+
190+
## Elements and update priority
191+
192+
Elements hold read-only property snapshots and tuple children. Values inside
193+
application props aren't deep-copied; replace mutable application data when its
194+
meaning changes. Component construction follows the decorated function's Python
195+
signature, including positional-only parameters, keyword-only parameters,
196+
variadic children, and normal argument errors.
197+
198+
State setters and reducer dispatch functions retain their identity across renders.
199+
Deferred updates stay out of urgent renders. When deferred work runs, the state
200+
queue replays urgent updates in their original order so an urgent edit isn't lost.
201+
Python rendering remains cooperative: a long synchronous function cannot be
202+
preempted by a later urgent update.
203+
204+
A native mount failure retires the surface and cancels its work. Validation
205+
failure before native mutation preserves the previous revision and can be retried.
206+
Property validation copies only changed records, so a one-property update doesn't
207+
clone the rest of the live native tree.

‎docs/guides/native-contracts.md‎

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
Declare an extension's interface in Python, generate its native adapters, and
44
implement the platform behavior in Swift and Kotlin. The complete working
5-
example is `examples/inbox`: its badge uses generated props, and its module
6-
has synchronous and asynchronous methods.
5+
example is the separately packaged `examples/inbox-extension`, consumed by
6+
`examples/inbox`. Its badge uses generated props, and its module has synchronous
7+
and asynchronous methods.
78

89
## Define the interface
910

@@ -66,8 +67,8 @@ Set `contracts` in the plugin's `pn_plugin.json` to its schema file:
6667
}
6768
```
6869

69-
The schema should contain the extension's own components and modules. The inbox
70-
example's `generate_contracts.py` demonstrates extracting those definitions.
70+
The schema should contain the extension's own components and modules. The
71+
`examples/inbox-extension/generate_contracts.py` script demonstrates extracting those definitions.
7172
Builders merge plugin contracts with the built-ins, regenerate native bindings,
7273
and bundle the matching schema into embedded Python. Startup rejects a client
7374
whose protocol, Yoga version, or schema fingerprint doesn't match.
@@ -92,3 +93,35 @@ mobile binary extensions into the desktop build process.
9293

9394
Rebuild after changing a contract or native implementation. Fast Refresh applies
9495
to Python application behavior; it can't change a compiled native interface.
96+
97+
## Runtime enforcement
98+
99+
Built-in widgets and services use these contracts too. Unknown props, methods,
100+
events, and arguments fail validation. Nested dataclasses and `TypedDict` values
101+
validate required fields and reject unknown fields. Wire integers must be exact
102+
numbers within JavaScript's safe integer range; booleans don't count as numbers.
103+
Methods declare both their arguments and their return types. Python validates
104+
native results before exposing them to application code.
105+
106+
A `null` update removes a prop. The renderer restores its declared default, or
107+
recreates the physical widget to restore a platform default. Creation-only
108+
changes, including `TextInput.multiline`, preserve the logical tag, component
109+
state, child ownership, focus, and selection where the platform permits it.
110+
Refs keep addressing that logical tag. `NativeField.platforms` limits where a
111+
field can be used; unsupported fields are rejected by the target renderer.
112+
113+
Regenerate the repository's built-in bindings with:
114+
115+
```sh
116+
uv run python scripts/generate-native-contracts.py
117+
```
118+
119+
The shared fixtures run in Python, Swift, Kotlin, and JavaScript. The actual
120+
browser renderer runs in Chrome with Yoga WebAssembly:
121+
122+
```sh
123+
uv run python scripts/run-browser-tests.py
124+
```
125+
126+
Native service adapters are named `NameModuleAdapter`, which keeps the generated
127+
base class distinct from a platform's `NameModule` implementation.

‎docs/guides/testing.md‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ The Swift and Kotlin halves have their own suites next to the code:
221221

222222
```bash
223223
cd src/pythonnative/native/ios
224-
xcodebuild test -scheme PythonNativeKit -destination 'platform=iOS Simulator,name=iPhone 15 Pro'
224+
xcodebuild test -project Tests/NativeTests.xcodeproj -scheme NativeTests -destination 'platform=iOS Simulator,name=iPhone 15 Pro'
225225

226226
cd src/pythonnative/templates/android_template
227227
./gradlew :pythonnative:testDebugUnitTest
@@ -232,6 +232,13 @@ promise envelopes, so a wire-format change is caught on every side. Add
232232
cases there when you change a manager or module rather than relying on
233233
the Maestro E2E suite alone.
234234

235+
The iOS `NativeTests` scheme runs the Swift tests inside a minimal UIKit
236+
app. Use that scheme for native tests: composition, selection, and focus
237+
need an application event loop and keyboard session, which the standalone
238+
Swift package test runner doesn't provide. Add new Swift test files to
239+
both the test directory and the Xcode project's `PythonNativeKitTests`
240+
target.
241+
235242
## Testing async code
236243

237244
`render` and every event helper settle the framework's `asyncio` loop

‎examples/e2e-suite/app/screens/components/text_input.py‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
"""Demo screen for [`pn.TextInput`][pythonnative.TextInput].
22
3-
Maestro types into the single-line input and asserts the ``Echo:``
4-
line mirrors the typed value. The multiline variant is also rendered
5-
so flows can confirm both modes coexist on one screen.
3+
Maestro types into the name input, checks its echo and selection, then
4+
switches between single-line and multiline widgets without losing the value.
5+
A separate multiline note field shows a live character count.
66
"""
77

88
from __future__ import annotations
@@ -18,6 +18,8 @@ def TextInputDemo() -> pn.Element:
1818
name, set_name = pn.use_state("")
1919
notes, set_notes = pn.use_state("")
2020
focused, set_focused = pn.use_state(False)
21+
multiline, set_multiline = pn.use_state(False)
22+
selection, set_selection = pn.use_state({"start": 0, "end": 0})
2123

2224
field_style = pn.style(
2325
padding=10,
@@ -39,6 +41,8 @@ def TextInputDemo() -> pn.Element:
3941
value=name,
4042
placeholder="Type your name here",
4143
on_change=set_name,
44+
multiline=multiline,
45+
on_selection_change=set_selection,
4246
on_focus=lambda: set_focused(True),
4347
on_blur=lambda: set_focused(False),
4448
clear_button=True,
@@ -48,7 +52,11 @@ def TextInputDemo() -> pn.Element:
4852
style=field_style,
4953
),
5054
result_text("Echo", name or "(empty)"),
51-
hint("Maestro types here and asserts the echo + focus update."),
55+
result_text("Selection", f"{selection['start']}:{selection['end']}"),
56+
pn.Button(
57+
"Use single line" if multiline else "Use multiline name", on_press=lambda: set_multiline(not multiline)
58+
),
59+
hint("Switch the name field between modes while keeping its text."),
5260
),
5361
section(
5462
"Multiline",

0 commit comments

Comments
 (0)

Footer

© 2026 GitHub, Inc.

Back | FazBrowse Home | New Git URL