A Python ORM interface for building and solving clingo ASP programs
Project description
ASPAlchemy
ASPAlchemy is a Python ORM for clingo: you author Answer Set Programming rules as typed Python objects, it renders them to ASP source, solves through the clingo API, and hands the answer sets back as typed Python values.
Write Clingo using Python
Your rules are Python objects. Predicates are classes with typed fields; atoms are instances; rules are built from both. A misspelled field or a missing argument is a type error before clingo ever runs, and the pieces you use over and over live in ordinary Python variables instead of being typed out again each time.
The code reads as English. program.when(...).derive(...). program.forbid(...). Choice(...).exactly(1). The example below can be read aloud, roughly correctly, by someone who has never seen ASP.
Your program is dynamic. This is the headline, and the second example below demonstrates exactly what we mean. A .lp file is frozen text; ASP written in Python has if statements, loops, functions, and imports, so the rules you emit reshape themselves around the input data and configuration in ways a static program never can. This is what SQLAlchemy did for SQL — hence the name.
You never need to see the clingo code. And just as with SQLAlchemy, the generated language becomes an implementation detail: the whole loop — express the rules, solve, consume the answers — happens in typed Python. The ASP is always there when you want it (render() prints it; the example below shows one), but you can go from problem to solution without reading a line of it.
It emits correct clingo. Code that clingo would reject — unsafe variables, malformed rules, syntax errors — is refused as you build it, at the Python line at fault. The net is deliberately one-sided: every refusal is a certain clingo rejection, and the few shapes only grounding can judge (arithmetic gringo can't invert, a #const bound whose value only grounding knows) still fail loudly at solve, mapped back to your source. And much of that is enforced statically: the type system encodes what may appear where, so autocomplete and your type checker tell you what's permitted before anything runs at all.
It's opinionated, with guardrails. ASPAlchemy deliberately does not admit everything gringo does. Constructs that are nearly always a mistake — singleton variables, negated heads, comparisons against pools — are refused, and every refusal says what you probably meant instead. When you genuinely need the full language, raw_asp() accepts verbatim clingo (with a predicates= seatbelt so its atoms still round-trip typed).
Every exception is a teaching moment. Rules are validated as they're constructed, so errors land on the Python line at fault and explain the fix. For problems only clingo can catch, every generated ASP line carries a reverse map to the Python that authored it: grounding diagnostics come back annotated "generated by yourfile.py:42", and render(annotate=True) prints the full mapping.
Solutions are first-class. Iterate models lazily; run brave/cautious consequence analyses; solve prioritized optimization problems via optimize(); read atoms back as typed objects with plain int/str fields. And when a program grounds big or slow, ground().analyze_grounding() shows exactly which rules blew up.
And it's tested against reality. The test suite holds a hard 100% line-coverage gate, and claims about clingo behavior are never taken on faith from documentation — they're pinned by tests that run gringo and clasp and assert on what actually comes back. Even corners like gringo's arithmetic binding order — which as far as we can tell is documented nowhere — have been nailed down precisely, with the receipts executable in CI.
Example
Color a map so that no two neighbours have the same color:
from aspalchemy import ANY, ASPProgram, Choice, Field, Predicate, Variable
class Edge(Predicate, show=False):
"""States a and b share a border."""
a: Field[str]
b: Field[str]
class Color(Predicate, show=False):
"""A color available to paint with."""
color: Field[str]
class Node(Predicate, show=False):
"""A state on the map, derived from the border list."""
name: Field[str]
class ColoredNode(Predicate):
"""The color assigned to a state — the solution we read back."""
name: Field[str]
color: Field[str]
program = ASPProgram()
# Define the ASP variables our rules will use
N, C, A, B = (Variable(v) for v in "NCAB")
# The map: six states and their borders
borders = [
("washington", "oregon"), ("washington", "idaho"),
("oregon", "idaho"), ("oregon", "california"), ("oregon", "nevada"),
("idaho", "nevada"), ("idaho", "utah"),
("california", "nevada"), ("nevada", "utah"),
]
program.fact(*(Edge(a=a, b=b) for a, b in borders))
# Three colors to paint with
colors = ["red", "green", "blue"]
program.fact(*(Color(color=c) for c in colors))
# A state is anything that appears on either side of a border
program.when(Edge(a=N, b=ANY)).derive(Node(name=N))
program.when(Edge(a=ANY, b=N)).derive(Node(name=N))
# Every state picks exactly one color
program.when(Node(name=N)).derive(Choice(ColoredNode(name=N, color=C), condition=Color(color=C)).exactly(1))
# Bordering states never share a color
program.forbid(Edge(a=A, b=B), ColoredNode(name=A, color=C), ColoredNode(name=B, color=C))
model = program.solve().first()
for atom in sorted(model.atoms(ColoredNode), key=lambda c: c.name):
print(f"{atom.name} -> {atom.color}") # .name and .color are plain strs
california -> red
idaho -> red
nevada -> blue
oregon -> green
utah -> green
washington -> blue
program.render() shows exactly the clingo program you would have written by hand:
>>> print(program.render())
% Generated by aspalchemy ...
edge("washington", "oregon").
edge("washington", "idaho").
edge("oregon", "idaho").
edge("oregon", "california").
edge("oregon", "nevada").
edge("idaho", "nevada").
edge("idaho", "utah").
edge("california", "nevada").
edge("nevada", "utah").
color("red").
color("green").
color("blue").
node(N) :- edge(N, _).
node(N) :- edge(_, N).
{ colored_node(N, C) : color(C) } = 1 :- node(N).
:- edge(A, B), colored_node(A, C), colored_node(B, C).
#show.
#show colored_node/2.
Your program is dynamic
That example is static: you could have typed it into a .lp file. Here is what you couldn't. A puzzle on a rectangular board and one on a hex board are not the same shape of problem — a cell is addressed differently, and "neighbour" means something different — so let the geometry decide both the predicate schema and the rules:
from aspalchemy import ASPProgram, Predicate, Variable
COORDS = {"rect": ["row", "col"], "hex": ["x", "y", "z"]}
NEIGHBOURS = {"rect": [(0, 1), (1, 0)], "hex": [(1, -1, 0), (1, 0, -1), (0, 1, -1)]}
def step(axis, delta): # an ASP term: Col, or Col + 1, or Col - 1
return axis if delta == 0 else axis + delta
def adjacency(program, geometry):
"""Emit adjacency rules in whichever coordinate system this puzzle uses."""
coords, offsets = COORDS[geometry], NEIGHBOURS[geometry]
Cell = Predicate.define("cell", {c: int for c in coords}, show=False) # the schema...
Adjacent = Predicate.define("adjacent", {"a": Cell, "b": Cell})
axes = [Variable(c.capitalize()) for c in coords]
here = Cell(**dict(zip(coords, axes, strict=True)))
for offset in offsets: # ...and the rules
there = Cell(**{c: step(a, d) for c, a, d in zip(coords, axes, offset, strict=True)})
program.when(here, there).derive(Adjacent(a=here, b=there))
The same call, on two geometries, builds two structurally different ASP programs:
>>> rect = ASPProgram()
>>> adjacency(rect, "rect")
>>> print(rect["Rules"].render())
adjacent(cell(Row, Col), cell(Row, Col + 1)) :- cell(Row, Col), cell(Row, Col + 1).
adjacent(cell(Row, Col), cell(Row + 1, Col)) :- cell(Row, Col), cell(Row + 1, Col).
>>> hexgrid = ASPProgram()
>>> adjacency(hexgrid, "hex")
>>> print(hexgrid["Rules"].render())
adjacent(cell(X, Y, Z), cell(X + 1, Y - 1, Z)) :- cell(X, Y, Z), cell(X + 1, Y - 1, Z).
adjacent(cell(X, Y, Z), cell(X + 1, Y, Z - 1)) :- cell(X, Y, Z), cell(X + 1, Y, Z - 1).
adjacent(cell(X, Y, Z), cell(X, Y + 1, Z - 1)) :- cell(X, Y, Z), cell(X, Y + 1, Z - 1).
A cell is cell(Row, Col) in one and cell(X, Y, Z) in the other — different arity, different names, different rules, from one call site. A module can hold a library of grid rules and emit only the ones a given puzzle asks for; nothing it doesn't ask for is ever grounded. To do this with a .lp file you would be templating strings, untyped and unvalidated. This is what ASPAlchemy is for, and it is why aspuzzle exists.
There is an honest cost: a schema invented at runtime is invisible to your type checker, so Predicate.define() trades the static half of the guarantee for the flexibility (everything is still validated at runtime, and you make the choice per predicate). The full story is in the docs.
Why not clorm?
Different job. clorm types the data boundary — facts in, models out, with a relational query layer over solutions. ASPAlchemy types the program itself: the rules are Python objects, validated as you build them, which clorm by design leaves as ASP text. Solution handling here is deliberately minimal (typed atoms, loud failures); if you want to query solutions relationally while writing your rules in ASP, clorm is the right tool. Note that both tools can be used together: ASPAlchemy to define the program, clorm to handle the data.
Installation
pip install aspalchemy # or: uv add aspalchemy
Requires Python 3.14+ (clingo is installed automatically).
Documentation
For examples of ASPAlchemy at work solving logic puzzles, see aspuzzle, a puzzle-solving framework built on top of it.
License
MIT. Copyright Jolyon Bloomfield 2025-2026.
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 aspalchemy-1.4.0.tar.gz.
File metadata
- Download URL: aspalchemy-1.4.0.tar.gz
- Upload date:
- Size: 121.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.28 {"installer":{"name":"uv","version":"0.11.28","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
04842b65790740ad74fbb58896c594e9d7b203fc02a60852359edb5ef25dbd60
|
|
| MD5 |
d07b090a03243adf546eb3fbf4053bd4
|
|
| BLAKE2b-256 |
8c944567599e6df667dcbf48b6490317cb74f7e974a51bfea6f607b0458ca87a
|
File details
Details for the file aspalchemy-1.4.0-py3-none-any.whl.
File metadata
- Download URL: aspalchemy-1.4.0-py3-none-any.whl
- Upload date:
- Size: 132.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.28 {"installer":{"name":"uv","version":"0.11.28","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9f69c95359349f9574588e4274fa27ea8ac5bb6b39251be9fab80365120d0e0f
|
|
| MD5 |
130c8b475ef9f89ea6be6a64356a15bf
|
|
| BLAKE2b-256 |
9a105863e3b2e29199851033a2aaceb4dcdc864a80eee74da77d37501f8e90bc
|