Skip to main content

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.

Metadata

Release files for compilekv 0.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for compilekv 0.0.0
File Size Uploaded Provenance Actions
compilekv-0.0.0.tar.gz 69.5 kB With provenance Details for compilekv-0.0.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for compilekv 0.0.0
File Interpreter ABI Platform Actions
compilekv-0.0.0-py3-none-any.whl Python 3 none any Details for compilekv-0.0.0-py3-none-any.whl

Total release size: 19.4 MB

Release files / compilekv-0.0.0.tar.gz

Download URL compilekv-0.0.0.tar.gz
Size 69.5 kB
Tags Source
SHA-256 checksum
How to use checksums
7cb9eeb7b030bccc6bf1158b4b6558e98390cb72ccaf1c5e7e46cf66795de343
BLAKE2b-256 checksum
How to use checksums
8d1e15ecb3baa71e18fa7c82c7b231231accfa83dffdcad494d7f54c24728ced
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 9, 2026.

Transparency log

Release files / compilekv-0.0.0-py3-none-any.whl

Download URL compilekv-0.0.0-py3-none-any.whl
Size 19.3 MB
Tags Python 3
SHA-256 checksum
How to use checksums
130ff8cd2e5c0e24fb83984fb6a33dcd0817362a38e4d4f5ff5de69385dcc28e
BLAKE2b-256 checksum
How to use checksums
b7c7a582e1e8ff8aa0a6ee38203e36dec85b3b50c092f6ec0755c86fbaa238e1
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 9, 2026.

Transparency log

Security for compilekv 0.0.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for compilekv-0.0.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 9, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.0.0 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page