Generated native contracts¶
Python declarations define the native interface. The same compiler generates
Swift and Kotlin props, records, event emitters, and service adapters for
built-ins and extensions. The separately packaged examples/inbox-extension
contains a complete implementation; examples/inbox consumes its wheel.
Declare Python values¶
Use ordinary dataclasses, enums, TypedDict, Literal, optional values, and
nested collections. Record defaults survive decoding, and results and events
arrive as the original Python record classes. Unsupported annotations fail
during declaration. Explicit Any means a portable JSON value, not an arbitrary
Python object.
from dataclasses import dataclass
from typing import Annotated, Callable, Protocol
from pythonnative.sdk import define_component
from pythonnative.sdk.schema import ModuleSchema, NativeField, register_schema
@dataclass(frozen=True)
class Record:
identifier: str
title: str
labels: tuple[str, ...] = ()
@dataclass(frozen=True)
class BadgeProps:
count: Annotated[int, NativeField(invalidates_layout=True)] = 0
on_press: Callable[[int], None] | None = None
class ToolsProtocol(Protocol):
async def prepare(self, records: list[Record], *, limit: int = 10) -> list[Record]: ...
Badge = define_component("Badge", BadgeProps)
register_schema(ModuleSchema.from_protocol("Tools", ToolsProtocol, events={"prepared": list[Record]}))
Generate from an importable declaration module:
Generation writes the schema, NativeValues, NativeProps, NativeModules,
Python facades, browser metadata, and a property reference. Generated Python
methods preserve defaults and keyword arguments. Generated on_prepared
subscriptions accept typed callbacks and return an unsubscribe function.
Native mappings require string keys. Integers must fit JavaScript's exact
integer range, numbers must be finite, and booleans don't count as numbers.
Record field names must be Python identifiers; use a mapping for other JSON
keys. Records reject missing required fields and unknown fields. Recursive type
cycles aren't supported. Values marked NativeField(python_only=True) remain
in Python; other unserializable values raise an error.
Custom components accept style= through the factory define_component
returns, even when the props dataclass doesn't declare a style field. Shared
style fields are included in the generated native contract. Declare
platforms=("ios",) on define_component for an iOS-only renderer; custom
components default to iOS and Android support. A browser placeholder doesn't
make Platform.supports return true.
Implement the native side¶
Use PNTypedComponentManager<BadgeProps> on iOS and
TypedComponentManager<BadgeProps> on Android. The applyTyped method receives
checked props. has_count distinguishes a changed property from one absent in
a partial update. Common view props live in PNViewProps; they aren't repeated
in every generated component. Use PNComponentEvents.Badge.on_press to emit
the declared payload.
For iOS controls, attach PNActionTarget in createView after calling
super.createView. The view's state then retains the target for its lifetime.
makeView runs before that state exists.
Nullable record fields encode explicit null. A nonrequired nullable TypedDict
field uses PNPresence<T?> in Swift and Kotlin so absence stays distinct from
a present null; use .present(nil) in Swift or PNPresence.Present(null) in
Kotlin for the latter.
Implement ToolsImplementation, then register a ToolsModuleAdapter in the
plugin. Swift's asynchronous implementation receives a completion callback;
Kotlin receives a Result callback. Both return an optional cancellation
function. The generated adapter registers it with the native promise. Cancelling
the Python task releases the native work, removes its waiter, and discards late
settlements. Emit module events through the generated ToolsEvents helper.
The Inbox extension demonstrates nested records, tuple defaults, a cancellable delayed operation, typed module and component events, packaged resources, and a real SwiftPM and Maven dependency. Its generation script filters the manifest to its own components and services. Build discovery merges those contracts with the built-ins without importing target binaries on the host.
Package sources, resources, and dependencies¶
Include pn_plugin.json and its referenced files in the wheel:
{
"name": "BadgeExtension",
"contracts": "schema.json",
"ios": {
"entry": "BadgePlugin",
"resources": ["ios/resources/*.json"],
"dependencies": [
{
"url": "https://github.com/apple/swift-collections.git",
"version": "1.1.4",
"products": ["DequeModule"]
}
]
},
"android": {
"entry": "com.example.badge.BadgePlugin",
"resources": ["android/res/**/*.xml"],
"assets": ["android/assets/*.json"],
"dependencies": ["androidx.collection:collection-ktx:1.4.5"]
}
}
Swift dependencies require HTTPS URLs, exact versions, and product names.
Maven coordinates require an exact version; ranges, dynamic versions, and
snapshots are rejected. Conflicting declarations fail preparation. These pins
cover direct native dependencies; Python's pn.lock records Python wheels.
Android resources retain their res structure. Assets are namespaced by plugin
name, such as InboxExtension/inbox_labels.json. Resource paths must stay
inside the plugin.
Swift plugin resources retain their manifest-relative paths inside
Bundle.module, under PluginResources/<plugin name>/. This keeps two plugins
with the same resource filename separate. For example, the Inbox extension
loads PluginResources/InboxExtension/ios/resources/inbox_labels.json.
Android assets similarly live under the plugin name in the app assets.
Removing a plugin removes its staged files and dependency declarations.
Resolved wheel hashes invalidate cached metadata even when the wheel's filename
hasn't changed.
Missing values, nulls, and resets¶
The protocol separates setting a value from removing a property:
import pythonnative as pn
# Explicit null is a value when the declared type permits it.
nullable = pn.Element("Picker", {"value": None})
# Omission or UNSET removes a previously supplied property.
reset = pn.Element("Text", {"text": "Default size", "font_size": pn.UNSET})
Generated component facades use UNSET for omitted arguments. Built-in
convenience factories continue to omit optional keyword arguments whose value
is None; their omission still produces an explicit removal in the next
transaction. Native state retains the distinction between a null and a missing
property. Physical setters restore a declared default or the platform default.
A reset doesn't recreate the logical tag. Only fields marked recreate, such
as TextInput.multiline and ProgressBar.indeterminate, replace the physical
widget, retaining logical ownership and supported focus/selection state.
NativeField also declares layout invalidation, animation, and platform support.
Platform.supports("Image", "source") or
Platform.supports("Notifications", "get_device_token") inspects the compiled
contract. This checks declared support; a permission grant or attached piece of
hardware is a separate runtime condition. Android remote push needs a provider
extension and reports an unsupported operation instead of a successful empty
token.
Permissions.check() is a coroutine, matching asynchronous platform
settings APIs: use await pn.Permissions.check("camera"). Android camera
editing (allow_editing=True) raises an unsupported-operation error; use
a provider plugin if your application needs an Android image editor.
Notifications.get_device_token() raises
NativeModuleError("unsupported") on Android, where remote push needs a
provider extension.
Only clients that speak the current protocol (5) are supported. Rebuild the app after changing declarations, native sources, or native dependencies. Startup checks the exact contract fingerprint and Yoga version before mounting. Fast Refresh handles Python application changes within that compiled interface.
Verify contracts¶
scripts/generate-native-contracts.py also writes the built-in contracts to
src/pythonnative/sdk/_builtin_contracts.json, which import pythonnative
loads instead of evaluating the factories' annotations. Regenerate after
changing a built-in factory's signature; tests/test_codegen.py fails until
you do.
uv run python scripts/generate-native-contracts.py
uv run pytest tests/test_codegen.py tests/test_native_values.py
uv run python scripts/run-browser-tests.py
Native tests exercise generated adapters alongside actual UIKit and Android controls. The Inbox Maestro flows build the extension wheel, drive native navigation and input, verify native cancellation without a late event, save data, restart the process, and verify persistence.