Getting started

The oaknut Python API lets you read, write, and round-trip Acorn DFS, ADFS, and AFS disc images from inside a Python program. The shape of the API is deliberately close to pathlib.Path so that image.root / "Games" / "Elite" reads the same as a host path. AFS is the partition format used by the Acorn Level 3 File Server, sometimes called AFS0 (pronounced “A F S zero”) after the magic at the head of the partition; see the glossary for the longer note.

This page walks from “I have a disc image” to “I am confident reading and writing files” in about ten minutes. Every snippet is runnable as-is — paste it into a REPL or a script. If you have not yet installed any oaknut packages, see Installation. If you do not have a real Acorn image handy, the Build a blank disc to follow along section below builds a blank one to follow along with.

First contact

Three operations cover the common case of “look at one directory, look at the whole disc, read one file”:

from oaknut.dfs import DFS

with DFS.from_file("Disc003-Zalaga.ssd") as dfs:
    print(dfs.title)

    # The contents of the default directory, $.
    for entry in (dfs.root / "$").iterdir():
        print(entry.name)

    # Every file on the disc, across every populated letter.
    for dirpath, _dirs, filenames in dfs.root.walk():
        for filename in filenames:
            print(f"{dirpath.path}.{filename}")

    # Read one file's bytes.
    data = (dfs.root / "$" / "ZALAGA").read_bytes()

Every filesystem class follows the same shape: from_file is a named-constructor context manager, .root is the catalogue handle, iterdir() yields children, walk() recurses, and the slash operator joins path components. Swap DFS for oaknut.adfs.ADFS or oaknut.afs.AFS and the rest of the code reads identically.

On DFS specifically, $ is a child of the nameless root, just like any other single-character directory name. On a real BBC Micro it is the default current directory after boot. The expression dfs.root / "$" selects that child, and iterating it yields the files filed under $. ADFS and AFS differ: on those filesystems $ is the root, so adfs.root.iterdir() yields the children of $ directly.

Build a blank disc to follow along

The DFS.create_file named constructor makes a fresh image you can write into. We will use a single-sided BBC Micro floppy (SSD) — small, fast, and what most Acorn-era discs in the wild are.

from pathlib import Path
from oaknut.dfs import DFS

filepath = Path("hello.ssd")
with DFS.create_file(filepath, title="GETSTART"):
    pass

The yielded handle is a writable DFS instance. The with block here is empty because we just want the empty disc; the next sections fill it. The .ssd extension is enough for create_file to pick the canonical 80-track single-sided format; .dsd would give you the 80-track double-sided interleaved one. Pass an explicit DiscFormat for the 40-track or sequential-double-sided variants.

ADFS and AFS have the same create_file shape. ADFS picks the floppy format from the extension too — .ads ⇒ ADFS_S, .adm ⇒ ADFS_M, .adl ⇒ ADFS_L — and .adf is the lone ambiguous case that still needs an explicit argument:

from oaknut.adfs import ADFS

with ADFS.create_file("hello.adl", title="GETSTART"):
    pass

AFS lives in the tail of an ADFS hard-disc image, and AFS.create_file orchestrates both partitions in one call. See the cookbook recipe API cookbook for the full walkthrough.

Put files in

Three write methods, mirroring pathlib.Path:

from oaknut.file import Access

with DFS.from_file("hello.ssd") as dfs:
    (dfs.root / "$.README").write_text(
        "Welcome to GETSTART.\n",
    )
    (dfs.root / "$.PROG").write_bytes(
        b"\xa9\x41\x20\xee\xff\x60",     # LDA #'A' : JSR &FFEE : RTS
        load_address=0x1900,
        exec_address=0x1900,
    )
    (dfs.root / "$.STUB").touch(access=Access.LWR)

The write_text() method encodes via the Acorn character set and translates Python "\n" to the on-disc "\r" line terminator by default — the write-side companion of Python’s universal-newline convention. Pass newline="" to preserve whatever terminators your string already has. The write_bytes() method writes raw bytes plus optional load_address, exec_address, and access. The touch() method creates an empty file (skipping if it already exists, matching pathlib.Path.touch()).

