Native modules¶
Native modules are PythonNative's wrappers around device APIs that
aren't part of the view tree: the camera, GPS, file I/O, clipboard,
share sheet, deep links, permissions, connectivity, secure storage,
battery, haptics, biometrics, and the device-environment set React
Native core ships (Keyboard, Dimensions, PixelRatio, Device,
Localization, AccessibilityInfo). Each module is a Swift class in
PythonNativeKit and a Kotlin class in the pythonnative Gradle
module, registered by name; the Python class you call is a thin facade
that routes through the native bridge. Off
device (pn preview, pytest), the same facade resolves to a Python
implementation with safe defaults (in-memory buffers, "unknown"
states, no-op feedback), so app code stays single-source.
Every facade follows the same two rules, so you never need to look one up:
- Sync or async is decided by what the OS has to do. A method is a
plain function when the answer is already on the device and returns
on the calling thread:
Clipboard,Linking,Haptics/Vibration,Battery,NetInfo.fetch,SecureStore,AppState,FileSystem,Biometrics.is_available,Keyboard,Dimensions,PixelRatio,Device.info,Localization, andAccessibilityInfo. It is a coroutine when the OS has to prompt the user, drive hardware, or hand off to another process:Camera.take_photo/pick_from_gallery,Location.get_current,Share.share,Permissions.check/request,Biometrics.authenticate,Notifications.*,Alert.confirm/choose, and all ofAsyncStorage. Inside a component, drive coroutines with anasync defuse_effectcallback,use_resource,use_query, orpn.run_async(coro)from a sync handler. - Failures raise; "nothing happened" returns a value. A native
error (a rejected call, a missing module, a bad argument) is a
NativeModuleErrorand propagates like any other exception;FileSystemraises the standardOSErrorfamily. Outcomes that are not errors, such as the user cancelling a picker (None), dismissing the share sheet (False), or denying a permission ("blocked"), come back as values and are documented per method. No facade turns an exception into a default. Off device the desktop implementations never raise; they answer with the "nothing happened" value.
Several modules also ship reactive hooks:
use_app_state,
use_net_info,
use_locales,
use_screen_reader_enabled,
and use_reduce_motion. Return
values are frozen dataclasses (DeviceInfo, Locale, KeyboardEvent,
DimensionsEvent, AccessibilityEvent) rather than dicts.
Permissions: declare them once, request at runtime¶
Declare the capabilities your app uses in the [permissions] table of
pythonnative.toml. pn writes the matching Info.plist usage
strings, AndroidManifest.xml <uses-permission> entries, background
modes, and entitlements into the native projects for you (the full
catalog is in the configuration reference):
[permissions]
camera = "So you can take photos in MyApp."
location_when_in_use = "So MyApp can show nearby content."
photo_library = "So you can pick photos from your library."
notifications = true
The strings appear in the system permission dialog on iOS, so write
them as you would want a user to read them. The same keys are the
names you pass to Permissions at runtime, so
there is one vocabulary to learn.
Camera¶
Camera wraps photo
capture and gallery picking. Both coroutines resolve to a path to the
saved file (or None if the user cancelled).
import pythonnative as pn
@pn.component
def CameraScreen() -> pn.Node:
photo, set_photo = pn.use_state("")
async def take():
path = await pn.Camera.take_photo()
if path:
set_photo(path)
return pn.Column(
pn.Button("Take photo", on_press=lambda: pn.run_async(take())),
pn.Image(source=photo) if photo else pn.Spacer(),
)
Cold-start permissions
The first call shows the system permission prompt. If the user
denies it, subsequent calls resolve to None immediately; surface
a helpful message in your UI rather than calling in a loop.
On Android, take_photo writes the full-resolution capture to a
FileProvider URI handed to the camera app as EXTRA_OUTPUT, so the
returned path is the real photo, not the intent's thumbnail. The
library manifest declares the provider; allow_editing=True raises
NativeModuleError("unsupported") on Android.
Location¶
Location.get_current
reads a single GPS fix.
import pythonnative as pn
@pn.component
def WhereAmI() -> pn.Node:
q = pn.use_query(pn.Location.get_current, [])
if q.loading:
return pn.Text("Acquiring location...")
if q.data is None:
return pn.Text("Location unavailable")
lat, lon = q.data
return pn.Text(f"{lat:.4f}, {lon:.4f}")
use_query manages the
loading/data/error state for you and exposes a refetch() callable.
get_current requests when-in-use permission inline on both platforms
the first time it runs; a denial comes back as None.
For continuous updates, write a small native module that subscribes
to CLLocationManagerDelegate (iOS) or LocationManager.requestUpdates
(Android). Send updates through the module event channel; the Python callback
can call set_state on the application thread.
File system¶
FileSystem
answers the one question the standard library can't, "where may this
app write?", and then gets out of the way. app_dir() is the app's
sandboxed documents directory; path("notes/today.txt") resolves a
relative name inside it and returns a pathlib.Path you use like any
other. The read/write helpers are thin conveniences over that path and
raise the same OSError subclasses open does (FileNotFoundError,
PermissionError, ...). Everything is synchronous, exactly like the
standard library it wraps; for large files, offload to a worker thread:
import asyncio
import pythonnative as pn
# Sync: fine for small files (preferences, JSON state, etc.)
pn.FileSystem.write_text("notes.txt", "hello")
text = pn.FileSystem.read_text("notes.txt")
# Work with the Path directly when you need more than the helpers.
for entry in sorted(pn.FileSystem.path("notes").iterdir()):
print(entry.name)
# Async: explicitly offload to a worker thread for big payloads.
text = await asyncio.to_thread(pn.FileSystem.read_text, "big.txt")
Need just a key-value store?
Prefer AsyncStorage over
serialising JSON into a file by hand; it's the native
NSUserDefaults / SharedPreferences API and is async-first.
Notifications¶
Notifications
schedules local notifications and cancels previously scheduled ones.
import pythonnative as pn
async def setup_reminder():
granted = await pn.Notifications.request_permission()
if not granted:
return
await pn.Notifications.schedule(
title="Stretch break",
body="Stand up!",
delay_seconds=1800,
identifier="reminder",
)
request_permission() is required on iOS and on Android 13+. On
older Android the call returns True without prompting.
For server-sent (remote) pushes, declare the remote_notifications
capability in pythonnative.toml, then register with APNs:
Your server passes the token to APNs to address this install. The
simulator has no APNs connection, so test on a real device. Android
remote push requires a provider extension (Firebase Cloud Messaging),
which the built-in module doesn't wire up; get_device_token() raises
NativeModuleError("unsupported") there, and
Platform.supports("Notifications", "get_device_token") reports it.
Clipboard¶
Clipboard reads and writes the system
pasteboard synchronously.
import pythonnative as pn
pn.Clipboard.set_string("Copied!")
text = pn.Clipboard.get_string()
if pn.Clipboard.has_string():
...
Share¶
Share.share presents the system share sheet and
resolves to True once the user completes a share.
Linking¶
Linking opens URLs, deep links, and the app's
Settings page, and delivers inbound deep links to your app.
if pn.Linking.can_open_url("tel:+15551234567"):
pn.Linking.open_url("tel:+15551234567")
launch_url = pn.Linking.get_initial_url() # deep link the app opened with
pn.Linking.open_settings() # this app's entry in Settings
To receive deep links, declare your schemes in pythonnative.toml:
Opening myapp://orders/42 now launches (or foregrounds) your app on
both platforms. The URL that cold-started the app is returned by
get_initial_url(); URLs that arrive while the app is running reach
subscribers:
Permissions (runtime)¶
Permissions normalizes the iOS/Android
permission models. Both check and request are coroutines (check
reads asynchronous platform settings APIs; request prompts). Names
are the [permissions] keys from pythonnative.toml
that have a runtime prompt: "camera", "microphone",
"photo_library", "location_when_in_use", "contacts",
"notifications" (any other name raises ValueError). Statuses:
"granted", "denied", "blocked", "undetermined".
if await pn.Permissions.check("camera") != "granted":
status = await pn.Permissions.request("camera")
if status == "blocked":
pn.Linking.open_settings() # user must enable it in Settings
Declaring the capability in [permissions] is what puts the usage
string in the manifest; request is what shows the prompt.
App state¶
AppState reports the foreground/background
lifecycle phase ("active", "inactive", "background"). Use the
use_app_state hook in components:
Outside the tree, subscribe imperatively:
Network connectivity¶
NetInfo reports connectivity. fetch() returns
{"is_connected": bool, "type": str, "is_internet_reachable": bool};
the use_net_info hook re-renders on change.
@pn.component
def Banner() -> pn.Node:
net = pn.use_net_info()
if not net["is_connected"]:
return pn.Text("You are offline", style=pn.style(color="#B91C1C"))
return pn.Spacer()
Secure storage¶
SecureStore persists secrets in the iOS
Keychain / Android EncryptedSharedPreferences. Use it for tokens,
not AsyncStorage, which is
unencrypted.
pn.SecureStore.set_item("auth_token", token) # raises NativeModuleError(code="write_failed")
token = pn.SecureStore.get_item("auth_token") # None when absent
pn.SecureStore.delete_item("auth_token") # raises NativeModuleError(code="delete_failed")
Both writers raise when the Keychain or EncryptedSharedPreferences
reports failure (for example a Keychain entitlement problem) instead of
returning False; deleting a key that isn't stored is not an error.
Battery¶
Battery exposes the charge level (0.0–1.0,
or -1.0 if unknown) and state, plus a change listener.
level = pn.Battery.get_level()
state = pn.Battery.get_state() # "charging" | "full" | "unplugged" | "unknown"
Haptics & vibration¶
Haptics plays semantic feedback;
Vibration is a blunt duration-based buzz.
pn.Haptics.impact("medium") # light | medium | heavy | soft | rigid
pn.Haptics.notification("success") # success | warning | error
pn.Haptics.selection()
pn.Vibration.vibrate(400) # milliseconds
Biometrics¶
Biometrics gates an action behind Face ID /
Touch ID / fingerprint.
async def unlock():
if pn.Biometrics.is_available() and await pn.Biometrics.authenticate("Unlock"):
show_secrets()
Keyboard, dimensions, and pixel ratio¶
Keyboard reads and dismisses the on-screen
keyboard. Native pushes a change event on every show, hide, and
resize (iOS keyboardWillChangeFrame, Android
WindowInsetsCompat.Type.ime()), which also feeds
use_keyboard_height and
KeyboardAvoidingView on both
platforms:
pn.Pressable(pn.Text("Done"), on_press=pn.Keyboard.dismiss)
if pn.Keyboard.is_visible():
...
unsubscribe = pn.Keyboard.add_listener(lambda e: print(e.height, e.visible, e.duration_ms))
Dimensions and
PixelRatio read the window and screen
size and the display density. Neither talks to a native module: the
screen host publishes width, height, scale, font_scale,
screen_width, and screen_height on every layout pass, so both work
anywhere, including outside components. Dimensions.add_listener
delivers a DimensionsEvent with both
window and screen, and fires only when a size or density changed:
window = pn.Dimensions.get("window") # a WindowDimensions
screen = pn.Dimensions.get("screen")
columns = 3 if window.width >= 600 else 2
pn.PixelRatio.get() # 2.0 on a 2x display
pn.PixelRatio.get_font_scale() # the user's text-size multiplier
pn.PixelRatio.get_pixel_size_for_layout_size(1) # whole pixels for 1 layout unit
hairline = pn.PixelRatio.round_to_nearest_pixel(0.5)
Inside a component prefer
use_window_dimensions, which
re-renders on change and now carries scale and font_scale too.
Device¶
Device.info returns one frozen
DeviceInfo record: platform,
os_version, model, manufacturer, is_simulator, is_tablet,
app_name, app_version, build_number, bundle_id, scale,
font_scale, and locale. Every key is filled on both platforms;
the browser preview answers from navigator, and off device
platform is "test":
info = pn.Device.info()
if info.is_tablet:
columns = 3
print(f"{info.app_name} {info.app_version} ({info.build_number}) on {info.model}")
The writable data directory isn't part of DeviceInfo; use
FileSystem.app_dir().
Localization¶
Localization reads the user's preferred
languages and time zone (Locale.preferredLanguages on iOS,
LocaleList.getDefault() on Android, navigator.languages in the
browser). get_locales() returns Locale
records (language_tag, language_code, region_code, is_rtl),
most preferred first; get_timezone() returns the IANA name; and
is_rtl() answers for the preferred locale. The
use_locales hook re-renders when the
user changes either setting:
locales = pn.Localization.get_locales()
tz = pn.Localization.get_timezone() # "America/New_York"
direction = "rtl" if pn.Localization.is_rtl() else "ltr"
Accessibility info¶
AccessibilityInfo reads whether a
screen reader or Reduce Motion is on, announces messages, and moves
accessibility focus; the
use_screen_reader_enabled
and use_reduce_motion hooks are the
reactive form. See
Platform & Accessibility.
Writing your own native module¶
A native module has three parts: a Swift class, a Kotlin class, and a
Python facade. The two native classes are registered under one name by
a plugin entry (the same pn_plugin.json layout used for
custom components); the facade calls
them by that name and never imports platform code.
Native side¶
Each platform's base type is one method that settles a promise:
// native/ios/CompassModule.swift
import CoreLocation
import PythonNativeKit
public final class CompassModule: NSObject, PNNativeModule, CLLocationManagerDelegate {
public static let name = "Compass"
private let manager = CLLocationManager()
private var pending: PNPromise?
public override required init() {
super.init()
manager.delegate = self
}
public func call(_ method: String, args: [String: Any], promise: PNPromise) {
switch method {
case "is_available":
promise.resolve(CLLocationManager.headingAvailable())
case "heading":
pending = promise // settled later from the delegate
manager.startUpdatingHeading()
default:
promise.reject("Compass has no method '\(method)'", code: "unknown_method")
}
}
public func locationManager(_ manager: CLLocationManager, didUpdateHeading heading: CLHeading) {
manager.stopUpdatingHeading()
pending?.resolve(heading.trueHeading)
pending = nil
}
}
// native/android/com/example/compass/CompassModule.kt
package com.example.compass
import com.pythonnative.runtime.modules.NativeModule
import com.pythonnative.runtime.modules.Promise
import org.json.JSONObject
class CompassModule : NativeModule {
override val name = "Compass"
override fun call(method: String, args: JSONObject, promise: Promise) {
when (method) {
"is_available" -> promise.resolve(true)
"heading" -> readHeadingOnce { degrees -> promise.resolve(degrees) }
else -> promise.rejectUnknownMethod(method)
}
}
}
Settling the promise before call returns answers Python inline (a
plain synchronous return). Settling it later, from any thread, delivers
the result through the bridge's event channel to the Python application thread. Push
unsolicited events with PNModuleEvents.emit(module:event:payload:) /
ModuleEvents.emit(module, event, payload).
Register both in the plugin entry next to any component managers:
Python facade¶
# compass/__init__.py
from typing import Optional
from pythonnative.native_modules.registry import native_module
_compass = native_module("Compass")
class Compass:
@staticmethod
def is_available() -> bool:
return bool(_compass.call("is_available"))
@staticmethod
async def heading() -> float:
return float(await _compass.call_async("heading"))
@staticmethod
def add_listener(callback) -> callable:
return _compass.add_listener("change", callback)
call is for methods that answer inline; it raises
NativeModuleError
when native rejects. call_async awaits methods that settle later.
add_listener subscribes to module events and returns an unsubscribe
callable.
Follow the two rules the built-in facades follow: pick sync or async by
what the OS does (a heading read is a coroutine here because the sensor
has to spin up), and let NativeModuleError propagate rather than
catching it and returning a placeholder. A caller who wants a fallback
can write the try themselves; a caller who wanted to know it failed
can't undo a swallowed exception.
Desktop and test implementation¶
native_module("Compass") returns a
BridgeModule on
device. Off device it looks for a Python implementation registered
under the same name, so register one for pn preview and unit tests:
from pythonnative.native_modules.registry import register_python_module
class FallbackCompass:
def is_available(self) -> bool:
return False
def heading(self) -> float:
return 0.0
register_python_module("Compass", FallbackCompass())
Methods are looked up by name and called with the same keyword
arguments the facade passed; a coroutine function is awaited by
call_async. Packages register their desktop implementations through
the pythonnative.modules entry point group so they are found without
an explicit import:
Tests can push module events directly with
emit to exercise
listeners without a device.
Next steps¶
- Reference: Native modules API.
- Async hooks and data fetching: Async + data.
- See how device APIs interact with focus: Lifecycle.
- Wrap a custom widget instead of an API: Custom native components.
- Wire protocol and plugin layout: The native bridge.
Implementing an extension¶
Declare a Python protocol and generate its Python, Swift, and Kotlin interfaces. The generated adapters validate arguments and results, preserve nested records, and connect coroutine cancellation to the native operation. See generated native contracts and the separately packaged Inbox extension.