Aller au contenu

TP02 : Le Runtime pour Oberon-0

Dans ce TP, nous créons un runtime pour le langage de programmation Oberon-0. Un runtime est un environnement d’exécution qui permet d’exécuter du code compilé dans un langage spécifique. Dans notre cas, nous créons un runtime en Python qui peut exécuter du code compilé en WebAssembly (WASM) à partir de programmes écrits en Oberon-0.

Création du projet

Suivant l’exemple du TP précédent, créez un nouveau dossier (par exemple oberon0-runtime), créez un nouveau projet Python avec uv.

Ajoutez les dépendances nécessaires :

uv add loguru rich typer wasmtime
uv add --dev  black bump-my-version furo mypy pre-commit pyright \
              pytest ruff sphinx sphinx-design \
              sphinxcontrib-napoleon sphinxcontrib-typer

Étudiez l’outil bump-my-version pour la gestion de version de votre projet ainsi que sphinxcontrib-typer.

Votre programme doit pouvoir exécuter du code WebAssembly et mettre à disposition les fonctions suivantes pour les programmes compilés en WebAssembly :

  • OpenInput()
  • ReadInt(addr)
  • eot()
  • WriteChar(ch)
  • WriteInt(n, len)
  • WriteLn()

Étudiez le code suivant pour comprendre comment implémenter ces fonctions dans votre runtime :

src/oberon0_runtime/__init__.py
# SPDX-FileCopyrightText: 2026 Jacques Supcik <jacques.supcik@hefr.ch>
#
# SPDX-License-Identifier: MIT

"""
Oberon0 runtime for WASM module
"""

import sys
from dataclasses import dataclass, field
from enum import Enum
from typing import Annotated

import typer
from loguru import logger
from rich.console import Console
from rich.panel import Panel
from wasmtime import (
    Func,
    FuncType,
    Global,
    GlobalType,
    Instance,
    Limits,
    Memory,
    MemoryType,
    Module,
    Store,
    ValType,
)

INT32_SIZE = 4
INITIAL_STACK_POINTER = 1 << 16


class _ReturnCode(Enum):
    SUCCESS = 0
    FILE_NOT_FOUND = 1
    COMMAND_NOT_FOUND = 2
    NO_MORE_INPUT = 3


app = typer.Typer(
    help="Oberon0 runtime for WebAssembly modules - "
    "execute WASM files compiled from Oberon0 code."
)
console = Console()


@dataclass
class _Context:
    """
    Shared context for the runtime functions.
    """

    store: None | Store = None
    buffer: list[int] = field(default_factory=list)
    memory: None | Memory = None


context = _Context()


# Runtime functions
def _open_input() -> None:
    """open-inout is a no-op for compatibility with the original Oberon0 runtime."""
    logger.debug("OpenInput()")


def _read_int(address: int) -> None:
    """Reads an integer from the input buffer and store it in the memory
    at the given address."""
    logger.debug(f"ReadInt({address})")
    try:
        val = context.buffer.pop(0)
    except IndexError:
        console.print("[bold red]Error: no more input[/bold red]")
        raise typer.Exit(code=_ReturnCode.NO_MORE_INPUT.value) from None

    assert context.memory is not None
    assert context.store is not None
    context.memory.write(
        context.store, val.to_bytes(INT32_SIZE, "little", signed=True), address
    )


def _eot() -> int:
    """Check if there are still elements in the input buffer."""
    logger.debug("EOT()")
    return 1 if len(context.buffer) == 0 else 0


def _write_char(c: int) -> None:
    """
    Write a character to the standard output.
    """
    logger.debug(f"WriteChar({c})")
    console.print(chr(c), end="")


def _write_int(i: int, len: int) -> None:
    """Write an integer to the standard output using a width of `len` characters
    (padded with spaces).
    """
    logger.debug(f"WriteInt({i}, {len})")
    console.print(f"{i:{len}d}", end="")


def _write_ln() -> None:
    """Write a newline character to the standard output."""
    console.print()


# Main function
@app.command()
def info(
    wasm_file: Annotated[typer.FileBinaryRead, typer.Argument()],
) -> None:
    """
    Print the list of commands available in the given WASM file.
    """
    store = Store()
    engine = store.engine
    try:
        module = Module(engine, wasm_file.read())
    except FileNotFoundError:
        console.print(
            f"[bold red]Error: WASM file '{wasm_file.name}'" " not found[/bold red]"
        )
        raise typer.Exit(code=_ReturnCode.FILE_NOT_FOUND.value) from None

    commands = []
    for i in module.exports:
        if isinstance(i.type, FuncType):
            commands.append(f"- {i.name}")

    panel = Panel(
        "\n".join(commands),
        title=f"[bold green]Commands available in '{wasm_file.name}'[/bold green]",
        border_style="green",
    )
    console.print(panel)