The access keyword accepts oaknut.file.Access flags; Access.LWR is the canonical “locked owner R+W”, Access.WR is unlocked owner R+W (the default if you omit access entirely).

Mutations made inside the with block are flushed when the context exits cleanly; an exception inside the block discards them. There is no separate commit step.

Read files out

The symmetric three:

with DFS.from_file("hello.ssd") as dfs:
    data = (dfs.root / "$.PROG").read_bytes()
    text = (dfs.root / "$.README").read_text()
    st   = (dfs.root / "$.PROG").stat()

The read_text() method applies universal-newline translation by default ("\r", "\r\n", and "\n" all become "\n" in the returned string). The stat() companion returns an oaknut.file.Stat with .length, .load_address, .exec_address, and .access — the same shape across all three filesystems.

Walking the catalogue

ADFS and AFS are hierarchical, so the natural traversal is a tree walk. The walk() method mirrors pathlib.Path.walk(): each step yields (dirpath, dirnames, filenames) in pre-order:

from oaknut.adfs import ADFS

with ADFS.from_file("disc.adl") as adfs:
    for dirpath, dirnames, filenames in adfs.root.walk():
        print(dirpath.path)
        for filename in filenames:
            print(f"  {filename}")

The same call works against oaknut.dfs.DFSPath and oaknut.afs.AFSPath. For a one-level listing without descending, use iterdir(). See Path objects for the full path model.

A real Acorn disc: Repton Infinity

Everything above works unchanged against released Acorn-era discs. Open Superior Software’s Repton Infinity (1988):

from oaknut.dfs import DFS

with DFS.from_file("Disc999-ReptonInfinity.ssd") as dfs:
    print(f"Title:       {dfs.title}")
    print(f"Boot option: {dfs.boot_option.name}")

    # Every file on the disc lives in $, the default directory.
    for entry in (dfs.root / "$").iterdir():
        st = entry.stat()
        print(f"  {entry.path:14s}  load={st.load_address:#010x}  "
              f"size={st.length:>5d}")

    boot = (dfs.root / "$.!BOOT").read_text()
    print(boot)

The disc’s boot option is EXEC: pressing SHIFT-BREAK on a real BBC effectively types *EXEC $.!BOOT, so the contents of !BOOT run as if each line had been typed at the OS prompt. The read_text() method does the "\r""\n" translation so the file reads cleanly respecting Python’s universal-newline convention.

The CLI walkthrough runs the same disc through disc from the shell.

When something fails

Every oaknut error inherits from oaknut.exception.OaknutException. A small set of category subclasses — DataError, ConfigurationError, InternalError — sits directly under it. Filesystem-specific errors like oaknut.adfs.ADFSPathError or oaknut.afs.AFSDirectoryEntryExistsError all inherit from FSError, which is itself a DataError.

from oaknut.adfs import ADFS, ADFSPathError

with ADFS.from_file("disc.adl") as adfs:
    try:
        (adfs.root / "Missing").read_bytes()
    except ADFSPathError as exc:
        print(f"oops: {exc}")

See Error handling for the full hierarchy and the oaknut.exception.handled_errors() CLI-boundary helper that turns these into one-line error messages and exit codes.

Where to go next

  • API cookbook — eight tested, runnable recipes for the operations this page introduced (cross-image copy, host round-trip, building a Level 3 File Server disc, bulk-archiving floppies).

  • The extension axis: working with any filesystem — for code that does not know which filesystem an image carries: content-first identification, opening any image through a uniform Mount, capability-aware operations, and discovering what filesystems are installed. The path to take when DFS.from_file and friends are too specific.

  • Cross-cutting patterns — the other cross-cutting concepts (paths, metadata, errors).

  • Package reference — the per-package autodoc for every public class, method, and function.

  • Getting started if you would rather drive the same operations from a shell than from Python.