The Python track
Part I, chapter for chapter, from a language with no kernel in it. Every listing is in
examples/python/. The blocks that start a peer and mount it
(Binding and the family below) are replayed against the
book’s pinned ikigai-cli on every change to this repository, with ikigai-python at its
main, by scripts/polyglot-tracks.sh: the peers are started from these very lines, and what
each ikigai command prints, cache verdicts included, is compared with the page. The client
listings and the describe card were run against a served ikigai-cli 0.1.18 as they were
written and are not replayed (the refusal text shown in the capabilities section is a later
kernel’s: see the note there).
If the ttt-host from Tic-tac-toe, VI or its apps are still
running, they can stay: this kernel is on a socket of its own.
pip install rdflib /path/to/ikigai-python # a checkout; not on PyPI yet
ikigai serve /tmp/ikbook-kernel.sock & # a kernel to talk to
until [ -S /tmp/ikbook-kernel.sock ]; do sleep 0.2; done # wait for its socket
The pip install brings ikigai-python’s one dependency with it: blake3, for the digests in
the contract IRIs a served door publishes.
Resolution: a name, over the wire
# A name, resolved. `connect` speaks the wire protocol over the kernel's socket; the
# answer is a representation — bytes with a type — exactly what a Rust caller gets.
k = ikigai.connect(path)
rep = k.source("urn:iki:fn:toUpper", **{"in": "resource oriented computing"})
print(rep.text) # RESOURCE ORIENTED COMPUTING
print(rep.media_type) # text/plain;charset=utf-8
print(rep.cache_status.name) # how the kernel's cache answered: MISS, HIT, UNCACHEABLE
connect speaks the wire protocol — a versioned hello each way, then framed requests —
and source is the verb. The answer is a representation: text, a media type, and how
the kernel’s cache answered. Nothing about it says the endpoint was Rust, or in another
process, which is Resolution’s point made from the
other side.
Hello, resource: a decorated function
# The decorator is the description, and the signature is the contract. `who: str`
# becomes a required input of class xsd:string; the `Annotated` text becomes its
# summary; `cacheable=True` is the same claim `.cacheable()` makes in Rust — a pure
# function of its declared inputs. Whether a kernel that mounts this peer caches the
# answers is the kernel's call, not this process's, and the chapter shows what
# ikigai-cli does with it.
@endpoint("urn:py:hello", summary="Greet someone", cacheable=True)
def hello(who: Annotated[str, "the name to greet"]) -> str:
return f"Hello, {who}!"
# A second one, so `list` has something to show and a pipe has something to feed: a list in,
# one item per line, and each line shouted (the TypeScript track's `shout` does the same).
@endpoint("urn:py:shout", summary="Shout each line of a list", cacheable=True)
def shout(text: Annotated[str, "the lines to shout, one per line"]) -> str:
return "\n".join(line.upper() + "!" for line in text.split("\n"))
if __name__ == "__main__":
path = sys.argv[1] if len(sys.argv) > 1 else "/tmp/py-hello.sock"
print(f"serving urn:py:hello and urn:py:shout on {path}", file=sys.stderr)
serve([hello, shout], path) # blocks; speaks the wire protocol
Compare Hello, resource: a function, a
description, a binding. Here the decorator is the description and the socket path is the
binding — this process is a space, and serve speaks the same protocol a Rust host
speaks to any other peer.
Why an endpoint describes itself: the signature is the contract
In Rust you wrote the ArgSpecs by hand. Here they are derived from the signature:
who: str is a required input of class xsd:string, the Annotated text is its
summary, a default would make it optional, Literal[...] would be one_of. The
description a Rust host sees is exactly the one it would see from a Rust endpoint (the peer it
asks is started in Binding, below; start it first to run this):
$ ikigai --plain --mount urn:py:=/tmp/py-hello.sock -c 'describe urn:py:hello text/plain'
hello —
Greet someone
verbs: Source, Meta
input who [argument]: the name to greet
outputs: text/plain;charset=utf-8
[computed]
And from the client side, the same card as data — the JSON face of Meta, parsed:
# Self-description, as data. `describe` is the JSON face of Meta, parsed: the
# ArgSpecs an endpoint declared, which is what an agent's tool definition is made of.
card = k.describe("urn:iki:fn:toUpper")
print(card["id"], [arg["name"] for arg in card["inputs"]]) # toUpper ['in']
# ...and the same card as a graph, which the notebook chapter queries.
turtle = k.meta("urn:iki:fn:toUpper", as_="text/turtle").text
print(turtle.splitlines()[0]) # @prefix ik: <https://ikigai-rs.dev/ns#> .
Binding: a mount is a binding
Start the peer, and wait for its socket before you mount it (the rm -f clears a socket an
earlier run left behind, which would end the wait before this peer is listening):
rm -f /tmp/py-hello.sock
python3 examples/python/hello.py /tmp/py-hello.sock &
until [ -S /tmp/py-hello.sock ]; do sleep 0.2; done
$ ikigai --plain --mount urn:py:=/tmp/py-hello.sock -c 'source urn:py:hello who=Ada'
Hello, Ada!
[uncacheable]
The Python process bound nothing under urn:py:; the host did, with --mount,
exactly as Binding, and a host of your own said a host
decides the name. list shows where the name is served from, after every name the host
binds itself and before its names layer, which ikigai-cli 0.2.6 binds last:
$ ikigai --plain --mount urn:py:=/tmp/py-hello.sock -c list
…
urn:py:hello → hello [/tmp/py-hello.sock]
urn:py:shout → shout [/tmp/py-hello.sock]
urn:iki:host:names → host-names
urn:iki:space:dynamic:names:status → dynamic-space-status
hello declares cacheable=True, and the answer above still says [uncacheable]. Read it
twice and ask the host why:
$ ikigai --plain --mount urn:py:=/tmp/py-hello.sock -c 'source urn:py:hello who=Ada' -c 'source urn:py:hello who=Ada' -c 'source urn:kernel:uncached'
Hello, Ada!
[uncacheable]
Hello, Ada!
[uncacheable]
uncached (why a computed read was not cached; the last 64, most recent first)
urn:py:hello ×2 declared [root]
[uncacheable]
— batch: 3 commands · 3 uncacheable
ikigai-cli 0.2.6, the version this page is replayed against, caches nothing a mounted peer
answers: both reads reached the Python process, and urn:kernel:uncached gives declared as
the reason. That is the mount’s rule, on purpose: a kernel that mounts a peer stores no copy of
what the peer answers, because a change on the peer’s side (a file it watches, a write
that reaches it by another route) never crosses back to the mounting kernel, so a stored
copy could be served stale for as long as the host runs. The declared reason is the
mount’s own declaration, not the peer’s. So cacheable=True on a served peer describes its answers;
through --mount, the host does not keep them.
What resolution buys you: the kernel’s cache and trace, from outside
# Cached once: the probe says not yet; a resolution; the probe says served now.
print(k.is_cached("urn:iki:fn:toUpper", **{"in": "a b"})) # False
k.source("urn:iki:fn:toUpper", **{"in": "a b"})
print(k.is_cached("urn:iki:fn:toUpper", **{"in": "a b"})) # True
# Traced: the kernel records its own events and ships them back over the wire.
rep, events = k.source_traced("urn:iki:fn:toUpper", **{"in": "a b"})
for event in events:
print(event.target, "cache_hit" if event.cache_hit else "computed")
The cache is the kernel’s: what these two listings probe is a name the served kernel
binds itself, and its answer is cached there, for every client of that kernel. cacheable=True
on a decorated function only marks the representation. Served straight from Python nothing is
cached, and mounted through ikigai-cli 0.2.6 nothing is either, as
Binding showed. The trace events come back over the wire and are the same TraceEvents a Rust
tracer receives.
Capabilities: a scoped connect
# Capabilities: connect under a narrowed authority and the kernel enforces it — the
# served kernel clamps what you carry to what the channel is entitled to, and a
# write to the file workspace under a read-only scope is refused, typed.
narrow = ikigai.connect(path, capability=Capability.scoped(["urn:cap:kernel:inspect"]))
try:
narrow.sink("urn:file:notes.txt", "nope")
except ikigai.DeniedError as denied:
print("denied:", denied.message)
narrow.close()
denied: capability does not grant `urn:cap:fs:write:*` (declared by `urn:file:notes.txt`; any grant under `urn:cap:fs:write:` satisfies it)
That refusal line is ikigai-core 0.1.97’s wording (its “any grant under … satisfies it”
clause); the 0.1.18 host these listings were first run against said only that the capability
did not grant the name. The typed refusal is the same either way.
The client carried a narrowed capability; the served kernel clamped it to what the
channel is entitled to and enforced it. The refusal crossed the wire typed —
DeniedError, not a string to parse — which is what lets a REST face over this client
answer 403 rather than 500 (Who is asking, from a client).
Families: one door, many names, four verbs
Every name so far was one resource. Most interesting resources come in families, such as
a note for every name or a cell for every square, and a Rust endpoint binds a family with a
UriTemplate. In Python a family is family(pattern), and each verb is a decorated function whose parameters include the template’s variables:
# One door for every `urn:py:note:{name}`: `{name}` is a Level 1 variable, matched the
# way ikigai_core::UriTemplate matches it, and it reaches each handler as a parameter.
note = family("urn:py:note:{name}", id="py-note", summary="A note, by name")
Name = Annotated[str, "the note's name"]
@note.source(cacheable=True, summary="the note's text; NotFound if there is none")
def read(name: Name) -> str:
with LOCK:
if name not in NOTES:
raise NotFoundError(f"no note called {name}")
return NOTES[name]
@note.sink(summary="write the note")
def write(name: Name, content: Annotated[str, "the note's text"]) -> str:
with LOCK:
NOTES[name] = content
return f"wrote {name}"
@note.delete(summary="forget the note")
def forget(name: Name) -> str:
with LOCK:
NOTES.pop(name, None)
return f"forgot {name}"
# No `@note.exists`: Exists defaults to "would Source succeed", so a NotFoundError is
# `false` — cacheable exactly when Source is.
{name} is a Level 1 variable, and it matches the way ikigai_core::UriTemplate matches,
line for line. A variable followed by a literal captures up to the leftmost occurrence of
that literal, a trailing variable takes the rest, and every capture is non-empty. The host
and the peer have to agree on which names a pattern covers, because the host reads the
peer’s catalog to decide what to forward, so the face mirrors core’s rule and does not
approximate it. The catalog lists the template, so list shows one family, not every note
anyone has written.
Each verb gets its own contract, which is core’s per-verb ActionSpec form: a Source that
is cacheable and answers NotFound for a missing note, a Sink whose body arrives as
content, and a Delete. Exists is not written at all. It defaults to “would Source
succeed”, so a NotFound answers false.
rm -f /tmp/py-notes.sock
python3 examples/python/notes.py /tmp/py-notes.sock &
until [ -S /tmp/py-notes.sock ]; do sleep 0.2; done
$ ikigai --plain --mount urn:py:=/tmp/py-notes.sock -c 'sink urn:py:note:milk two liters' \
-c 'source urn:py:note:milk' -c 'source urn:py:note:milk' \
-c 'sink urn:py:note:milk three liters' -c 'source urn:py:note:milk' \
-c 'source urn:py:note:index' -c 'delete urn:py:note:milk' -c 'source urn:py:note:milk'
wrote milk
[uncacheable]
two liters
[uncacheable]
two liters
[uncacheable]
wrote milk
[uncacheable]
three liters
[uncacheable]
milk
[uncacheable]
forgot milk
[uncacheable]
error: not found: no note called milk
— batch: 8 commands · 7 uncacheable
Read the verdicts. The note’s Source declares itself cacheable, and, as in
Binding, ikigai-cli 0.2.6 caches nothing a mounted peer
answers, so every read here reached the peer. The peer keeps no cache of its own either, and
that is why the read after the second write saw the new text. The writes and the delete are
uncacheable whatever the host does, because a Sink or Delete answer always is, and the index
is uncacheable on purpose: it reads every note, and a write is about only the note it wrote.
Exists, from a client (the block above ended by deleting milk, so sink it again
first):
k = ikigai.connect("/tmp/py-notes.sock") # the peer itself; Exists is a read
print(k.exists("urn:py:note:milk").text) # true, after `sink urn:py:note:milk two liters`
print(k.exists("urn:py:note:bread").text) # false
Which door answers
Two rules, and the second one is the reason the first can be trusted.
The first declared door that matches wins, as in core’s EndpointSpace. The family
would match urn:py:note:index too, with {name} capturing index. The index works
because it is declared first:
# An exact name, declared FIRST: the family below would match `urn:py:note:index` too
# (its `{name}` would capture `index`), and the first declared door that matches wins.
# Not cacheable: it reads every note, and a write cuts only the note it wrote.
@endpoint("urn:py:note:index", summary="The notes there are, one name per line")
def index() -> str:
with LOCK:
return "\n".join(sorted(NOTES))
Every door has two forms, and every door’s declared form is tried before any door’s
stripped form. --mount urn:py:= is an alias mount: the host rewrites
urn:py:note:milk to urn:note:milk before forwarding it, the way
Preferential resolution described. A peer cannot know the prefix it was
mounted at, only that it was aliased, so it answers each door at its declared pattern and at
that pattern with the first segment stripped: urn:py:note:{name} and urn:note:{name}.
If the forms were tried door by door, one door’s stripped form could shadow the next door’s
declared name. Trying every declared form first means a stripped form can only answer a
name that no door declares. A connection whose hello says it comes from an alias mount is
sending stripped names, so for that connection the order reverses, and stripped forms go
first. Because only the first segment can be stripped, a family mounted deeper,
at urn:py:note: for example, needs --override, which forwards names unchanged.
A family is how a store in another language holds a game: the sixth part of the tic-tac-toe arc, Tic-tac-toe, VI, which you have already read, serves the game’s stored cell as one, from Python, under a Rust host that keeps everything else.
What an L0 peer cannot do
camel-title in What resolution buys you resolved
title from inside its own invocation, and inherited its golden thread. A Python
endpoint cannot: there is no inv.source on this side of the wire, no back-channel from a
served peer into the kernel that is invoking it. A Python endpoint is a leaf — it takes
arguments and returns bytes — and composition happens in the kernel, above it. That is L0,
stated plainly; the ladder’s next rung is the module protocol’s host callback, which
Part II showed and which no polyglot peer speaks yet.