Skip to content

Aura For Python Developers

Most of what you know transfers. Indentation, def, class, f-strings, comprehensions, keyword arguments, for x in items — all of it works the way you expect. This chapter is about the parts that do not, so the compiler stops surprising you by the end of the page.

There Is No if __name__ == "__main__"

A file with statements at the top level is the script. It runs top to bottom:

Aura
langs = ["python", "aura"]

for lang in langs:
    print(f"hello, {lang}")

When you want a real entry point — an exit code, a program you will compile — write main:

Aura
def main() -> int32:
    print("hello")
    return 0

The one rule to remember: a file picks a side. It either has top-level statements or an explicit main, never both. Declarations like class and def are fine alongside either.

Bindings Are Immutable Unless You Say Otherwise

This is the first error most Python developers hit:

Aura
def main():
    total = 0
    total = total + 1   # error: cannot assign to immutable binding `total`

Add mut and it works:

Aura
def main():
    mut total = 0
    total = total + 1

mut is not a type — it is permission to rebind or mutate. You will see it in three places: local bindings, parameters that a function may change, and methods that modify their object.

Top-level entry scripts use the same rule. A mut binding and its later plain or compound assignments belong to the script's shared local environment:

Aura
mut count = 0
count = count + 1
count += 1
print(count)  # 2

A new bare top-level binding such as limit = 3 declares an immutable module constant. Module constants initialize before top-level entry statements, even when the two categories are interleaved in the file. Use mut limit = ... when the value must be computed from an earlier top-level script local.

Values Have Owners

Python passes references around and a garbage collector eventually cleans up. Aura tracks a single owner for every value, and the signature tells you what a function does to its argument.

Aura
def shout(name: str) -> str:          # shared: reads it, you keep it
    return name.to_upper()

def add_tag(tags: mut list[str], tag: own str):   # mut: changes yours
    tags.append(tag)                              # own: takes it

def consume(name: own str) -> int64:  # own: it is theirs now
    return name.len()

Three capabilities, and that is the whole model:

SpellingThe callee canYou afterwards
name: strread itstill own it
name: mut strchange it in placestill own it, changed
name: own strdo anything, including keep itno longer have it

Calls look like Python — no sigils, no &:

Aura
label = "aura"
print(shout(label))
print(shout(label))   # fine, shout only reads

Give a value away and the compiler holds you to it:

Aura
n = consume(label)
print(label)
text
error[AU3001]: use of moved value `label`
  = related owner.au:6:17: value moved here
  = help: pass shared access when ownership is not needed, or call `.clone()`
    at the move site when an independent value is required

Read that as the compiler asking a question: did you mean to hand it over, or did you mean to share it? Copy small things freely — numbers, booleans, durations are copied, not moved. Everything else (strings, collections, class instances, files) moves.

Classes Have No __init__

An Aura class is fields and methods. There is no initializer, and no self assignment ceremony — you construct with keyword arguments, and fields may declare defaults:

Aura
class Account:
    owner: str
    balance: float64
    currency: str = "USD"
Aura
account = Account(owner="ada", balance=0.0)

When you want named construction — the thing __init__ and @classmethod give you — write a function on the class that takes no self and returns one:

Aura
class Account:
    owner: str
    balance: float64
    currency: str = "USD"

    def new(owner: own str) -> Account:
        return Account(owner=owner, balance=0.0)

    def opening(owner: own str, deposit: float64) -> Account:
        return Account(owner=owner, balance=deposit)
Aura
fresh = Account.new("ada")
mut acct = Account.opening("grace", 100.0)

These are "associated functions": called through the class name, free to validate, compute, or pick defaults. You can have as many as you need, which is more than Python gives you without @classmethod gymnastics.

Methods Say What They Do To self

The receiver follows the same three capabilities as parameters:

Aura
    def label(self) -> str:              # reads
        return f"{self.owner}: {self.balance} {self.currency}"

    def deposit(mut self, amount: float64):   # modifies
        self.balance += amount

    def into_balance(own self) -> float64:    # consumes
        return self.balance
Aura
acct.deposit(25.0)
print(acct.label())
final = acct.into_balance()   # acct is gone after this

A bare self cannot mutate — the compiler will tell you to write mut self. And a method named close is special: it makes the class a managed resource for with blocks, so pick another name unless that is what you want.

There Is No Inheritance

class Dog(Animal): does not parse. Aura uses traits for shared behavior and composition for shared data — if you reach for a base class, define a trait with the methods and implement it for each type.

Failure Is A Return Value

There are no exceptions and no try/except. A function that can fail says so in its type:

Aura
def parse_port(text: str) -> Result[int64, str]:
    match parse_int64(text):
        case Result.Ok(port):
            if port > 65535:
                return Result.Err("port out of range")
            return Result.Ok(port)
        case Result.Err(_):
            return Result.Err(f"not a number: {text}")

Callers must handle both sides — there is no invisible propagation:

Aura
match parse_port("8080"):
    case Result.Ok(port):
        print(f"listening on {port}")
    case Result.Err(message):
        print(f"bad config: {message}")

Option[T] plays the role of None-or-a-value, and try propagates an error to the caller when your own function returns a Result. See Results, Options, And try.

Types Are Static, But Locals Infer

Annotations are required where a contract crosses a boundary — parameters and return types — and inferred everywhere else:

Aura
def total(prices: list[float64]) -> float64:
    mut sum = 0.0        # inferred float64
    for price in prices:
        sum += price
    return sum

Three differences worth knowing up front:

  • A missing parameter type is a parse error, not a dynamic parameter. def f(x): does not compile.
  • Numeric types never convert implicitly. Passing an int32 where int64 is expected is an error; cast with as int64 or .to_float(). Unsuffixed integer literals are int64, floats are float64.
  • Generics are explicit: list[str], dict[str, int64], Option[int64], and type parameters are declared, as in def first[T](values: list[T]) -> Option[T].

If It Returns A Value, Declare The Type

A function with no -> returns nothing. That is fine when the body really returns nothing, and a bare return for an early exit is fine too:

Aura
def greet(name: str):
    print(f"hi {name}")

def early(flag: bool):
    if flag:
        return
    print("no")

The moment the body returns a value, the signature has to say so. Aura does not infer it from the body:

Aura
def double(n: int64):
    return n * 2
text
error[AU2002]: return type mismatch: expected `None`, found `int64`
 --> ret_bad.au:2:5
  |
2 |     return n * 2
  |     ^

Read -> None as the default that was there all along. The fix is to write the type you meant:

Aura
def double(n: int64) -> int64:
    return n * 2

Coming from Python this feels like extra typing for about a day, and then it starts reading as documentation: every signature tells you what goes in and what comes back without opening the body.

Things That Will Surprise You

Integer / is rejected. Python 3 made / true division; Aura makes you choose, because silently truncating is the older bug:

text
error[AU2003]: integer `/` is not supported; use `//` for floor division,
or call `.to_float()` on both operands for true division

There is no truthiness. if values: fails — conditions are bool and nothing else. Write if values.len() > 0: or if value == None:.

Strings are not indexable. s[0] does not work; a str is a sequence of Unicode scalar values, len() counts those, and slicing (s[1:4]) gives you an owned copy. Use s.split("") style operations or slices instead of character indexing.

is does not exist. Use == None for optionals; there is no identity comparison.

Reading a non-copy element out of a list by index is rejected, because it would move a value out of a collection you still own. Use values.get(index), which hands you an Option containing a clone.

Top-level bindings live in module storage and cannot be moved out of it. If you want to consume a value with an own method, do it inside a function.

Module state is immutable. Constants at module level are fine; mut at module level is not. Mutable state belongs to some owner — usually main.

Where To Go Next

  • Values, Moves, And Borrows — the ownership model in depth, with the errors you will meet and how to fix each one.
  • Shaping Data — classes, enums, traits, and methods.
  • Testingaura test, assertions that show their values.
  • The Manual — the normative rules when you need the exact contract.

Aura 0.3.2 technical preview. Implementation baseline: 837eb9756ed9efdca275d960edf12317fff1aa9c.