compilekv
An importable Python module. import compilekv and call it from your own
code — a build step, an editor plugin, a test fixture, whatever. It compiles
Kivy .kv files into plain Python classes, so widgets can be built without
Builder.
Why WebAssembly
The conversion is KvToPyClass, a Swift package. Shipping that as a native binary would mean a wheel per platform, built on a machine with a Swift toolchain, and no wheel at all for anything you did not build for — which is fine for a command line tool you install yourself, and useless for a module other people import.
Compiled to wasm instead, the conversion is just data: one
compilekv-0.1.0-py3-none-any.whl that installs and imports anywhere Python
runs. Python owns the filesystem — it walks the tree, reads the .kv and any
existing .py, and writes the result. The wasm module only ever sees and
returns strings, so nothing in it is platform specific.
The one native piece is the wasmtime runtime, which publishes wheels for
macOS (x86_64, arm64), Linux (x86_64, aarch64; glibc and musl), Windows
(amd64, arm64) and Android.
Usage
compilekv is a library. Import it and call the helpers:
from compilekv import compile_file, compile_tree, find_kv_files
compile_tree("ui/") # every .kv under a directory, in place
compile_tree("ui/", "build/") # ... or into a separate tree
compile_file("ui/style.kv") # one file
compile_file("ui/style.kv", "build/") # ... written to build/style.py
Each style.kv compiles to style.py. Give an output directory and the tree
under the input is mirrored into it, so ui/panels/style.kv becomes
build/panels/style.py and same-named files in different directories cannot
collide. Pass a path with a suffix instead and it is used verbatim as the file
name.
Extending, not replacing
The .py next to the .kv is the source. It is read whether or not you
compile into a separate output directory, and the result is that file with the
generated code folded in: imports the rules need are added to the ones already
there, a class the KV defines is merged with the same-named class in place, and
rules with no matching class are appended. Everything else -- module docstring,
constants, helper functions, unrelated classes -- stays where you put it.
Inside a class the author's body is the starting point, so properties,
annotations, the class docstring, nested classes and hand written methods all
survive. A generated method replaces the one it shares a name with, with one
exception: __init__ is appended to, not replaced. Your setup runs first,
then the widget tree:
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.counter = 0 # yours
self._bindings = [] # generated from here down
self.orientation = "vertical"
...
Your signature and your super() call are the ones kept. Everything from
self._bindings = [] onwards is treated as output from a previous run and
replaced, which is what keeps regenerating from stacking copies of the tree.
A <Name>: rule styles a class that already exists, so its bases come from
your Python; <Name@Base>: declares them inline. With neither, it falls back
to Widget.
Output is deterministic and regenerating is idempotent -- compiling twice leaves the files byte for byte identical, which keeps generated code reviewable in version control.
One consequence of extending rather than replacing: compiling in place,
where the source and the output are the same file, cannot tell a class it
emitted last run from one you wrote, so deleting a rule leaves its class
behind. Compile into an output directory for a file that only ever reflects
the current .kv.
For direct control over the strings, skipping the file layer entirely:
from compilekv import default_compiler
python_source = default_compiler().compile_source(kv_source, existing_py_source)
compile_source raises KvCompileError with the parser's message when the KV
is invalid.
One wasm module per process
Compiling the wasm module takes a few seconds against ~3 ms per conversion, so
it happens once. default_compiler() returns a process-wide instance shared by
every caller, so any number of modules can import compilekv and convert as often
as they like without reloading:
# module_a.py # module_b.py
from compilekv import compile_file import compilekv
compile_file("a.kv") compilekv.compile_file("b.kv")
# ^ pays the load # ^ ~1 ms, same instance
Conversions are serialized on the instance's own lock, so sharing it across
threads is safe. KvCompiler() still builds an isolated instance with its own
linear memory when you want one; the compiled module is cached either way.
Command line
Secondary, for one-off runs. No console script is installed.
$ python -m compilekv [paths...] [-o OUT] [--no-recursive] [-q]
-o is a directory when the input is one, mirroring its layout, and may be a
file name when compiling a single .kv.
Tests
$ COMPILEKV_SKIP_WASM_BUILD=1 uv run --group dev pytest
Drop the environment variable to rebuild the wasm module first. The suite covers the wasm ABI, the file walking layer, and the CLI, and finishes with a round trip that prints both inputs and the generated output.
Building from source
The wheel bundles a prebuilt compilekv.wasm. Rebuilding it needs a Swift
toolchain plus a matching Swift SDK for WebAssembly,
KvToPyClass/ is vendored in this repo and referenced by relative path, so no
extra checkout is needed.
$ python scripts/build_wasm.py # just the wasm module
$ python -m build --wheel # wasm + wheel
The build picks a wasm SDK matching the active toolchain, falling back to
swiftly run +<version> when the default toolchain is a different version.
Override with COMPILEKV_SWIFT, COMPILEKV_WASM_SDK, or set
COMPILEKV_SKIP_WASM_BUILD=1 to reuse the module already in src/compilekv/.
The module is ~46 MB because Swift's Foundation is statically linked (about 36 MB of that is Foundation's data tables); the wheel compresses to ~18 MB.
Wasm ABI
The Swift package builds as a WASI reactor exporting:
| Export | Purpose |
|---|---|
kv_alloc(size) -> ptr |
allocate an input buffer in linear memory |
kv_dealloc(ptr, size) |
release one |
kv_convert(kv_ptr, kv_len, py_ptr, py_len) -> status |
convert; 0 on success, 1 on error |
kv_result_ptr() / kv_result_len() |
the generated source, or the error message |
kv_result_free() |
release the result buffer |
root, self, and bindings
root in KV is the widget the rule applies to, so it becomes self:
<Card>:
Label:
text: root.title
label_1.text = self.title
self.bind(title=label_1.setter("text"))
self is the widget whose block the value was written in, so under a child
it is that child, not the rule:
<Card>:
Label:
height: self.texture_size[1]
label_1.height = label_1.texture_size[1]
label_1.bind(texture_size=_callback_0)
The bind call is only emitted when the attribute is a Kivy property of
whichever object it is read from. That is answered by the widget registry for
anything Kivy ships, by the rule for a widget another rule defines, and by the
class itself for one in your .py -- title = StringProperty("") binds,
title = "x" does not, because bind() on a non-property raises. A widget
from outside the module cannot be checked, so it is assumed bindable. In a
mixed expression the bindable names are bound and the rest are read once.
Directives
KV's #:set directives are substituted into the generated code, since there is
no Builder at runtime to resolve them:
#:set plex_16 sp(16)
<Item>:
font_size: plex_16
from kivy.metrics import sp
...
self.font_size = sp(16)
The file that declares a #:set also publishes it, so .kv files loaded at
runtime still resolve the name -- Builder only knows the directives from files
it has already parsed:
from kivy.lang.parser import global_idmap
global_idmap["plex_16"] = sp(16)
Only the declaring file does this; one that merely uses the constant gets the substituted value and nothing else.
#:import becomes a real import, but only for the names the generated code
actually reads:
#:import get_font_name carbonkivy.utils.get_font_name
from carbonkivy.utils import get_font_name
KV puts both kinds in one namespace shared by everything Builder loads, so the
whole project is read before anything is written. Project.scan(roots) walks
every .kv -- and every .py, since KV passed to Builder.load_string()
carries directives the .kv files rely on -- and compile_tree and
python -m compilekv both go through it. Nothing depends on which file is
walked first. A #:set in the file itself wins over a shared one.
collect_directives(paths) exposes the gathering, and
compile_file(..., directives=...) takes the result.
Declared property types
A bare unquoted word normally becomes a string, but not when the property it is
assigned to cannot hold one. font_size: SOME_GLOBAL on a NumericProperty
emits the name, not "SOME_GLOBAL". The type comes from the widget registry or
from the property your class declares, so my_size = NumericProperty() in your
.py is taken into account.
Canvas
A canvas layer becomes a with block, the way it is written by hand:
<Card>:
canvas.before:
Color:
rgba: (1, 0, 0, 1)
Rectangle:
pos: self.pos
size: self.size
with self.canvas.before:
Color(rgba=(1, 0, 0, 1))
self.rectangle_1 = Rectangle(pos=self.pos, size=self.size)
self.bind(pos=_callback_1, size=_callback_2)
An instruction whose properties track something is named so the bindings have
an object to update; the rest stay anonymous. Child widgets get their own
canvas blocks, where self is that child.
Factory registration
Every generated class is registered, so other KV files and Builder can
resolve it by name:
Factory.register("MyButton", cls=MyButton)
Existing registrations in your .py are left alone rather than duplicated.
A widget the KV references that Kivy does not ship and this module does not
define is assumed to be a custom widget registered elsewhere, so it is taken
off the Factory instead of guessed at with a kivy.uix import:
MyWidget = Factory.MyWidget
This is a plain constant, not type MyWidget = Factory.MyWidget. The PEP 695
form resolves lazily, which would be nicer, but a TypeAliasType is not
callable and the generated code has to construct the widget. The constant is
read at import time, so the widget must be registered by then.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file compilekv-0.0.0.tar.gz.
File metadata
- Download URL: compilekv-0.0.0.tar.gz
- Upload date:
- Size: 69.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7cb9eeb7b030bccc6bf1158b4b6558e98390cb72ccaf1c5e7e46cf66795de343
|
|
| MD5 |
5c97d1f66a8ba48cc786df444db1ff56
|
|
| BLAKE2b-256 |
8d1e15ecb3baa71e18fa7c82c7b231231accfa83dffdcad494d7f54c24728ced
|
Provenance
The following attestation bundles were made for compilekv-0.0.0.tar.gz:
Publisher:
pypi-release.yml on kivy-school/compilekv
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
compilekv-0.0.0.tar.gz -
Subject digest:
7cb9eeb7b030bccc6bf1158b4b6558e98390cb72ccaf1c5e7e46cf66795de343 - Sigstore transparency entry: 2769004790
- Sigstore integration time:
-
Permalink:
kivy-school/compilekv@44043387c12c262ab17a85ed97b8e6f85e4e0656 -
Branch / Tag:
refs/tags/0.0.0 - Owner: https://github.com/kivy-school
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
pypi-release.yml@44043387c12c262ab17a85ed97b8e6f85e4e0656 -
Trigger Event:
push
-
Statement type:
File details
Details for the file compilekv-0.0.0-py3-none-any.whl.
File metadata
- Download URL: compilekv-0.0.0-py3-none-any.whl
- Upload date:
- Size: 19.3 MB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
130ff8cd2e5c0e24fb83984fb6a33dcd0817362a38e4d4f5ff5de69385dcc28e
|
|
| MD5 |
e3eabb1d2bcf93886807a8f50f8241c6
|
|
| BLAKE2b-256 |
b7c7a582e1e8ff8aa0a6ee38203e36dec85b3b50c092f6ec0755c86fbaa238e1
|
Provenance
The following attestation bundles were made for compilekv-0.0.0-py3-none-any.whl:
Publisher:
pypi-release.yml on kivy-school/compilekv
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
compilekv-0.0.0-py3-none-any.whl -
Subject digest:
130ff8cd2e5c0e24fb83984fb6a33dcd0817362a38e4d4f5ff5de69385dcc28e - Sigstore transparency entry: 2769004880
- Sigstore integration time:
-
Permalink:
kivy-school/compilekv@44043387c12c262ab17a85ed97b8e6f85e4e0656 -
Branch / Tag:
refs/tags/0.0.0 - Owner: https://github.com/kivy-school
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
pypi-release.yml@44043387c12c262ab17a85ed97b8e6f85e4e0656 -
Trigger Event:
push
-
Statement type: