Language reference
Everything a checked file can hold. This page is built from the project's README, so the two always agree.
The first lines
from __future__ import annotations, then from dependent import .... After that a checked file may import names from other checked files: from shapes import Point, width. It has no other imports.
A program
def main() -> None:
stock = [Item("pen", 3, 10), Item("ink", 12, 2)]
for item in stock:
print(f"{item.name}: {item.price}")
if __name__ == "__main__":
main()
A def can return None, call other defs for what they do, and print. What print writes is not part of what the checker reasons about. Its arguments are checked like any other expression.
Numbers, strings and booleans
Natis 0, 1, 2 and so on. It has+ * // % **and the comparisons.if n == 0:splits into the zero case and the other case, and in the other casen - 1is allowed. Elsewherea - bneeds the checker to see thatbis not larger thana(see Operations that can fail).monus(a, b)is subtraction that stops at 0.intis any whole number. It has+ - * // % **, unary minus,abs,min,maxand the comparisons.//and%round the way Python does.- A literal like
3is aNatunless the place it stands in needs anint. ANatused where anintis wanted is converted. Going the other way is writtento_nat(x), which gives 0 for a negativex, orabs(x). - Locals follow their use. A local that is given an int anywhere in a function is an int from its first assignment, so
s = 0followed bys += xworks for an intx.xs = [1, 2]is a list of ints whenxsis later passed where alist[int]is wanted.best = Nonefollowed bybest = xmakesbestan Optional. strhas+,*by a number,==,<,in,len,s[i], slices, looping over the characters,str(n), f-strings, and the methodsstartswith,endswith,join,split(sep),replace(old, new),strip,lstripandrstrip.splitandreplacetake the text to look for written out, and not empty.- f-strings hold
str,Nat,intandboolvalues. Insideprint(...)they can hold anything, and can use format specs. boolhasand,or,not. In a condition, a list, dict or string counts as true when it is not empty, and a number when it is not 0.- There are no floats, so
/is refused.
None and Optional
Optional[T] (or T | None) is a value that is a T or None. A plain value is accepted where an Optional is wanted. Going the other way needs a test:
def price_of(items: list[Item], name: str) -> int:
item = find(items, name) # Optional[Item]
if item is None:
return -1
return item.price # here item is an Item
d.get(k) gives an Optional. T has to be a type the checker knows, and not a name in square brackets: Python cannot tell "None" from "a None inside", so an Optional of an Optional would not mean the same in both languages.
Lists, tuples and dicts
list[A]:[1, 2],xs + ys,len(xs),x in xs,xs[i],xs[-1],xs[a:b](ends may be negative), comprehensions,sum,any,all,sorted(ofNatorint),list(...), andmatch xs:withcase []:andcase [first, *rest]:.tuple[A, B]:(a, b),a, b = t,a, b = b, a,t[0], andcase (a, b):. Two or more parts.dict[K, V]:{k: v},d[k],k in d,d.get(k),d.get(k, default),len(d), and looping overd,d.items(),d.keys()ord.values(). Keys keep the order they were first added. A key is aNat,int,bool,str, or a tuple of those: the kinds Python can use as keys.d.get(k)needs values that are neverNone, since it answersNonefor a missing key.range,enumerate,zip,reversedand the dict views can stand afterinof a loop or a comprehension, and insidelist(...). On their own they are not lists, and Python would not call them equal to one, so they are refused as plain values. For the same reason two dicts cannot be compared with==.
Operations that can fail
xs[i] fails when i is past the end, xs[-1] and xs.pop() when the list is empty, d[k] when the key is missing, // and % when the divisor is 0, and a - b on Nats has no answer when b is larger. Agda has no failing, so each of these needs a reason it is fine. The checker finds the reason by itself when:
- the code is under a test of the condition:
if i < len(xs):,if xs:,if k in d:,if b != 0:,if b <= a:. The test can be anif, awhile, or the test ofx if c else y; - an earlier part of the same
andtests it, as inwhile i < len(xs) and xs[i] != t:, or an earlier part of anorrules the bad case out, as innot xs or xs[0] < 10; - the loop is
for i in range(len(xs)):and the body reads or writesxs[i]; - a parameter states it:
def at(xs: list[int], i: Nat, ok: (i < len(xs)) == True) -> int:. The caller passesknown(i < len(xs))from inside its own test; - an
assertstates it, where Agda can work the assert out; - Agda can work the condition out, as in
[1, 2, 3][1]orn // 2.
An int used as an index needs both ends: if 0 <= i < len(xs):. Otherwise the line is refused with a message that lists what such lines need.
Changing a list or dict
xs.append(x), xs.extend(ys), xs.insert(i, x), x = xs.pop(), xs += ys, xs[i] = v, xs[i], xs[j] = xs[j], xs[i], xs.sort(), xs.reverse(), xs.clear() and d[k] = v are allowed on a local name. In the Agda text the name simply gets a new value.
The Python still runs as Python, where these change the object itself, and every other holder of that object sees the change. Agda's version and Python's agree only when there is no other holder. So the checker allows a change only on a list or dict that the function made itself (a literal, a comprehension, list(xs), xs.copy(), a slice, a + b, sorted(...)) and has not since given another name, passed to a function, put inside another value, or used in a lambda or inner def. A parameter, or the result of a call, has to be copied first: xs = list(xs). A loop cannot change the list it is running over.
Loops
forruns over a list, a dict, a string,range(...)and the like.break,continueandreturnwork inside it. The names the body assigns must have been assigned before the loop, and each keeps one type through the loop.- The loop variable is gone after the loop. To keep its last value, give it to another name inside the loop.
whileneeds a reason to end. The checker sees it by itself for the two commonest shapes, also when the test has more parts joined byand:
i = 0
while i < len(xs): # a Nat counted up to a limit the loop does not change
...
i += 1 # the last statement
while k > 0: # an int counted down to 0
...
k -= 1
- Any other
whileends withdecreases(measure, proof). The measure is aNatworked out from the loop's variables. The proof shows(measure at the end of a pass < measure at the start) == True.lemmashas two ready ones,monus_stepanddown_step. - A
forloop at the top level may holdprintstatements and furtherforloops. - In the Agda text a loop is one expression that folds the body over the items. So
ifinside a loop does not change what the types know (see Conditions), andcase _: return unreachable()is not available there. A property of a loop's result is proved by writing the loop as a recursive function instead.
Exceptions
class ParseError(Exception): ...
def parse(text: str) -> int:
if not text:
raise ParseError("nothing to parse")
...
def parse_or(text: str, default: int) -> int:
try:
n = parse(text)
except ParseError as e:
print("bad input:", e)
return default
except Exception:
return default
return n
raisetakes an exception class and at most a message:raise ValueError("bad"). The built-in classes areException,ValueError,TypeError,RuntimeError,IndexError,ZeroDivisionError,ArithmeticError,LookupErrorandNotImplementedError. A file adds its own withclass MyError(Exception): ..., and one of those can be the base of another.try/except/elsework as in Python: the first clause that names the exception's class, or a class it is built on, handles it.except Exception:and a bareexcept:catch everything. A bareraisein an except block raises the same exception again.- In an except block,
str(e)andf"{e}"give the message, andprint(e)prints it. The name is gone after the block, as in Python. - A call to a def that can raise stands on its own: as a statement, as the whole right side of an assignment, or as the whole value of a
return.return parse(a) + 1is refused; writen = parse(a)and thenreturn n + 1. - A def that calls one that can raise can itself raise, unless the call is inside a
trywhose clauses include one that catches everything. The checker cannot see that a list of named classes covers all that a called def raises. - An exception that nothing catches ends the program, as in Python. That is allowed for a call that stands alone at the top level, such as
main(). - Only
raiseraises.xs[i],d[k],//and the rest still need a reason they cannot fail, and never raise in a checked file. - Not available yet:
finally,raise ... from ..., an exception with more than one argument,raiseinside a def that is inside a function, and a loop inside atrywhose body can raise (move the loop into its own def). - In the Agda text a def that can raise gives back either its value or the exception. From a
tryon, the rest of the def is one expression, so anifafter it does not change what the types know.
Classes
- A class with fields is written with
@dataclass. Fields arename: typelines, and a field's type can mention the fields before it, so a field can hold a proof about another field. It can have methods, whose first parameter isself.Point(1, 2),Point(x=1, y=2),p.x,p.norm(),case Point(a, b):and (when every field can be compared)==all work. Fields cannot be assigned to:replace(p, x=3)gives a new value with that field changed. - A family of types is
class Name[params](Data): .... Each constructor is adefmarked@constructorwith...as its body. Its parameters are the fields, and its return annotation says which member of the family it builds. - Names in square brackets (
def f[A, n: Nat],class Box[A]) exist only for the checker. They can appear in annotations and nowhere else, because they are gone when the program runs. A name without a: boundis a type.
Functions
- Every parameter and the result must be annotated. Every path must return, unless the def returns
None. - Default values are numbers, strings,
True,FalseorNone, written out. Arguments can be passed by name. - A def inside a function can use the names around it. It cannot call itself, and it cannot change a list or dict of the outer function in place.
- A lambda or inner def made inside a loop cannot use a name that the loop sets. In Python such a function sees the name's value at the time it is called, which is usually the last one. Pass the value in as an argument.
- Function types:
Fn[A, B, C]means the same asCallable[[A, B], C]: the last item is the result. An argument can be given a name, and the types after it can then use it.Fn[k: Nat, Vec[A, k]]takes a numberkand gives aVecof that length. The named form is for annotations only. - Inside a lambda whose argument types the checker cannot see, numbers are taken to be
Nat. Use adeffor a function on ints that is passed to a generic function.
Conditions and proofs
- In an annotation,
a == bis the type of proofs thataequalsb.refl()proves a thing equals itself.sym,trans,congandsubstcombine proofs, andmatch p: case Refl():uses one. - Inside
if c:(outside a loop) the checker has replacedcbyTruein the types it still has to check, and byFalsein theelsepart. So a proof about a function that testsa < bcan testa < bitself and finish each branch separately.if a and b:andif a < b < c:count as several tests, one inside the other. known(c)insideif c:orwhile c:is the proof thatcis true. In theelsepart,known(not c)is the proof for the other way.match decide(c):withcase Yes(p):andcase No(p):does the same job and names the proof.assert cinside a function has to be a condition the checker can see holds. After it, the rest of the function may rely on it.- Impossible cases:
case _: return unreachable()is accepted only when Agda can see that no value reaches it. lemmasis a built-in checked file of ready-made proofs:plus_zero,plus_suc,plus_comm,plus_assoc,times_zero,times_one,lt_suc,lt_irrefl,lt_asym,lt_trans,monus_step,down_step,append_nil,append_assoc,len_append,false_is_not_true,true_is_not_false. Import the ones needed by name.
Holes
hole() stands for a part that is not written yet. The checker checks the rest of the file, then lists each hole with the type it needs and the names in scope there. A file with a hole does not run.
TypeCheckError: this file has 1 hole, so it was not run.
line 8: hole() needs a value of type
n₁ + 0 ≡ n₁
with these names to build it from:
n₁ : Nat
Calling unchecked Python
A checked file cannot import an ordinary Python file. It can hold a def marked @trusted. The checker reads its annotations and nothing else, so the body can be any Python, including imports, loops over anything, and exceptions.
@trusted
def file_size(path: str) -> Nat:
import os
return os.path.getsize(path)
Four things are taken on trust: that the def returns a value of the type it says, that the same arguments always give the same result, that it does not change the lists and dicts it is given, and that it does not raise. Agda treats it as a function, so assert f(x) == f(x) passes the check whatever f does.
The top level
name: T = value, calls, print(...), assert, classes, defs, and for loops that print. An assert is checked by Agda before the program runs. A final if __name__ == "__main__": block may hold the same statements. from dependent import * brings in every name of the module, including Fn, Dec, Yes, No, decide, known, hole, dataclass, replace, trusted and Optional. A file that defines its own Yes has to import the names it uses one by one.
What the check covers, and what it does not
- Covered: if a checked file is accepted and run as the main program, every call inside it gets arguments of the annotated types, every
matchfinds a case, every recursion and loop ends, no index, key, pop, division or subtraction fails, noNoneis used as a value, everyassertholds, and the only exceptions are the ones the file raises withraise. @trusteddefs: the guarantee holds only as far as each one keeps its four promises.- Callers outside: an ordinary Python file can import a checked file and call it with anything. Annotations are not checked while the program runs. An outside caller can also keep a list it passed in and change it later.
- Deep recursion: Agda proves that a recursion ends. The interpreter still stops at 1000 nested calls with
RecursionError. A checked file cannot importsys, so raise the limit from an ordinary file that imports the checked one. - Large numbers in asserts: Agda works an
assertout itself, and it countsNatvalues one step at a time in proofs. An assert over large numbers can be slow. - The translation: the guarantee rests on
src/agda.cgiving each construct the same meaning the interpreter does, and onlib/dependent.pymatchinglib/PagdaPrelude.agda. The tests compare the two on edge values throughassert, which Agda and Python each work out.pagdaalways loads its built-independent, so adependent.pyin the script's folder is ignored.
Missing from checked files
- Floats,
bytes, sets, and string methods beyond the ones listed (noupper,lower,format,int("12")). finally,withblocks, and anything that touches the outside world exceptprint. Use a@trusteddef.- Assigning to a field, inheritance between
@dataclassclasses, field defaults, and methods named__x__(Python calls those by itself). - Changing a list that sits inside another list (
grid[i][j] = v), and changing a list or dict that might be shared. list.remove,dict.pop,del, slices with a step, tuples of one part,rangewith a step.- Loop state whose type changes from pass to pass, and
elseon a loop. - A def inside a function that calls itself, or has more than 4 parameters.
- Explicit type arguments at a call, as in
ident[Nat](3). The checker always works them out. - A constructor field that holds a type. Agda reports that
Set₁does not fit intoSet. - Function types with more than 4 arguments.
case ... if, mapping patterns, and keyword patterns.- Two checked files that import each other.
Each of these is refused with a message that names the Python line.
Reading the error messages
Type errors come from Agda, with positions mapped back to Python lines. Agda writes some things differently:
| Agda | Python |
|---|---|
Vec Nat 2 | Vec[Nat, 2] |
suc n | n + 1 |
zero | 0 |
pos 3, negsuc 2 | the ints 3 and -3 |
Int, String, Bool, List A | int, str, bool, list[A] |
A × B, (a , b) | tuple[A, B], (a, b) |
x ∷ xs, [] | [x, *xs], [] |
xs ++ ys | xs + ys |
a +ᵢ b, a <ᵢ b | a + b and a < b on ints |
x ≡ y | x == y in an annotation, or Eq[x, y] |
Fun₂ A (λ _ → B) (λ _ _ → C) | Callable[[A, B], C] |
Fun₂ A (λ x → B) (λ x y → C) | Fn[x: A, y: B, C] |
f x | true | f(x) in the branch where its if test was true |
Dec₀ c | Dec[c] |
Exc A, ok x, err e | the result of a def that can raise: a value, or an exception |
exn "ValueError" "bad" | ValueError("bad") |
Maybe A, just x, nothing | Optional[A], x, None |
⊤, tt | None as a return type, and the value a def that returns None gives |
natMinus a b p | a - b on Nats |
index xs i p, setAt xs i v p | xs[i], xs[i] = v |
Set | Type |
⊥ | Empty |
new-Point 1 2, Point.x p, Point-norm p | Point(1, 2), p.x, p.norm() |
x₁ | a second variable that was also called x |
_n_14 | a value Agda has not worked out yet |