Data-driven schemas for Basilisp - Malli-inspired.
Project description
Balli
Data-driven schemas for Basilisp, inspired by Malli.
Schemas are plain data in Malli's vector syntax, plus Malli-style map syntax / AST input — no macros, no protocols to implement. Balli covers most of Malli's surface: validation and Malli-shaped explain data with :path/:in, humanized errors with key spell-checking and localized/custom messages, value transformation (decode/encode/coerce), parse/unparse with tagged branches, sequence (regex) schemas with an iterative backtracking engine, function schemas with generative checking and instrumentation, deterministic seeded generators with regex generation, generator objects, and heuristic shrinking, JSON Schema/OpenAPI/Swagger/DOT/PlantUML/description export, schema utilities (merge/union/closed-schema/...), form walking, safe schema serialization through named function refs and EDN wrappers, custom schema type extensions, schema inference/destructuring from sample data, predicate/comparator schemas, time schemas, default branches, self-contained local registries, and mutable/lazy/dynamic/var custom registries with recursive refs. Malli-inspired, not a strict port — see Differences from Malli. Pure Basilisp; no Python dependencies beyond the standard library.
Project resources
- Hosted docs: https://vandyand.github.io/balli/
- API reference: https://vandyand.github.io/balli/api/
- Compatibility matrix: docs/malli-compatibility.md
- Examples: examples/
- Benchmarks: benchmarks/
- Release process: RELEASE.md
- Changelog: CHANGELOG.md
Install
From PyPI, after a release has been published:
python -m pip install balli
From source:
git clone https://github.com/vandyand/balli.git
cd balli
python -m pip install .
On PEP-668 ("externally managed environment") systems, either use a virtualenv or add --break-system-packages:
python -m pip install . --break-system-packages
To verify a fresh clone quickly:
python -m pip install -e ".[dev]"
scripts/user_suite.sh
Quick start
Validate:
(require '[balli.core :as b])
(b/validate [:map
[:id :string]
[:age {:optional true} [:int {:min 0}]]]
{:id "abc" :age 3})
;; => true
Explain why a value fails (nil when it doesn't):
(b/explain [:vector :int] [1 "x"])
;; => {:schema [:vector :int]
;; :value [1 "x"]
;; :errors [{:path [0] :in [1] :schema :int :value "x"
;; :type :balli.core/invalid-type}]}
Humanize explain data into messages shaped like the value:
(require '[balli.error :as be])
(be/humanize (b/explain [:map [:x :int]] {:x "s"}))
;; => {:x ["should be an integer"]}
(be/humanize (b/explain [:string {:min 3}] "ab"))
;; => ["should be at least 3 characters"]
Register schemas by qualified keyword and reference them — directly or via [:ref ...]:
(require '[balli.registry :as reg])
(def r (reg/registry {:user/id [:string {:min 1}]
:user/user [:map [:id :user/id]]}))
(b/validate :user/user {:id "abc"} {:registry r})
;; => true
(b/validate [:ref :user/id] "" {:registry r})
;; => false
Recursive schemas terminate on finite data:
(def tree-registry
(reg/registry {:tree/node [:map
[:value :int]
[:children [:vector [:ref :tree/node]]]]}))
(b/validate [:ref :tree/node]
{:value 1 :children [{:value 2 :children []}]}
{:registry tree-registry})
;; => true
(b/validate [:ref :tree/node]
{:value 1 :children [{:value "x" :children []}]}
{:registry tree-registry})
;; => false
Make a schema self-contained with a local :registry property. The registry
scopes over the whole subtree, including recursive refs:
(def local-tree
[:schema {:registry {:tree/node [:maybe [:map
[:value :int]
[:children [:vector [:ref :tree/node]]]]]}}
:tree/node])
(b/validate local-tree {:value 1 :children [{:value 2 :children []}]})
;; => true
Use :balli.core/default entries for residual map keys or fallback :multi
branches:
(def with-default
[:map {:closed true}
[:id :int]
[:balli.core/default [:map-of :keyword :string]]])
(b/validate with-default {:id 1 :extra "ok"})
;; => true
(be/humanize (b/explain with-default {:id 1 :extra 2}))
;; => {:extra ["should be a string"]}
Decode time values with the separate time transformer:
(require '[balli.transform :as bt])
(def decoded
(b/decode [:map [:created :time/instant] [:ttl :time/duration]]
{:created "2024-06-01T12:00:00+00:00" :ttl "PT15M"}
(bt/transformer (bt/json-transformer) (bt/time-transformer))))
(b/validate [:map [:created :time/instant] [:ttl :time/duration]] decoded)
;; => true
Predicate functions/symbols and comparators are schemas:
(b/validate int? 1) ;; => true
(b/validate 'int? "x") ;; => false
(b/validate [:> 10] 11) ;; => true
(be/humanize (b/explain [:<= 10] 11))
;; => ["should be at most 10"]
Assert, getting the value back or an ex-info carrying the explain map:
(b/assert-valid :int 5)
;; => 5
(try (b/assert-valid :int "x")
(catch python/Exception e (:type (ex-data e))))
;; => :balli.core/invalid-input
Decode values with a transformer:
(require '[balli.transform :as bt])
(b/decode [:map [:age :int] [:tags [:set :keyword]]]
{:age "42" :tags ["a" "b"]}
(bt/string-transformer))
;; => {:age 42 :tags #{:a :b}}
Parse into tagged branches (and back):
(b/parse [:orn [:num :int] [:str :string]] 42)
;; => #balli.compile.Tag{:key :num :value 42}
(b/parse :int "x")
;; => :balli.core/invalid
Validate sequences with regex schemas:
(b/validate [:cat :int [:* :string]] [1 "a" "b"])
;; => true
(b/validate [:cat :int [:* :string]] [1 "a" 2])
;; => false
Generate values, deterministically under a seed:
(require '[balli.generator :as bg])
(bg/generate [:map [:id :uuid] [:n [:int {:min 0 :max 9}]]] {:seed 1})
;; => {:id #uuid "4283fefc-63f0-cd0e-873a-0000c6d07ef7" :n 5}
Infer a schema from sample data:
(require '[balli.provider :as bp])
(bp/provide [{:x 1} {:x 2 :y "a"}])
;; => [:map [:x :int] [:y {:optional true} :string]]
Supported schemas
All 59 builtin schema type keywords, each with a validating example. Predicate
schemas are supported too, but they are not builtin keywords: the schema is the
predicate fn or quoted symbol itself, e.g. int? or 'int?.
| Type | Example |
|---|---|
:any |
(b/validate :any 42) |
:nil |
(b/validate :nil nil) |
:string |
(b/validate [:string {:min 1}] "hi") |
:int |
(b/validate [:int {:min 0 :max 10}] 5) |
:float |
(b/validate :float 1.5) |
:double |
(b/validate :double 1.5) (alias of :float — Python has one float type) |
:number |
(b/validate :number 1) (int or float) |
:boolean |
(b/validate :boolean true) |
:keyword |
(b/validate :keyword :a) |
:symbol |
(b/validate :symbol 'foo) |
:uuid |
(b/validate :uuid (random-uuid)) |
:time/instant |
(b/validate :time/instant (datetime/datetime 2024 1 1 0 0 0 0 ** :tzinfo datetime.timezone/utc)) |
:time/offset-date-time |
aware datetime.datetime |
:time/zoned-date-time |
aware datetime.datetime whose tzinfo is zoneinfo.ZoneInfo |
:time/local-date-time |
(b/validate :time/local-date-time (datetime/datetime 2024 1 1)) |
:time/local-date |
(b/validate :time/local-date (datetime/date 2024 1 1)) |
:time/local-time |
(b/validate :time/local-time (datetime/time 10 0 0)) |
:time/offset-time |
offset-aware datetime.time |
:time/duration |
(b/validate :time/duration (datetime/timedelta ** :seconds 900)) |
:time/period |
map {:years int :months int :days int} |
:time/zone-id |
IANA zone string accepted by zoneinfo.ZoneInfo |
:time/zone-offset |
datetime.timezone |
:map |
(b/validate [:map [:x :int] [:y {:optional true} :string]] {:x 1}) |
:map-of |
(b/validate [:map-of :keyword :int] {:a 1 :b 2}) |
:vector |
(b/validate [:vector :int] [1 2 3]) |
:sequential |
(b/validate [:sequential :int] '(1 2)) |
:seqable |
(b/validate [:seqable :int] #{1 2}) |
:every |
(b/validate [:every {:coll-check-limit 2} :int] [1 2 "unchecked"]) |
:set |
(b/validate [:set :int] #{1 2}) |
:tuple |
(b/validate [:tuple :string :int] ["a" 1]) |
:enum |
(b/validate [:enum "S" "M" "L"] "M") |
:= |
(b/validate [:= 42] 42) |
:maybe |
(b/validate [:maybe :int] nil) |
:and |
(b/validate [:and :int [:fn even?]] 4) |
:andn |
(b/validate [:andn [:type :int] [:positive [:> 0]]] 7) |
:or |
(b/validate [:or :int :string] "x") |
:orn |
(b/validate [:orn [:num :int] [:str :string]] 42) (named branches; parses to a Tag) |
:not |
(b/validate [:not :string] 1) |
:fn |
(b/validate [:fn {:error/message "must be even"} even?] 4) (thrown exceptions count as invalid) |
:multi |
(b/validate [:multi {:dispatch :kind} [:cat [:map [:kind [:= :cat]]]]] {:kind :cat}) |
:re |
(b/validate [:re #"\d+"] "123") (full match; string patterns compiled via re-pattern) |
:ref |
(b/validate [:ref :user/id] "abc" {:registry (reg/registry {:user/id :string})}) |
:cat |
(b/validate [:cat :int :string] [1 "a"]) (sequence concatenation) |
:catn |
(b/validate [:catn [:n :int] [:s :string]] [1 "x"]) (named :cat; parses to Tags) |
:alt |
(b/validate [:alt :int :string] ["x"]) (one-element alternatives, in a sequence) |
:altn |
(b/validate [:altn [:n :int] [:s :string]] [1]) (named :alt; parses to a Tag) |
:? |
(b/validate [:? :int] []) (zero or one) |
:* |
(b/validate [:* :int] [1 2 3]) (zero or more) |
:+ |
(b/validate [:+ :int] [1]) (one or more) |
:repeat |
(b/validate [:repeat {:min 1 :max 3} :int] [1 2]) (bounded repetition) |
:schema |
(b/validate [:schema :int] 5) (transparent wrapper; stops seqex splicing) |
:=> |
(b/validate [:=> [:cat :int] :int] inc) (function schema: input seqex + output) |
:-> |
(b/function-info [:-> :int :string :boolean]) (function shorthand; inputs followed by output) |
:function |
(b/validate [:function [:=> [:cat :int] :int] [:=> [:cat :int :int] :int]] +) (multi-arity) |
:> |
(b/validate [:> 10] 11) |
:>= |
(b/validate [:>= 10] 10) |
:< |
(b/validate [:< 10] 9) |
:<= |
(b/validate [:<= 10] 10) |
:not= |
(b/validate [:not= 10] 11) |
Properties
| Property | Applies to | Meaning |
|---|---|---|
:min / :max |
:string (length), :int/:float/:double/:number (value), :time/* (value), :vector/:sequential/:set/:map-of (element count), :repeat (repetition count) |
Inclusive bounds; violations produce :balli.core/limits errors |
:closed |
:map |
When true, keys not declared in the schema are rejected (:balli.core/extra-key); maps are open by default |
:optional |
:map entries (entry-level props: [:k {:optional true} schema]) |
Key may be absent; when present its value must still match |
:error/message |
any schema | Overrides the default humanized message for errors at that schema |
:dispatch |
:multi (required) |
Keyword or function used to pick the child schema; unknown dispatch values are invalid (:balli.core/invalid-dispatch-value) |
:registry |
any vector-form schema | Local {qualified-keyword schema-form} registry scoped to that schema subtree |
:default |
any schema / :map entries |
Replacement for nil (and missing map keys) under default-value-transformer |
:decode/<name> / :encode/<name> |
any schema | Per-schema transformer override for the transformer named <name> (e.g. :decode/string); a real fn or {:enter f :leave g} map |
:gen/return :gen/elements :gen/schema :gen/fmap :gen/min :gen/max |
any schema | Generator hooks — constant, uniform pick, alternate schema, post-map fn, bound overrides |
Self-contained schemas and local registries
Any vector-form schema may carry {:registry {qualified-keyword schema-form}}
in its properties. The local registry is layered over the effective registry
for that whole subtree, including nested map entries, refs, transformers,
generators, and JSON Schema export. Local entries can refer to each other and
to outer entries, so recursive schemas can be shipped as one self-contained
form:
(def local-tree
[:schema {:registry {:tree/node [:maybe [:map
[:value :int]
[:children [:vector [:ref :tree/node]]]]]}}
:tree/node])
(b/validate local-tree {:value 1 :children [{:value 2 :children []}]})
;; => true
Shadowed ref names stay distinct in JSON Schema definitions:
(require '[balli.json-schema :as bjs])
(bjs/transform
[:schema {:registry {:x :int}}
[:map
[:outer [:ref :x]]
[:inner [:schema {:registry {:x :string}} [:ref :x]]]]])
;; => {"type" "object"
;; "properties" {"outer" {"$ref" "#/definitions/x"}
;; "inner" {"$ref" "#/definitions/x__2"}}
;; "required" ["outer" "inner"]
;; "definitions" {"x" {"type" "integer"}
;; "x__2" {"type" "string"}}}
Default branches
In a :map, an entry keyed :balli.core/default validates the residual map
of keys not explicitly declared. A default entry disables :closed extra-key
checking because undeclared keys are claimed by the default schema:
(def with-default
[:map {:closed true}
[:id :int]
[:balli.core/default [:map-of :keyword :string]]])
(b/validate with-default {:id 1 :extra "ok"}) ;; => true
(b/validate with-default {:id 1 :extra 2}) ;; => false
In a :multi, :balli.core/default is the fallback branch for dispatch
misses:
(def multi-default
[:multi {:dispatch :kind}
[:user [:map [:kind [:= :user]] [:name :string]]]
[:balli.core/default [:map [:kind :keyword]]]])
(:key (b/parse multi-default {:kind :system}))
;; => :balli.core/default
Time schemas
balli.time maps Malli-style time schemas onto Python datetime types:
| Schema | Python value |
|---|---|
:time/instant |
aware datetime.datetime (tzinfo not nil) |
:time/offset-date-time |
aware datetime.datetime |
:time/zoned-date-time |
aware datetime.datetime with zoneinfo.ZoneInfo |
:time/local-date-time |
naive datetime.datetime |
:time/local-date |
datetime.date, excluding datetime.datetime |
:time/local-time |
naive datetime.time |
:time/offset-time |
aware datetime.time |
:time/duration |
datetime.timedelta |
:time/period |
{:years int :months int :days int} |
:time/zone-id |
IANA zone string accepted by zoneinfo.ZoneInfo |
:time/zone-offset |
datetime.timezone |
Comparable time schemas support inclusive :min/:max, seeded generation,
JSON Schema formats, and a separate transformer. The time transformer is intentionally
not folded into string-transformer or json-transformer; compose it where
you want string parsing or ISO encoding:
(require '[balli.transform :as bt])
(def decoded
(b/decode [:map [:created :time/instant] [:ttl :time/duration]]
{:created "2024-06-01T12:00:00+00:00" :ttl "PT15M"}
(bt/transformer (bt/json-transformer) (bt/time-transformer))))
(b/validate [:map [:created :time/instant] [:ttl :time/duration]] decoded)
;; => true
(b/encode :time/duration (:ttl decoded) (bt/time-transformer))
;; => "PT15M"
Predicate and comparator schemas
The 41 Basilisp core predicates in balli.normalize/predicates are schemas
when used as a function value or quoted symbol. Predicate calls are
exception-safe: a thrown host exception means invalid, not a thrown validator.
(b/validate int? 1) ;; => true
(b/validate 'int? "x") ;; => false
(b/validate [:vector pos-int?] [1 2 3])
;; => true
Comparators validate by comparing the input value to their child value. They are leaves: the child is a literal value, not a nested schema.
(b/validate [:> 10] 11) ;; => true
(be/humanize (b/explain [:<= 10] 11))
;; => ["should be at most 10"]
Mutable registry
The default registry is mutable. register! adds schemas to the live default
registry and bumps an epoch so raw-form calls pick up the new meaning
immediately. Schema objects keep snapshot semantics: they bake the registry
seen at construction.
(reg/register! :demo/id :int)
(def baked (b/schema :demo/id))
(reg/register! :demo/id :string)
(b/validate :demo/id "x") ;; => true (live default)
(b/validate baked 1) ;; => true (snapshot)
(b/validate baked "x") ;; => false
reg/composite eagerly merges registries with first-hit schema lookup and
unioned builtin type sets. set-default-registry! replaces the live default;
tests that mutate it should restore the old default in try/finally.
Transformers
balli.transform provides value transformation driven by the schema. decode transforms input toward the schema, encode away from it; both are lenient — mismatching values pass through unchanged, never throw:
(require '[balli.core :as b]
'[balli.transform :as bt])
(b/decode :int "42" (bt/json-transformer))
;; => "42" (JSON already has numbers -- json-transformer does NOT parse number strings)
(b/encode [:map [:age :int]] {:age 42} (bt/string-transformer))
;; => {:age "42"}
coerce is decode-then-validate: it returns the decoded value or throws ex-info with {:type :balli.core/coercion :value ... :schema ... :explain ...}:
(b/coerce :int "42" (bt/string-transformer))
;; => 42
(try (b/coerce :int "x" (bt/json-transformer))
(catch python/Exception e (:type (ex-data e))))
;; => :balli.core/coercion
The seven built-ins:
| Transformer | Behavior |
|---|---|
string-transformer |
Decode scalars from strings (int/double/boolean/keyword/symbol/uuid/enum), sequentials into vectors/sets; encode scalars to strings |
json-transformer |
Assumes JSON-parsed input: no string→number/boolean coercion; keyword/symbol/uuid from string, sets from sequentials, :map-of keys via string rules |
time-transformer |
Decode/encode :time/* values with ISO strings; parse failures and awareness-policy misses pass through unchanged |
strip-extra-keys-transformer |
:map — drop undeclared keys (unless explicitly {:closed false}); :map-of — drop entries failing the key/value schemas |
key-transformer |
{:decode f :encode g} applied to map keys |
default-value-transformer |
Replace nil (and fill missing required map keys) from the :default property; opts {:key ... :defaults {type f} :add-optional-keys bool} |
collection-transformer |
Coerce between collection kinds (sequential↔set↔vector) |
(b/decode [:map [:x :int]] {:x 1 :junk 2} (bt/strip-extra-keys-transformer))
;; => {:x 1}
(b/decode [:map [:x {:default 7} :int]] {} (bt/default-value-transformer))
;; => {:x 7}
(b/decode [:map [:x :int]] {"x" 1} (bt/key-transformer {:decode keyword}))
;; => {:x 1}
(b/decode [:set :int] [1 1 2] (bt/collection-transformer))
;; => #{1 2}
Schemas can override a transformer per node with :decode/<name> / :encode/<name> properties (real fns, or {:enter f :leave g} maps):
(b/decode [:string {:decode/string (fn [s] (.upper s))}]
"kikka" (bt/string-transformer))
;; => "KIKKA"
Transformers compose with bt/transformer — decode runs the chain in order, encode in reverse:
(b/decode [:map [:x :int]] {:x "1" :junk 2}
(bt/transformer (bt/strip-extra-keys-transformer)
(bt/string-transformer)))
;; => {:x 1}
decoder/encoder/coercer return the compiled 1-arg fns (cached per schema + transformer identity) when you transform many values.
Schema utilities
balli.util is the malli.util port: form→form functions over :map schemas (they accept forms or schema objects and return forms):
(require '[balli.util :as u])
(u/merge [:map [:x :int]] [:map [:x :string] [:y :int]])
;; => [:map [:x :string] [:y :int]] (last-one-wins, entry order preserved)
(u/union [:map [:x :int]] [:map [:x :string]])
;; => [:map [:x [:or :int :string]]]
(u/select-keys [:map [:x :int] [:y :string]] [:x]) ;; => [:map [:x :int]]
(u/dissoc [:map [:x :int] [:y :string]] :y) ;; => [:map [:x :int]]
(u/optional-keys [:map [:x :int] [:y :string]] [:y])
;; => [:map [:x :int] [:y {:optional true} :string]]
(u/closed-schema [:map [:a [:map [:b :int]]]])
;; => [:map {:closed true} [:a [:map {:closed true} [:b :int]]]]
(u/get-in [:map [:a [:map [:b :int]]]] [:a :b])
;; => :int
Also: required-keys, open-schema, get. u/get/get-in address :map/:multi/:orn/:catn/:altn children by entry key and all indexed types (:and/:or/:tuple/colls/seqex/...) by integer index.
balli.core/walk is a form-level postwalk — at every node, children first, the rebuilt form and its path are passed to your fn, whose return value replaces the node:
(let [acc (atom [])]
(b/walk [:map [:x [:vector :int]]]
(fn [form path] (swap! acc conj [path form]) form))
@acc)
;; => [[[:x 0] :int] [[:x] [:vector :int]] [[] [:map [:x [:vector :int]]]]]
(b/walk s (b/schema-walker f)) adapts a 1-arg form fn, mirroring Malli's (m/walk s (m/schema-walker f)). Refs are not entered (the ref node is a leaf).
JSON Schema export
balli.json-schema/transform exports any schema as a JSON Schema (draft 2020-12 style, string-keyed Basilisp map — basilisp.json/write-str takes it as-is):
(require '[balli.json-schema :as bjs])
(bjs/transform [:map [:id :string] [:age {:optional true} [:int {:min 0}]]])
;; => {"type" "object"
;; "properties" {"id" {"type" "string"}
;; "age" {"type" "integer" "minimum" 0}}
;; "required" ["id"]}
(bjs/transform [:tuple :string :int])
;; => {"type" "array" "prefixItems" [{"type" "string"} {"type" "integer"}] "items" false}
Refs become $ref pointers with a top-level "definitions" map (recursion terminates); ref names are JSON-Pointer-escaped in the $ref string but unescaped as definitions keys:
(bjs/transform [:ref :tree/node] {:registry tree-registry})
;; => {"$ref" "#/definitions/tree~1node"
;; "definitions" {"tree/node" {"type" "object"
;; "properties" {"value" {"type" "integer"}
;; "children" {"type" "array"
;; "items" {"$ref" "#/definitions/tree~1node"}}}
;; "required" ["value" "children"]}}}
:title/:description/:default properties pass through; a :json-schema property replaces the generated node wholesale and :json-schema/foo properties unlift into it (winning over generated keys). Sequence schemas export as a lossy {"type" "array"} best-effort.
Humanized errors and spell-checking
balli.error/humanize renders explain data into messages mirroring the value shape (see Quick start). with-spell-checking rewrites explain errors before humanizing: an extra key within edit distance of an absent declared key becomes :balli.error/misspelled-key (and the corresponding missing-key error is dropped); an unknown :multi dispatch value near a declared one becomes :balli.error/misspelled-value:
(-> (b/explain [:map {:closed true} [:name :string]] {:nam "x"})
be/with-spell-checking
be/humanize)
;; => {:nam ["should be spelled :name"]}
be/levenshtein (the underlying edit distance) is public too: (be/levenshtein "nam" "name") → 1.
Parsing
parse destructures a value against a schema; unparse is the exact inverse. Both return the sentinel :balli.core/invalid on mismatch (test with b/invalid?, never identical?). Non-transforming schemas parse as validate-then-value; named branches produce records:
:orn/:multi/:altn→ aTagrecord{:key branch-key :value parsed}(b/tag,b/tag?):catn→ aTagsrecord{:values {tag parsed ...}}(b/tags,b/tags?)
(b/parse [:catn [:n :int] [:s :string]] [1 "x"])
;; => #balli.compile.Tags{:values {:n 1 :s "x"}}
(b/unparse [:catn [:n :int] [:s :string]]
(b/parse [:catn [:n :int] [:s :string]] [1 "x"]))
;; => [1 "x"]
(b/unparse [:orn [:num :int] [:str :string]] (b/tag :num 42))
;; => 42
Tag/Tags are records — user data that merely looks like {:key ... :value ...} is not confused with them by the :map parser guard. parser/unparser return the compiled (cached) 1-arg fns.
Sequence schemas
:cat/:catn/:alt/:altn/:?/:*/:+/:repeat describe sequences and are compiled to an iterative backtracking regex engine (no Python recursion on input length, memoized against pathological backtracking):
(b/validate [:cat [:* :int] :int] [1 2 3]) ;; => true (backtracks)
(b/validate [:map [:args [:cat :keyword [:* :int]]]]
{:args [:add 1 2]}) ;; => true (seqex nested in a map)
Seqex children of seqex splice — [:* [:cat :int :string]] matches a flat [1 "a" 2 "b"] — while parse output nests per combinator:
(b/parse [:* [:cat :int :string]] [1 "a" 2 "b"])
;; => [[1 "a"] [2 "b"]]
Wrap a seqex in [:schema ...] to escape splicing and match one nested sequence element instead:
(b/validate [:cat :int [:schema [:cat :int :string]]] [1 [2 "a"]])
;; => true
Explain reports the furthest failure position with dedicated error types:
(:type (first (:errors (b/explain [:cat :int] [])))) ;; => :balli.core/end-of-input
(:type (first (:errors (b/explain [:cat :int] [1 2])))) ;; => :balli.core/input-remaining
A :ref directly in seqex child position throws :balli.core/potentially-recursive-seqex at compile time (unbounded recursion cannot be compiled); wrap the ref in [:schema [:ref ...]] for one nested element.
Function schemas
[:=> input-seqex output-schema guard?] describes a function; :function groups :=> children with distinct arities. Plain validate only checks callability:
(b/validate [:=> [:cat :int] :int] inc) ;; => true
(b/validate [:=> [:cat :int] :int] 5) ;; => false
Pass a function checker to validate generatively (100 generated input vectors, outputs validated):
(require '[balli.generator :as bg])
(b/validate [:=> [:cat :int] :int] str
{:balli.core/function-checker (bg/function-checker)})
;; => false (str returns strings, not ints)
The checker opt is honored on raw forms (bypassing the raw-form cache) and can be baked into a schema object at construction — call-time opts on an existing schema object are ignored.
instrument wraps a fn with per-call arity/input/output/guard validation, throwing typed ex-info (:balli.core/invalid-arity / invalid-input / invalid-output / invalid-guard) or routing failures to your :report fn:
(try ((b/instrument {:schema [:=> [:cat :int] :int]} inc) "x")
(catch python/Exception e (:type (ex-data e))))
;; => :balli.core/invalid-input
function-info extracts arity/shape data:
(b/function-info (b/schema [:=> [:cat :int :int] :int]))
;; => {:min 2 :max 2 :arity 2 :input [:cat :int :int] :output :int}
A :=> whose input does not normalize to :cat/:catn throws :balli.core/invalid-input-schema. Generating from a :=> yields a constant-ish function producing valid outputs.
Generators
balli.generator generates schema-satisfying values with pure random.Random — equal seeds give equal values:
(bg/generate [:int {:min 0 :max 100}] {:seed 1}) ;; deterministic
(bg/sample [:int {:min 0 :max 100}] {:seed 1 :size 5}) ;; => [17 72 97 8 32]
(= (bg/generate [:map [:a :int] [:b [:vector :string]]] {:seed 42})
(bg/generate [:map [:a :int] [:b [:vector :string]]] {:seed 42}))
;; => true
:size (default 30) scales magnitudes and collection lengths. Seqex schemas generate flat sequences ((bg/generate [:cat :int [:* :string]] {:seed 3}) validates against its own schema). Recursive refs are depth-capped and escape through :maybe/optional keys/zero-min collections, else throw :balli.core/unsatisfiable-schema (as does :and/:not filtering after 100 retries).
Common :re forms generate strings directly (\d, \w, literal characters, simple character classes, +/*/?/{m,n}, and anchors). :fn schemas still have no default generator and throw :balli.core/no-generator unless you supply :gen/* property hooks:
(bg/generate [:re #"\d+"] {:seed 3})
;; => "398"
(bg/generate [:re {:gen/elements ["a1" "b2"]} #"[ab]\d"] {:seed 3})
;; => "a1"
(bg/generate [:int {:gen/fmap str}] {:seed 0})
;; => "829"
Hooks (highest priority first): :gen/return (constant), :gen/elements (uniform pick), :gen/schema (generate an alternate schema), :gen/fmap (post-map with a real fn), :gen/min/:gen/max (bound overrides).
shrink returns smaller valid candidates, optionally retaining only values that satisfy a failure predicate:
(bg/shrink [:int {:min 0}] 10)
;; => [0 5 9]
Schema inference
balli.provider/provide infers a schema form from sample values — every sample validates against the result:
(bp/provide [{:x 1} {:x 2 :y "a"}])
;; => [:map [:x :int] [:y {:optional true} :string]]
(bp/provide [1 2 nil])
;; => [:maybe :int]
(bp/provide [{"a" 1 "b" 2} {"c" 3} {"d" 4 "e" 5}])
;; => [:map-of :string :int]
Maps with many distinct keys but uniform key/value shapes become [:map-of k v] (tune with {:map-of-threshold n}, default 3); mixed scalars become [:or ...]; nil-mixed become [:maybe ...].
API
s is a raw schema form or a schema object; opts supports {:registry r} and is ignored when s is already a schema object (its baked-in registry — and baked-in checker — win). Compiled fns are cached on schema objects, or in a bounded global cache for raw forms.
balli.core
| Function | Signature | Description |
|---|---|---|
schema |
(schema form) (schema form opts) |
Wrap a form into a schema object (normalized AST + registry + compile cache). Idempotent on schema objects. opts may carry :balli.core/function-checker. |
schema? |
(schema? x) |
True when x is a balli schema object. |
form |
(form s) |
The original schema form. |
ast / from-ast |
(ast s) / (from-ast ast opts?) |
Malli-style map syntax / AST view and construction. |
properties |
(properties s) |
Properties map of the root node, e.g. {:closed true}. |
children |
(children s) |
AST children of the root node. |
validator / validate |
(validator s opts?) / (validate s value opts?) |
Compiled 1-arg boolean validator / one-shot boolean. |
explainer / explain |
(explainer s opts?) / (explain s value opts?) |
nil when valid, else {:schema :value :errors}; each error is {:path :in :schema :value :type}. |
assert-valid |
(assert-valid s value opts?) |
Returns value, or throws ex-info with the explain map plus {:type :balli.core/invalid-input}. |
decoder / decode |
(decoder s t opts?) / (decode s value t opts?) |
Transform toward the schema with transformer t; lenient (mismatches pass through). |
encoder / encode |
(encoder s t opts?) / (encode s value t opts?) |
Transform away from the schema; lenient. |
coercer / coerce |
(coercer s t opts?) / (coerce s value t opts?) |
Decode then validate; returns the decoded value or throws :balli.core/coercion. |
parser / parse |
(parser s opts?) / (parse s value opts?) |
Value → parsed value or :balli.core/invalid; Tag/Tags for named branches. |
unparser / unparse |
(unparser s opts?) / (unparse s value opts?) |
Exact inverse of parse; same sentinel. |
tag / tag? |
(tag k v) / (tag? x) |
Construct/detect the Tag parse container (:orn/:multi/:altn). |
tags / tags? |
(tags m) / (tags? x) |
Construct/detect the Tags parse container (:catn). |
invalid? |
(invalid? x) |
True when x is the parse failure sentinel (=-compared). |
walk |
(walk s f opts?) |
Form-level postwalk; f is (fn [rebuilt-form path] form'). |
schema-walker |
(schema-walker f) |
Adapt a 1-arg form fn for walk. |
function-info |
(function-info s opts?) |
{:min :max? :arity :input :output :guard?} for a :=> schema; nil otherwise. |
instrument |
(instrument props f) |
Wrap f per {:schema s :scope #{:input :output} :report rf} — typed failures per call. |
version |
var | The library version string. |
balli.transform
| Function | Description |
|---|---|
transformer |
Compose chain-link maps / transformers / 0-arg fns into one transformer (decode in chain order, encode reversed). |
transformer? / into-transformer |
Predicate / coercion into a transformer object. |
string-transformer json-transformer time-transformer strip-extra-keys-transformer key-transformer default-value-transformer collection-transformer |
The built-ins (see Transformers). |
balli.util
| Function | Description |
|---|---|
merge / union |
Combine two :map forms — last-wins / [:or]-combining. |
select-keys / dissoc |
Keep / remove :map entries. |
optional-keys / required-keys |
Set / clear :optional on entries (all, or a key seq). |
closed-schema / open-schema |
Recursively add / remove {:closed true} (explicit {:closed false} is respected). |
get / get-in |
Sub-schema form by entry key or child index; nil when absent. |
Other namespaces
| Function | Description |
|---|---|
balli.json-schema/transform |
(transform s opts?) — JSON Schema as a string-keyed map, "definitions" when refs are reached. |
balli.error/humanize |
(humanize explain-map) — messages mirroring the value shape; nil in, nil out. |
balli.error/with-spell-checking |
(with-spell-checking explain-map) — rewrite extra-key/dispatch errors as misspellings. |
balli.error/levenshtein |
(levenshtein a b) — edit distance. |
balli.generator/generate |
(generate s opts?) — one value; {:seed :size :registry}. |
balli.generator/sample |
(sample s opts?) — vector of values; :size is the count (default 10). |
balli.generator/generator / generate* / sample* / shrink* |
Reusable data generator objects, accepted by :gen/gen. |
balli.generator/shrink / shrink-candidates |
Smaller valid candidates for a value; {:predicate f} keeps only still-failing values. |
balli.generator/function-checker |
(function-checker opts?) — generative :=>/:function checker; {:iterations n} (default 100). |
balli.openapi/schema / openapi / request-body / response / parameter |
Export OpenAPI 3 schema objects, minimal documents, and operation fragments. |
balli.swagger/schema / swagger / parameter / response |
Export Swagger 2 schema objects, minimal documents, and operation fragments. |
balli.dot/transform |
Export a Graphviz DOT string for a schema graph. |
balli.plantuml/transform |
Export a PlantUML wrapper for a schema graph. |
balli.describe/describe |
Return a small English description for a schema. |
balli.lite/form / schema |
Explicit data-spec-style lite syntax conversion. |
balli.destructure/destructure / paths |
Validate/parse a value into a named destructuring shape; list named schema paths. |
balli.pretty/explain-str / validate-str / throw! |
Pretty development-time validation messages. |
balli.extension/register-type! / unregister-type! / type-spec / custom-type? / clear-types! |
Register custom schema keywords with validate/generate/transform/export/message callbacks. |
balli.serial/register-function! / unregister-function! / serialize / deserialize / write-string / read-string |
EDN-safe schema serialization with named function references and regex pattern round-tripping. |
balli.edn/write-string / read-string / serialize / deserialize |
Malli-named wrappers over Balli's safe serialization. |
balli.dev/register! / unregister! / start! / stop! / running / capture-fail! / captured-failures |
Explicit development instrumentation for atoms holding functions. |
balli.clj-kondo/function-config / lint-config |
Static-analysis metadata for function schemas. |
balli.sci/sci-context |
Data-only namespace map for embedding Balli public functions in restricted evaluators. |
balli.provider/provide |
(provide samples opts?) — infer a schema form; {:map-of-threshold n :infer-enums true :enum-threshold n :infer-tuples true}. |
balli.registry/registry |
(registry & schema-maps) — layer {qualified-kw form} maps over the default registry. |
balli.registry/lazy-registry |
(lazy-registry provider) / (lazy-registry base provider) — provider-on-miss registry with memoized forms. |
balli.registry/dynamic-registry |
(dynamic-registry source) — read an atom/fn source at raw-form lookup time. |
balli.registry/var-registry |
(var-registry {kw derefable-or-form}) — dereference schema refs on raw-form lookup. |
balli.registry/default-registry |
(default-registry) — returns the current mutable default registry. |
balli.registry/register! |
(register! kw form) / (register! {kw form ...}) — merge schemas into the current default registry. |
balli.registry/set-default-registry! |
(set-default-registry! r) — replace the mutable default registry. |
balli.registry/composite |
(composite r1 r2 ...) — eager first-hit registry merge. |
balli.registry/mutation-epoch |
atom — bumped by default-registry mutations; raw-form caches use it for invalidation. |
balli.registry/resolve-ref |
(resolve-ref reg k) — registered form or nil. |
balli.registry/builtin-types |
var — the set of 59 builtin type keywords. |
Unknown or malformed schema forms throw ex-info with :type :balli.core/invalid-schema; a :ref to an unregistered keyword throws :balli.core/unresolved-ref at compile time (fail fast, not at validate).
Differences from Malli
Balli covers most of Malli's core surface but is Malli-inspired, not a port. Not implemented:
See also docs/malli-compatibility.md for the feature-family compatibility matrix.
- No executable sexpr property code — schema properties take real Basilisp fns (
:fnchildren,:dispatch,:gen/fmap,:decode/*overrides, ...); safe serialization is explicit viaballi.serialnamed function refs and never evals code. - No old (pre-0.18) parse format shim.
- Time schemas use Python's stdlib model — offset/zoned distinctions follow Python
datetime/zoneinfo, and:time/periodis a map of calendar fields. - Heuristic shrinking, not test.check shrinking —
balli.generator/shrinkreturns practical smaller candidates, andballi.generator/generatorexposes reusable Balli generator objects, but Balli does not expose Clojure test.check generator objects. :fngeneration requires:gen/*props — otherwise:balli.core/no-generatoris thrown.- No executable
malli.experimentalor real SCI/clj-kondo runtime integration — Balli providesballi.sciandballi.clj-kondodata exports for host tooling, not Clojure analyzers.
Behavioral deviations:
- Basilisp data only — validators check Basilisp data structures. Python
dict/listvalues are not accepted; convert at the interop boundary first. walkis form-level —fis(fn [rebuilt-form path] form')over forms, not Malli's 4-arg walker over schema objects;schema-walkerkeeps the familiar call shape. Refs are not entered.- Schema-object opts and registries are baked — call-time opts (registry,
:balli.core/function-checker) are ignored when the schema argument is already a schema object; bake them at(b/schema form opts). Mutating the default registry affects future raw-form calls but not existing schema objects. Raw-form calls carrying a checker bypass the global raw-form cache. instrumentalways arity-checks — the argument-count check against the input seqex bounds runs on every call regardless of:scope.:multikeyword dispatch usesget— a keyword:dispatchis looked up with(get value k)(nil on non-associative values →:balli.core/invalid-dispatch-value); it is not invoked as an arbitrary ifn. Fn dispatch must be Python-callable.ifn?usesifn-like?— real fns plus keywords, maps, and sets count as ifn-like; vectors and symbols are rejected even though Python/Basilisp callability is broader.- Provider tuple/enum inference is opt-in — pass
{:infer-tuples true}or{:infer-enums true}for narrower hints. Mixed int/float samples infer[:or :int :double](Basilispfloat?rejects ints, so no single scalar type covers both). :?unparse is stricter — the child unparser is tried first; only a nil the child rejects unparses to[], and shape mismatches return:balli.core/invalidrather than best-effort output.- JSON Schema ref names are JSON-Pointer-escaped in
$refstrings (:tree/node→"#/definitions/tree~1node") with unescaped"definitions"keys; local-registry shadow collisions get__2,__3, ... suffixes; seqex types export as lossy{"type" "array"}. - Set element
:inuses the element's seq-order ordinal (sets are unordered; the index identifies which element in seq order failed, not a stable position). - Mixed-level humanize uses
:balli/error— when a value has errors of its own and nested child errors (e.g.[:vector {:min 3} :int]on[1 "x"]),humanizerenders that level as a map with child entries plus the level's own messages under the reserved:balli/errorkey:{1 ["should be an integer"] :balli/error ["should have at least 3 elements"]}.
Development
# quick confidence suite for fresh clones
scripts/user_suite.sh # or: basilisp test tests/test_user_suite.lpy
# run the test suite (pytest via basilisp)
basilisp test # or: scripts/test.sh
# lint = compile-check every namespace under src/
basilisp run scripts/compile_check.lpy
tests/test_user_suite.lpy is a small public-surface smoke suite intended for
users validating a clone quickly. basilisp test is the comprehensive developer
suite. There is no clj-kondo for Basilisp; the compile-check script (which
imports every src/**/*.lpy namespace and fails on any compile error) is the
lint step.
License
MIT © 2026 Andrew VanDyke
Project details
Release history Release notifications | RSS feed
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 balli-0.6.0.tar.gz.
File metadata
- Download URL: balli-0.6.0.tar.gz
- Upload date:
- Size: 127.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c7786bdf520e6ebfcfc1601c564cafb526a166ecd3ea90412dd28fe67c592904
|
|
| MD5 |
03eeb9030c22b1c845dc77638be4b50f
|
|
| BLAKE2b-256 |
77ff23ebd2eafe72e8de2b755d26771b1d11445457adcec932a277d6f2147348
|
Provenance
The following attestation bundles were made for balli-0.6.0.tar.gz:
Publisher:
release.yml on vandyand/balli
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
balli-0.6.0.tar.gz -
Subject digest:
c7786bdf520e6ebfcfc1601c564cafb526a166ecd3ea90412dd28fe67c592904 - Sigstore transparency entry: 2102647396
- Sigstore integration time:
-
Permalink:
vandyand/balli@05db0112b8cfe69f163d563cbe0bf8535e5c365a -
Branch / Tag:
refs/heads/main - Owner: https://github.com/vandyand
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@05db0112b8cfe69f163d563cbe0bf8535e5c365a -
Trigger Event:
workflow_dispatch
-
Statement type:
File details
Details for the file balli-0.6.0-py3-none-any.whl.
File metadata
- Download URL: balli-0.6.0-py3-none-any.whl
- Upload date:
- Size: 109.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
938171af100c7061c9b73bd84fb8272799f2a0db98bdccd1a3c2368b67c6cf5e
|
|
| MD5 |
9d57c33e4b6d95e41a789a86a5bd1f5e
|
|
| BLAKE2b-256 |
872435ee00402dc491ff30c11d389efc617d83f2780327fca4aeb7f85172d441
|
Provenance
The following attestation bundles were made for balli-0.6.0-py3-none-any.whl:
Publisher:
release.yml on vandyand/balli
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
balli-0.6.0-py3-none-any.whl -
Subject digest:
938171af100c7061c9b73bd84fb8272799f2a0db98bdccd1a3c2368b67c6cf5e - Sigstore transparency entry: 2102647640
- Sigstore integration time:
-
Permalink:
vandyand/balli@05db0112b8cfe69f163d563cbe0bf8535e5c365a -
Branch / Tag:
refs/heads/main - Owner: https://github.com/vandyand
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@05db0112b8cfe69f163d563cbe0bf8535e5c365a -
Trigger Event:
workflow_dispatch
-
Statement type: