Dragon

Migrating from Python

If you write Python, you already read most of Dragon. The syntax is deliberately familiar: f-strings, comprehensions, for x in xs, the same four collection types with the same method names, indentation in .py mode. That familiarity is the on-ramp. But Dragon is inspired by Python - it is not a superset and not a dialect. It is a typed, compiled language that targets LLVM and produces a native binary.

Set expectations honestly. You cannot take an arbitrary Python

program - numpy, pandas, Flask, Django, anything reaching for the

CPython C API - and compile it with Dragon. Those do not work, and

they never will. What works is a typed file that stays inside the

subset Dragon implements, plus a batteries-included

standard library that mirrors the Python module

shapes Dragon ships.

This appendix walks through what carries over unchanged, what you adjust slightly, and the handful of places where the mental model genuinely differs. Read it once and you will stop being surprised.

Two file modes, one language

Dragon reads two surface syntaxes. Pick by file extension:

.dr.py
Blockscurly braces { }indentation
Type annotationsmandatorymandatory (PEP-484, enforced)
Constructordef() { ... }def __init__(self, ...)
self in methodsimplicitexplicit, as in Python
Audiencenew Dragon codeporting an existing typed file

The two modes compile to identical semantics - block scoping, the :-declares rule, fixed types. The only differences are the syntactic ones in the table above. .py mode is the adoption ramp: it lets you bring an individually typed Python file across with minimal edits. It is not a promise that any .py file runs.

Here is the same program in both modes.

# greet.py - .py mode: indentation, explicit self, PEP-484 types
class Point:
    def __init__(self, x: int, y: int) -> None:
        self.x = x
        self.y = y

    def dist(self) -> int:
        return self.x + self.y

p: Point = Point(3, 4)
print(p.dist())  # 7
# greet.dr - .dr mode: braces, def() constructor, implicit self
class Point {
    x: int
    y: int
    def(x: int, y: int) {
        self.x = x
        self.y = y
    }
    def dist() -> int {
        return self.x + self.y
    }
}
p: Point = Point(3, 4)
print(p.dist())  # 7

The enforcer is not optional. In .py mode, a missing parameter

or return annotation is a compile error - `missing type annotation for

parameter 'name'`. Python's gradual typing is gone; types are the

contract. See Type Annotations.

: declares, = reassigns

This is the single rule that catches the most Python bugs, so internalize it first. A name enters a scope exactly once, with an annotation:

total: int = 0   # declares - introduces the name with its type
total = 10        # reassigns - the name must already exist

A bare = to a name that was never declared is an error, not a new variable:

# Python - a typo silently creates a brand-new variable
totl = 0
total = total + 1   # NameError at runtime, if you're lucky
# Dragon - caught at compile time
totl: int = 0
total = total + 1
# ERROR: 'total' is not declared; introduce it with 'total: <type> = ...'
#        (bare '=' only reassigns an existing variable)

That classic Python footgun - a misspelled assignment target quietly spawning a fresh local - is impossible here. The compiler knows every declaration site because every declaration site uses :.

A variable's type is fixed at its declaration

In Python a name can be an int on line 1 and a str on line 5. In Dragon the type is locked when you declare it. Reassigning an incompatible type is an error:

# Python - perfectly legal, occasionally a disaster
n = 5
n = "hello"   # n is now a str
# Dragon
n: int = 5
n = "hello"
# ERROR: cannot assign 'str' to 'n' of type 'int'
#        (a variable's type is fixed at its declaration)

This is not pedantry - it is what lets Dragon flow every value at its native machine type with no boxing. An int is an i64 from declaration to last use.

Block scoping, not function scoping

Python scopes by function: a name bound inside an if or for body leaks out to the rest of the function. Dragon scopes by block: every { } (or every indented suite in .py mode) is its own scope, and names declared inside it vanish at the closing brace.

# Python - z survives the if block
if True:
    z = 99
print(z)   # 99
# Dragon - z is local to the if block
if true {
    z: int = 99
}
print(z)
# ERROR: undefined name 'z'

If you need a value after the block, declare it before:

z: int = 0
if true {
    z = 99   # reassign the outer z, don't redeclare
}
print(z)     # 99

This applies to for, while, with, try/except, and match cases alike. It is a deliberate, mode-independent choice - the same in .dr and .py - and it lets the compiler free heap locals at block exit with no garbage-collection cost. See Variables for the full scoping rules.

No magic main(), no if __name__ == "__main__"