@app.command()
def run(
    wasm_file: Annotated[typer.FileBinaryRead, typer.Argument()],
    command: Annotated[str, typer.Argument()],
    numbers: Annotated[list[int] | None, typer.Argument()] = None,
    debug: bool = False,
) -> None:
    """
    Run the given command from the given WASM file with the provided input numbers.
    """
    logger.remove()
    if debug:
        logger.add(sys.stdout, level="DEBUG")
    else:
        logger.add(sys.stdout, level="INFO")

    if numbers is not None:
        context.buffer.extend(numbers)

    context.store = Store()

    try:
        module = Module(context.store.engine, wasm_file.read())
    except FileNotFoundError:
        print(f"[bold red]Error: WASM file '{wasm_file.name}' not found[/bold red]")
        raise typer.Exit(code=_ReturnCode.FILE_NOT_FOUND.value) from None

    f_open_input = Func(context.store, FuncType([], []), _open_input)
    f_read_int = Func(context.store, FuncType([ValType.i32()], []), _read_int)
    f_eot = Func(context.store, FuncType([], [ValType.i32()]), _eot)
    f_write_char = Func(context.store, FuncType([ValType.i32()], []), _write_char)
    f_write_int = Func(
        context.store, FuncType([ValType.i32(), ValType.i32()], []), _write_int
    )
    f_write_ln = Func(context.store, FuncType([], []), _write_ln)

    context.memory = Memory(context.store, MemoryType(Limits(1, None)))
    sp = Global(
        context.store, GlobalType(ValType.i32(), mutable=True), INITIAL_STACK_POINTER
    )

    instance = Instance(
        context.store,
        module,
        [
            f_open_input,
            f_read_int,
            f_eot,
            f_write_char,
            f_write_int,
            f_write_ln,
            context.memory,
            sp,
        ],
    )

    try:
        cmd = instance.exports(context.store)[command]
        if not isinstance(cmd, Func):
            print(f"[bold red]Error: '{command}' is not a callable function[/bold red]")
            raise typer.Exit(code=_ReturnCode.COMMAND_NOT_FOUND.value)
        cmd(context.store)
    except KeyError:
        print(f"[bold red]Error: command '{command}' not found[/bold red]")
        raise typer.Exit(code=_ReturnCode.COMMAND_NOT_FOUND.value) from None


if __name__ == "__main__":
    app()

Voici un exemple de pyproject.toml pour votre projet :

pyproject.toml
[project]
name = "oberon0-runtime"
version = "0.2.0"
description = "Oberon-0 runtime implemented in Python"
authors = [{ name = "Jacques Supcik", email = "jacques.supcik@hefr.ch" }]
keywords = ["oberon", "runtime", "compiler", "interpreter"]
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
    "loguru>=0.7.3",
    "rich>=14.3.2",
    "typer>=0.21.2",
    "wasmtime>=42.0.0",
]

[dependency-groups]
dev = [
    "black>=26.1.0",
    "bump-my-version>=1.2.7",
    "furo>=2025.12.19",
    "mypy>=1.19.1",
    "pre-commit>=4.5.1",
    "pyright>=1.1.408",
    "pytest>=9.0.2",
    "ruff>=0.15.0",
    "sphinx>=9.1.0",
    "sphinx-design>=0.7.0",
    "sphinxcontrib-napoleon>=0.7",
    "sphinxcontrib-typer>=0.8.0",
]

[build-system]
requires = ["uv_build>=0.10.1,<0.11.0"]
build-backend = "uv_build"

[tool.ruff.lint]
select = ["E", "F", "B", "I"]

[tool.pyright]
venvPath = "."
venv = ".venv"

[project.scripts]
oberon0-rt = 'oberon0_runtime:app'

[tool.bumpversion]
current_version = "0.2.0"
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
serialize = ["{major}.{minor}.{patch}"]
search = "{current_version}"
replace = "{new_version}"
regex = false
ignore_missing_version = false
tag = true
sign_tags = false
tag_name = "v{new_version}"
tag_message = "Bump version: {current_version} → {new_version}"
allow_dirty = false
commit = true
message = "Bump version: {current_version} → {new_version}"
commit_args = ""
pre_commit_hooks = ["uv sync", "git add uv.lock"]

[[tool.bumpversion.files]]
filename = "docs/conf.py"
search = "release = \"{current_version}\""
replace = "release = \"{new_version}\""

Téléchargez le fichier add.wasm depuis le site du cours et testez le runtime avec la commande suivante :

oberon0-rt run add.wasm add 23 19

Vous devriez obtenir le résultat 42.

À faire

Expérimentez en faisant des erreurs et observez le comportement du runtime.

oberon0-rt run foo.wasm add 23 19
oberon0-rt run add.wasm foo 23 19
oberon0-rt run add.wasm add 23

Vous trouverez le code complet du runtime sur GitHub.

Vous pouvez installer le runtime localement avec la commande suivante dans le dossier du projet :

uv tool install .

Étudiez les points suivants:

  • La validation du code avec pre-commit et ruff.
  • Les tests unitaires avec pytest.
  • La conformité du projet avec REUSE.
  • La documentation avec sphinx.
  • Le CI/CD avec les GitHub Actions.

Ce dépôt git doit vous servir d’exemple pour vos projets en Python sur Github.