Pagda

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

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

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:

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

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

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

Classes

Functions

Conditions and proofs

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

Missing from checked files

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:

AgdaPython
Vec Nat 2Vec[Nat, 2]
suc nn + 1
zero0
pos 3, negsuc 2the ints 3 and -3
Int, String, Bool, List Aint, str, bool, list[A]
A × B, (a , b)tuple[A, B], (a, b)
x ∷ xs, [][x, *xs], []
xs ++ ysxs + ys
a +ᵢ b, a <ᵢ ba + b and a < b on ints
x ≡ yx == 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 | truef(x) in the branch where its if test was true
Dec₀ cDec[c]
Exc A, ok x, err ethe result of a def that can raise: a value, or an exception
exn "ValueError" "bad"ValueError("bad")
Maybe A, just x, nothingOptional[A], x, None
⊤, ttNone as a return type, and the value a def that returns None gives
natMinus a b pa - b on Nats
index xs i p, setAt xs i v pxs[i], xs[i] = v
SetType
⊥Empty
new-Point 1 2, Point.x p, Point-norm pPoint(1, 2), p.x, p.norm()
x₁a second variable that was also called x
_n_14a value Agda has not worked out yet