Python files lead a double life: imported as a module and runnable as a script, with the __name__ guard deciding which. Dragon has no such guard, because it has no ambiguity to resolve.

The file you run is the program. Its top-level statements execute

top to bottom. Every other file is an import that only contributes

definitions.

# Python - the dual-identity dance
def main():
    print("running")

if __name__ == "__main__":
    main()
# Dragon - top-level code is the program body
def work() -> None {
    print("running")
}
work()   # it runs only because you called it here

There is no privileged function name. A def main() is an ordinary function; defining it without calling it produces no output. Imported modules' top-level code runs once on import; the entry file's top-level code is the program. See Modules.

A static binary, not an interpreter

dragon build app.dr produces one native executable. There is no interpreter shipping alongside it, and that has consequences you should plan for:

PythonDragon
eval / exec of runtime stringsnot available - there is no interpreter
C extension modules (.so via CPython API)not available - use FFI for C
pip install from PyPInot available - the stdlib is statically linked in
import numpy / pandas / Flasknot available, by design
__import__, monkeypatching, runtime reflectionmostly absent - the program is fixed at compile time

In exchange you get a single self-contained binary with no runtime dependency, no virtualenv, and a standard library - os, io, re, json, http.server, hashlib, datetime, math, the collections family, and more - that is compiled directly into your executable. Missing batteries are added to the language, not fetched from a registry.

Dynamic Python, statically

Python leans on runtime dynamism: type(x) dispatch, getattr/setattr, metaclasses, monkeypatching, @classmethod constructors that build whatever subclass cls turns out to be. Dragon drops all of it - not to be austere, but because a static type system expresses the same intent at compile time, with no runtime cost. These patterns don't fail mysteriously at runtime; the compiler rejects them up front, and the idiom below is what you reach for instead.

Rule of thumb. If a Python pattern asks *"what type is this at

runtime?"*, the answer is already in Dragon's static type - reach for

isinstance narrowing, a base class with virtual dispatch, or a

generic. If it asks *"let me poke at this object's attributes by string

name"*, there is no Dragon equivalent by design; model the data

explicitly.

A method that yields - but drop @classmethod

yield works in methods, instance and @staticmethod alike, so a generator method is perfectly ordinary:

class Squares {
    @staticmethod
    def of(n: int) {
        k: int = 0
        while k < n {
            yield k * k
            k = k + 1
        }
    }
}
total: int = 0
for v in Squares.of(4) {
    total = total + v   # 0 + 1 + 4 + 9
}
print(total)   # 14

What Dragon does not support is a @classmethod generator. In Python the whole point of @classmethod is the late-bound cls - the body can do cls(...) to construct whatever subclass it was called on, decided at runtime. Dragon resolves the class statically, so cls carries no information the type system doesn't already have: a @classmethod generator would be a @staticmethod generator wearing a costume. Write the @staticmethod form above, a plain generator function, or - when the yielded type genuinely varies by caller - a generic:

def take[T](xs: list[T], n: int) -> list[T] {
    out: list[T] = []
    i: int = 0
    while i < n {
        out.append(xs[i])
        i = i + 1
    }
    return out
}
first_two: list[int] = take([10, 20, 30, 40], 2)
print(first_two)   # [10, 20]

type(x) dispatch → isinstance narrowing

Python branches on type(x) or isinstance and trusts duck typing for the rest. Dragon keeps isinstance, and inside the branch it narrows the static type - the right methods and fields become available with no cast:

# Python
def describe(x):
    if isinstance(x, int):
        return f"doubled: {x * 2}"
    return f"upper: {x.upper()}"
# Dragon - each branch narrows the union member
def describe(x: int | str) -> str {
    if isinstance(x, int) {
        return f"doubled: {x * 2}"
    }
    if isinstance(x, str) {
        return f"upper: {x.upper()}"
    }
    return "?"
}
print(describe(21))     # doubled: 42
print(describe("hi"))   # upper: HI

There is no type(x) handing back a class object to compare against or construct from - the union member is the type, and isinstance is how you ask which one it is. See Type Annotations.

Duck typing → a base class + virtual dispatch

"Anything with an .area()" becomes a declared base class. A list of the base type dispatches to each subclass's override through a vtable (and the call is devirtualized to a direct call when the whole program defines no override):

class Shape {
    def area() -> int { return 0 }
}
class Square(Shape) {
    side: int
    def(s: int) { self.side = s }
    def area() -> int { return self.side * self.side }
}
def total(shapes: list[Shape]) -> int {
    t: int = 0
    for s in shapes {
        t = t + s.area()   # dispatches to Square.area
    }
    return t
}
print(total([Square(4)]))   # 16

