Libraries and crates
The tour continues: error handling, the standard library, your own Rust crates, packages written in the dialect, and the CPython escapes.
Error handling
The order to reach for things is fixed.
- Use
*_or. A read that folds failure into a default; when the reason for the failure does not matter, this is all you need.fs.read_text_or(p, ""),http.get_text_or(url, ""),sqlite.query_int_or(p, sql, 0). - Use try/except. The form for when the reason matters, written exactly as in Python:
multiple statements in the body, per-exception except clauses, tuples (
except (ValueError, KeyError) as e:),else,finally. Every standard-library call that can fail — a write, a query, a read by JSON path, a clock format — raises here, so atryaround any of them receives the reason. Exceptions raised by@pyescape functions are caught here too, ande's message is exactly what Python produces. - Do nothing. An uncaught failure aborts its statement and the app lives on. It does not crash.
The standard library
It comes in two halves, told apart by where the name comes from. Neither half puts Python in the shipped binary. How far each module reaches into Python's, function by function, is on the coverage page, which is generated rather than written.
Python's own modules, written the way Python writes them: import math, import random, import statistics, import json, import datetime, import time, import re, import string, import textwrap, import bisect, import heapq, import collections, import itertools.
During development the app imports CPython's module and CPython runs it.
The shipped binary calls a twin written against CPython's semantics.
What it answers, how it fails and the wording of the error are all the same.
math.sqrt(-1) raises where Python raises; statistics.mean([0.1, 0.2, 0.3]) is 0.2, the exact answer, not the 0.20000000000000004 a plain sum gives; random.seed(1) starts the same Mersenne Twister sequence in both runs; json.dumps writes what CPython writes, down to the ", " between the parts and the \uXXXX escapes; a date adds a timedelta, subtracts another date and formats itself the way Python's does; and a regular expression is compiled by CPython itself while the app translates, so the shipped binary runs the very array Python would have run — the backtracking, the groups and the flags are CPython's, not a second dialect of them.
import json, math, random, re, statistics
from datetime import date, timedelta
def measure():
hyp.set(math.sqrt(3.0 * 3.0 + 4.0 * 4.0)) # 5.0
spread.set(statistics.stdev([1.5, 2.5, 4.75]))
random.seed(42)
roll.set(random.randint(1, 6))
doc.set(json.dumps({"name": "momo", "tags": ["a", "b"]}))
due.set(date(2026, 1, 1) + timedelta(weeks=6)) # 2026-02-12, a Thursday
mail.set(re.findall(r"\w+@[\w.]+", line())[0]) # a Match has no shape here
def view():
text(f"circumference: {math.tau * r():.3f}") # pure, so a view may ask
math and statistics are pure, so a view can call them; random moves a generator on, so it belongs in a handler like the rest.
An unseeded generator is as unrepeatable here as it is in Python — seed it and the gate can hold the two runs to one sequence.
Yokan's own modules cover files, a database, the network, the clipboard, notifications, sound and the keyboard: from yokan import fs, sqlite, http, jsondoc, clock, strings, clipboard, notify, audio, keys.
Python can do most of this too, through pathlib, sqlite3 and urllib.
What differs is how many implementations there are.
math and re are CPython's modules during development and Rust twins after shipping.
These call the same Rust function in both runs, so the two runs cannot answer differently.
None of them uses a Python module's name, because a Python name is a promise that CPython decides the answers.
Reading a JSON document by a dotted path is jsondoc, not json, and the machine's own time zone is clock, not time.
Call them from handlers (views stay pure).
- fs:
read_text/write_text/append_text/exists/read_text_or/list_dir(the names in a directory, sorted) /make_dir/remove/app_dir(name)(the directory this app may keep its own files in, created if it is not there yet) /size(a file's length in bytes) /modified_ms(when it was last written, in the millisecondsclock.format_msreads) /is_dir/read_text_from(path, offset)(the text from a byte offset to the end: the rest of a file a read already reached the end of once, which is how a growing log is followed without reading it again from the top; at or past the end it answers"") /read_bytes/write_bytes(a file whole, asbytes, for what the text pair cannot carry: an image, an archive, a digest's input) — plus the platform's own panels,open_dialog(title)andsave_dialog(name), which answer with a path or""when the person cancelled. A dialog waits for a person, so it runs insidetask(...); a verification script answers it withfile:<path>. - sqlite:
exec/query_text/query_int/query_rows/query_int_or/query_text_or/query_rows_or(SQLite bundled.query_textanswers column 0 of each row,query_rowsevery column. Wrap aggregates in COALESCE and pin the order with ORDER BY) - http:
get_text(url)/get_text_or/get_text_with(url, headers)/get_bytes(url)/post_text(url, body)/post_text_or/status(url)(synchronous;get_texttakes a deadline in milliseconds as a second argument,post_texta content type as a third;get_bytesanswers the response asbytes, for what is not text) - jsondoc:
get_text/get_int/get_float/get_bool/length/has— reads into a JSON document by a dotted path like"items.0.title", which Python'sjsonhas no verb for.get_texts(src, paths, default)is several of those reads for one parse: a list of paths in, a list of texts out (a number or a bool as JSON writes it, a list or a map as its JSON), anddefaultwhere a path finds nothing — the read that takes a line of a log apart, since a record does not always carry every field. Writing is Python'sjson.dumps. - clock:
format_ms(ms, "%Y-%m-%d")(UTC. In verification scripts, pass a fixed ms),format_local_ms(ms, fmt)(the machine's own zone, from the same zone database in both runs),local_offset_minutes(ms)— the machine's zone, which Python'stimereaches only through a struct. Reading the clock is Python'stime, and calendar work is Python'sdatetime. - strings:
to_int(s, default)/to_float(s, default)(numeric parsing where broken input becomes the default) - clipboard:
set_text(s)/get_text()— the system clipboard. A window exchanges it with every other application; a headless run keeps it to itself, so a copy and a paste are checked like any other interaction - notify:
send(title, body)— an OS notification, delivered through Notification Center when the app runs as an.appbundle (--app); a bare dev run and headless runs drop it quietly - audio:
play(path, volume=1.0)/stop()— a WAV starts and the call returns; several play together, andvolumeis a level between 0 and 1 (loud is the one mistake a sound cannot take back, so something that plays several times a second should ask for less). A SCRIPTED run is silent, so a gate never needs a machine with speakers and a sound never reaches a dump; a machine with no audio device, or a file that cannot be read, plays nothing rather than failing the app. Only an app that imports this links a sound device, which is about 1.3 MB of binary - keys:
down(k)/pressed(k)/released(k)— the keyboard as a device, read from a timer's tick; see The window for the chords that come to a handler instead
How far the Python half reaches, module by module:
- math — everything but six members, each refused with its reason:
prodandsumprodanswer an int or a float depending on the list, andgamma,lgamma,erfanderfcare computed by CPython itself rather than by the platform. - random —
seed,random,randint,randrange,getrandbits,uniform,gauss,choice,sample. - statistics —
mean,fmean,median,mode,variance,pvariance,stdev,pstdev, overlist[float]. A list of ints is refused: CPython answers an int formean([1, 2, 3])and a float formean([1, 2, 4]), so there is no one type it could have. - json —
dumps, with CPython's defaults and no keyword arguments. - time —
time,time_ns,monotonic,monotonic_ns,perf_counter,perf_counter_ns,sleep. - re —
findall,sub,split,escape, andre.search(p, s) is not None(withmatchandfullmatch) as the test. The pattern is a literal, because it is compiled while the app translates. - datetime —
date,datetimeandtimedelta, naive unless a zone is given (seezoneinfobelow): construction,today/now/fromisoformat/fromtimestamp/fromordinal/combine, the parts (.year,.hour,.days, …),isoformat,strftime,weekday,toordinal,timestamp,total_seconds, arithmetic and comparison. A value renders in a hole the waystr()renders it. - collections —
Counter, over a list of str: the dict of counts, keyed in first-seen order, with.most_common()and.total()beside everything a dict answers. A Counter held in aStatereads back as the dict it is, so take the counts out before storing it. - itertools —
chain,pairwise,accumulate,combinations,permutations,product. Each answers an iterator in Python, so each is what aforwalks here. - string / textwrap / bisect / heapq — the nine constants;
dedentandindent;bisect_leftandbisect_right;nsmallestandnlargest. - hashlib —
sha256,sha1andmd5, each read with.hexdigest(). Python spells a digest as two calls over a hash object; the dialect reads the pair, because an object written to in steps has no compiled shape. - base64 —
b64encodeandb64decode. Both answerbytes, as Python's do. - zoneinfo —
ZoneInfo(key), and what a zone decides about an awaredatetime:now(tz),fromtimestamp(ts, tz),datetime(..., tzinfo=tz),astimezone,utcoffset,dst,tzname,isoformatwith the offset,strftime's%zand%Z, comparison and subtraction between two of them, and+ timedelta. Both runs read the machine's own zone files, so an offset is not something they can disagree about.
The zone rides in the type rather than in the value, which is why a key is written where it stands: the compiled side reads it while it translates. The value itself is the same integer a naive datetime is — the wall clock in its own zone — so .year, .hour and the rest read it unchanged, and a State or a field, which has only its annotation to go by, holds the naive value it always held.
TOKYO = ZoneInfo("Asia/Tokyo")
NEW_YORK = ZoneInfo("America/New_York")
here = datetime(2026, 7, 14, 9, 30, tzinfo=TOKYO)
there = here.astimezone(NEW_YORK) # 2026-07-13 20:30:00-04:00
text(f"{there.strftime('%H:%M %Z')}") # 20:30 EDT
utcoffset() and tzname() are typed | None in typeshed, because a naive value has neither. In a hole that reads fine; where the number itself is wanted, strftime("%z") says the same thing without the narrowing.
bytes is a type of its own, and it behaves as Python's does: the literal is b"..." with its escapes, s.encode() makes bytes from text and b.decode() takes them back, len(b) counts them, b[i] answers a number, b[a:b] answers bytes, + joins two, and .hex() / bytes.fromhex(s) cross to and from text. A State[bytes] and a bytes field hold one, starting from b"".
raw = phrase().encode() # b'yokan'
stamp.set(hashlib.sha256(raw).hexdigest()) # 61aca55e4c72…
packed.set(f"{base64.b64encode(raw)}") # b'eW9rYW4='
fs.write_bytes(path, PNG + raw) # a literal joined to a value
c = Counter(votes()) # {"ivy": 3, "momo": 2, "ada": 1}
for name, n in c.most_common(2): # by count, ties in first-seen order
board.set(board() + f"{name}:{n} ")
for a, b in itertools.pairwise(readings()):
steps.set(steps() + [b - a])
Every sqlite call takes one more argument, a list of values to bind:
sqlite.exec(DB, "INSERT INTO expenses VALUES (?, ?, ?)", [item, str(yen), cat])
sqlite.query_int_or(DB, "SELECT COALESCE(SUM(amount),0) FROM expenses WHERE cat=?", 0, ["food"])
Write ? where the value goes and pass it beside the statement.
An apostrophe in item is then an apostrophe, and text a user typed can never become SQL.
Values bind as text and the column's affinity converts, so an INTEGER column stores the number.
A whole row comes back as a list[str], so a result is a list[list[str]]:
@store
class Ledger:
raw: list[list[str]] = []
rows: list[str] = []
def load(self) -> None:
self.raw = sqlite.query_rows_or(DB, "SELECT name, amount, cat FROM expenses ORDER BY rowid")
self.rows = []
for r in self.raw:
self.rows = self.rows + [f"{r[0]} ¥{r[1]} ({r[2]})"]
The line is written in Python rather than assembled in SQL.
The discipline underneath all of these is determinism. Pass fixed times, seed the RNG — and verification scripts replay the same result every time.
You can also add a Rust crate of your own. That is the next section.
Calling a Rust crate
Declare a Rust crate and call it from the app — a crates.io version or a local path, either way. Adding one is a single command.
$ yokan add app.py deunicode 1 # from crates.io
$ yokan add app.py hexfmt --path native/hexfmt # a local crate
The declaration has two homes, matching how the app is shaped:
the PEP 723 block's [tool.yokan.crates] for script-style apps,
and the same table in pyproject.toml for project-style apps
(yokan add finds and writes whichever applies).
# /// script
# requires-python = ">=3.14"
#
# [tool.yokan.crates]
# hexfmt = { path = "native/hexfmt" }
# ///
from yokan import crates
# in a handler
self.encoded = crates.hexfmt.encode("yokan")
self.total = crates.hexfmt.add(40, 2)
self.mean = crates.hexfmt.avg(self.samples)
The crate side is ordinary Rust — no pyo3, no yokan types.
pub fn encode(s: &str) -> String { … }
pub fn add(a: i64, b: i64) -> i64 { … }
pub fn avg(xs: Vec<f64>) -> f64 { … }
yokan gate and yokan build set the crate up for both runs.
To run with plain uv run before ever gating, run
yokan sync app.py once.
The feature has the native build's prerequisites (the repository
checkout and Rust).
Functions are called by their documented snake_case names.
What crosses: Int, Float, Bool, String, Lists of those, Optionals
(None included), str-keyed dicts (HashMap<String, …>), structs (nested
ones included) and enums, and Result-returning functions — compound
returns like Result<Vec<…>> included.
A dict returned from a crate arrives ordered by key, the same
order in both runs.
A Result is received with try/except, and f"{e}" renders the same
message in both runs.
Structs and enums cross when the app declares their twins — the
same shapes under the same names, nothing more.
For a nested struct, declare the inner twin first and name it in
the outer one's field:
@value
class Span: # twin of the crate's struct Span
lo: int
hi: int
class Grade(Enum): # twin of the crate's enum Grade
Fine = 1
Odd = 2
moved = crates.hexfmt.shift(Span(3, 8), 10)
self.verdict = crates.hexfmt.describe(crates.hexfmt.judge(7))
Structs whose Rust fields carry exact widths (u32 and friends)
cross too — reads widen, writes cast back to the width — and the
same rules apply to nested fields.
Anything that cannot cross is refused, and the error says what and why.
The demos: demo/rustcrate.py (a path crate and a crates.io crate;
Optionals, Result, a struct, an enum and a dict) and demo/proj/
(the pyproject spelling).
Packages
An app can be several files: a module beside the entry is imported by name (from widgets import badge) and compiled into the same program.
A package goes one step further — it is an ordinary installed Python package, and it compiles into the app that imports it.
A package says it is written in the dialect with a py.yokan file beside its __init__.py, the way a typed package says so with py.typed:
# pyproject.toml — the marker has to ship with the package
[tool.setuptools.package-data]
yokanui = ["py.yokan"]
An app then imports it as it imports anything:
from yokanui import badge, panel
def view():
with column(spacing=10, padding=14):
panel("from a package", "compiled in")
badge("yokanui")
Inside the package, relative imports work (from .badges import badge), and __init__.py re-exports what the package offers.
There is no library at run time and nothing to ship beside the app: the package's modules are read the way the app's own files are, and the binary carries them.
Names cannot collide. A name from a package is emitted under a name derived from its module, so the package's badge and an app's own badge_of coexist and neither author has to know about the other.
That is invisible from Python — the app calls badge(...) — and shows only in the .pix, as YokanuiBadgesBadge.
A package may carry Rust. Its own [tool.yokan.crates] goes in a yokan.toml beside the marker, because a wheel does not carry a pyproject.toml:
That merges into the app's declarations, and the app never mentions the crate. Two sides that declare the same crate at different versions are refused by name — one crate is one version in a program.
An installed package with no marker is refused where its names are used, and the message names the marker: a package that is not dialect code is @py's to run.
A package cannot carry @py itself — an escape carries Python into the build, and what that Python needs is declared by the app whose dependencies get installed.
The demo is demo/pkg/ (the package) and demo/pkgapp.py (the app that imports it).
CPython escapes
When you need Python beyond everything above, mark the function with @py (from yokan import py).
That function stays real Python.
During development it runs as-is; after shipping it runs on the bundled or ambient CPython (to be self-contained, use --bundle / --onefile, below).
@py
def slug(t: str) -> str:
import re # write imports inside the escape
return re.sub(r"[^a-z0-9]+", "-", t.lower()).strip("-")
Annotate every parameter and the return: int, float, str, bool, list[...] and dict[str, ...] of those, a value class, and T | None.
Compiled extensions like numpy work inside escapes.
Heavy work, timers and keys
Never block a handler (the window freezes).
task does the work on a worker thread and runs the continuation on the UI thread when it finishes.
on_error= runs during development only; a failing standard-library call is caught with try / except around the call itself.
work must not build UI elements; it just returns a value, and task is the last statement of its handler (in Python the statements after it run before the work finishes).
It must not read app state either — it runs off the UI thread, where state cannot be reached: read what it needs above the task, hand the value in, and write what comes back in on_done.
Headless runs wait for task completion before taking the next step, so flows containing tasks are testable.
Both runs do the same thing with it: during development the work is a Python thread, and the compiled app awaits the calls inside it, which is what puts them on the engine's pool.
Pure computation stays where it is written — what moves off the UI thread is the fs, sqlite, http or time.sleep call, and a @py escape, which is how a minute of Python keeps the window drawing.
The work is not silent while it runs.
report(fraction, note) says where it has got to — from the work itself, or from inside a @py escape it called — and on_progress hears it on the UI thread like any other handler.
def moved(fraction: float, note: str):
done.set(fraction)
step.set(note)
def start():
task(count_primes, on_done=counted, on_progress=moved)
Inside an escape, report is imported the way anything else is: from yokan import report.
Every report is heard, and the last one lands before on_done does; called outside a task it does nothing.
What a report says is the work's business and may depend on the machine, but that it arrives is not — so counting them is something the gate can compare.
every(seconds, cb) is a timer, declared at module level (or under the __main__ guard) and started with the app.
It is a declaration, not a call you make later: both runs start it when the app starts, and both fire it off the same clock — a frame in a window, an advance:<ms> in a headless script, so a minute of ticks is gate-checkable.
Keys are declared the same way.
shortcut(chord, handler) binds a chord, and on_key(handler) sees every key as the chord it was.
The chord is spelled the way the platform spells it — cmd+s, shift-tab, ctrl+alt+k — and - reads the same as +.
cmd is the key an app's own shortcuts hang off, and only macOS has one of its own: on Windows and Linux that key is Ctrl, so cmd+s and ctrl+s name one chord there and a script still presses it with key:cmd+s everywhere.
While a text field has the caret, plain keys go on typing into it and only chords carrying cmd or ctrl reach the app.
A headless script presses one with key:cmd+s, so a shortcut is a checked interaction like a click.
A chord is a message; a key that is held is something else, and keys answers that.
from yokan import keys
def tick():
if keys.down("left"):
Game.steer(-1)
if keys.pressed("space"):
Game.fire()
every(0.033, tick)
keys.down(name) is "held right now", keys.pressed(name) is "went down since the last tick" and keys.released(name) its opposite.
A name is one bare key — left, space, z — and the modifiers answer under their own names (shift, cmd, ctrl, alt), so down("left") is true whether or not shift is held with it.
Read them from a tick, not from a view: a view is rebuilt on the framework's schedule, so what it read there would be a moment the app never chose (the dialect refuses it for the same reason it refuses a clock in a view).
What pressed and released saw is spent by the tick that read it, so holding a key fires once however many frames it stays down.
A script presses one with keydown:left and lets go with keyup:left, and key:<chord> is both halves at once — which is why a game is gate-checkable frame by frame.
menu_item(menu, name, handler) puts the same handler in the application's menu bar.
Declaration order is menu order, the window hands the bar to the platform, and a script picks an item by name with menu:Save.
Of the three platforms, only macOS draws a menu bar from it; Linux and Windows keep the declaration and show nothing, while menu:Save fires the handler on all three.
on_file_drop(handler) is the same kind of declaration for a file dragged onto the window: the handler receives its path, and a script drops one with drop:<path>.