When the variation is over types rather than behavior, reach for a generic instead: def f[T](...) is monomorphized per type with zero boxing.

An untyped Python list is not list[Any]

Coming from Python (or from mypy, where list[Any] is compatible with everything), the reflex for "a function that takes any list" is a list[Any] parameter. In Dragon that annotation names a concrete runtime layout - a 16-byte tagged box per element - which a monomorphized list[str] does not have, so a named concrete list is rejected at the boundary instead of being silently misread:

def first_len(xs: list[Any]) -> int {
    return len(xs)
}
def run() -> None {
    names: list[str] = ["a", "b"]
    n: int = first_len(names)
    # error: argument 1 of type 'list[str]' is not assignable to parameter
    # type 'list[Any]' (the two have different element layouts: monomorphized
    # vs boxed; build the value with this element type at its declaration,
    # or copy it element-wise)
}

Pick the parameter type by what you mean:

  • "I take one specific element type" → the concrete type: def f(xs: list[str]).
  • "I work for every element type" → a generic: def f[T](xs: list[T]) - monomorphized per caller, zero boxing.
  • "I take genuinely dynamic data" → a bare Any: def f(x: Any) accepts any list and dispatches on the real value at runtime; or list[Any] if the data is built dynamic from birth, the way json.loads builds it.

A fresh literal argument still reads naturally - f(["a", "b"]) against a list[Any] parameter builds the box layout directly, exactly like xs: list[Any] = ["a", "b"] does. See Lists for the full layout rule.

Reflection → model the data explicitly

getattr / setattr / hasattr, __dict__, monkeypatching, and metaclasses are all absent - the set of fields and methods is fixed when the program compiles. (Reading an undeclared attribute is a compile error, not a runtime AttributeError.) Two replacements cover almost every case:

  • Known-but-many fields → declare them. If you reached for setattr to avoid typing them out, type them out; now every access is checked.
  • Genuinely dynamic, string-keyed data → that is a dict[str, V], not an object. Keep the dynamism in a value, not in the type system:
# Python's setattr(cfg, k, v) / getattr(cfg, k) → a dict
cfg: dict[str, str] = {}
cfg["host"] = "localhost"
print(cfg.get("host", "127.0.0.1"))   # localhost

Built-in class synthesis (@dataclass, enums) covers the common reasons to reach for a metaclass - see Classes.

Colorless concurrency: fire / await, not async def

Python splits the world in two with async/await: an async def can only be awaited from another coroutine, you need an event loop to run it, and calling it from a plain function is a bug. That is "function coloring."

Dragon has no color split. Any function can spawn concurrent work with fire, and any function can await a task. There is no async def, no asyncio.run, no loop to set up.

# Python - async colors the whole call chain
import asyncio

async def work() -> int:
    return 42

async def main() -> None:
    r = await work()
    print(r)

asyncio.run(main())
# Dragon - fire spawns a green thread; await joins it
def work() -> int {
    return 42
}
t: Task[int] = fire work()
r: int = await t
print(r)   # 42

fire fn() spawns an M:N-scheduled green thread and hands back a Task[T]; await joins it (yielding to the scheduler if the awaiter is itself a green thread). The Task[int] binding annotation is mandatory - t = fire work() is rejected, because types are never inferred into a bare =. See Concurrency.

del proves; own and dub are new

Python's del x unbinds a name, and the object quietly lives on if anything else references it. Dragon's del x is the same shape with a stronger promise: it compiles only when the compiler can prove x is the value's sole owner, frees the value on that line, and makes any later use of the name a compile error. If something aliased, stored, or captured the value, the error names where - which turns del into a lifetime probe, not just a statement. (del d["key"] on a container entry works exactly as in Python.)

Two keywords have no Python counterpart:

  • own - sole ownership. On a field it means "this value dies with the instance, released automatically"; on a parameter and at the call site (def f(own d: T) / f(own d)) it transfers ownership and the source name is dead afterwards.
  • dub - an explicit copy, the only way to mint a second independent owner. Where Python copies implicitly or aliases silently, Dragon makes the copy a visible, greppable word.

Three places they are mandatory, all compile errors with named fixes: a field holding a Lock or other raw resource must be own; a value moved on one branch must be consumed (del) on the others; a container mutated inside its own for loop must be iterated as for x in dub xs. The full model is Ownership.

What carries over unchanged

A reassuring amount. If you already know Python, this is muscle memory:

  • f-strings - f"Hello, {name}!" works verbatim. See Strings.
  • Comprehensions - [i * i for i in range(n)], with if filters.
  • Collections - list, dict, set, tuple with the same literal syntax and the same methods (.append, .get, .items, slicing, in). See Collections.
  • Control flow - if/elif/else, for/else, while, break/continue, match.
  • Operators - arithmetic, comparison, and/or/not, //, **, %. See Operators.
  • Functions - default arguments, keyword arguments, *args/**kwargs (typed), closures. See Functions and Closures and Lambdas.
  • Exceptions - try/except/else/finally, raise, custom exception classes. See Error Handling.
  • stdlib shapes - where Dragon ships a module, the API names and signatures track Python 3. See the stdlib index.

The values look the same and read the same. The difference is that Dragon checks them at compile time and runs them at C speed.

Where the shapes differ

The stdlib tracks Python 3 names and signatures wherever it can, but a few modules and types diverge on purpose. Check these before you port:

  • re returns no Match object. re.match(pattern, subject) returns an int - positive when the pattern matches, 0 or negative when it does not - and re.search returns the matched substring as a str ("" for no match). Captured groups come from a compiled Pattern: re.compile(p).group(subject, n). There is no Match, no .start()/.end()/.span().
  • re.sub is literal replacement only. The replacement string is inserted verbatim - backreferences like \1 are not expanded.
  • tomllib.loads returns a TomlDoc, not a dict. Read values through its typed dotted-key accessors: .get(key), .get_int(key), .get_float(key), .get_bool(key).
  • csv exposes parse_row / format_row, not reader / writer. Both take the delimiter as a required argument: parse_row(line, ",") and format_row(fields, ",").
  • int is a fixed 64-bit signed integer and wraps silently. There is no arbitrary-precision big-int and no OverflowError; overflowing i64 wraps around, as in C.
  • Dicts are monomorphic in their key type. A dict[int, V] and a dict[str, V] are distinct - one dictionary cannot hold both 1 and "1" as keys. Indexing a dict[int, V] with a str is a compile error, unlike Python where both coexist.

A porting checklist

When you bring a typed .py file across, walk this list:

  1. Annotate everything. Every parameter and return type. The enforcer rejects bare defs.
  2. Change = to : at each first binding. The first time a name appears, give it a type; later assignments stay bare =.
  3. Hoist any name you read after a block to a declaration before the block.
  4. Replace dynamic features. No eval/exec, no monkeypatching, no numpy/pandas. Find the stdlib equivalent or drop to FFI.
  5. Drop the __main__ guard. Top-level code is the program; delete the guard and call what you mean to run.
  6. Swap asyncio for fire/await if you use coroutines.
  7. Verify the imports exist. Only modules in the stdlib index resolve.

If a file leans on something on this list that has no equivalent, that file is not portable - and that is the honest answer, not a workaround to hunt for.

At a glance

ConceptPythonDragon
Positioningthe languagea typed, compiled language inspired by Python
Executioninterpreted (CPython)compiled to a native LLVM binary
File modes.py only.dr (braces) and .py (indentation)
Type annotationsoptional, gradualmandatory, enforced at compile time
New variablex = 1x: int = 1 (: declares)
Reassignx = 2x = 2 (bare =, name must exist)
Retype a nameallowederror - type fixed at declaration
Scopefunctionblock (every {} / suite)
Rebind an enclosing fn's varbare = silently makes a new local (the forgot-nonlocal bug)compile error - nonlocal x to rebind, or x: T = ... for a new local
Constructordef __init__(self, ...).dr: def() { ... }; .py: __init__
Entry pointif __name__ == "__main__"the file you run; top-level code executes
Concurrencyasync def / await (colored)fire / await (colorless)
Packagespip install from PyPIstatically linked stdlib; no registry
Dynamic evaleval / execnot available
Runtime type dispatchtype(x) / duck typingisinstance narrowing, base-class virtual dispatch, or generics
Reflectiongetattr / setattr / metaclassesnot available - declare fields, or use a dict[str, V]
@classmethod generatorlate-bound cls, yieldsuse @staticmethod / a free generator / a generic
C extensionsCPython C APIFFI to C
numpy / pandas / Flaskyesno, by design
f-strings, comprehensions, collectionsyesyes - unchanged

Welcome aboard. Most of your Python instincts are correct; the few that aren't, the compiler will tell you about before your program ever runs.