API cookbook

Use the Python API when you need to compose Acorn-filesystem operations inside a larger Python program — building a disc image as part of an asset pipeline, scripting WFSINIT setup across many discs, integrating with an emulator, or implementing a feature the CLI does not yet expose. For ad-hoc operations from a shell, the Getting started walkthrough is usually the friendlier starting point.

Each recipe below is a complete, runnable Python file in scripts/api-examples/. The test suite exercises every recipe at each release (test_api_examples.py) so the code on this page is guaranteed to match the live API.

Opening a disc and listing its contents

The DFS.from_file named constructor opens an image as a context manager. The dfs.root attribute is the catalogue handle; iterating it yields one path per populated directory letter ($, AZ), and iterating each of those yields the files carrying that letter. Per-entry metadata is read via stat(), which returns a oaknut.file.Stat with .length, .load_address and .access.

DFS images are flat: the directory letters are siblings, not parents and children. See Paths and compound paths for the full model.

def list_disc(filepath: Path) -> None:
    """List every file on a DFS image: name, locked flag, length, load address.

    Demonstrates auto-detected format on DFS.from_file, two-level
    iteration through the catalogue's directory letters, and the
    unified Stat protocol's access, length and load_address accessors.

    Args:
        filepath: Path to a DFS image (.ssd or .dsd).
    """
    with DFS.from_file(filepath) as dfs:
        print(f"Title: {dfs.title}")
        for directory in dfs.root.iterdir():
            for entry in directory.iterdir():
                st = entry.stat()
                lock = "L" if st.access & Access.L else " "
                print(
                    f"  {entry.path:14s}  {lock}  "
                    f"load={st.load_address:#010x}  "
                    f"size={st.length:>6d}"
                )

Walking an ADFS tree recursively

ADFS (and AFS — the Acorn Level 3 File Server’s partition format, sometimes called AFS0 after its on-disc magic; see the glossary) is hierarchical: $ contains named subdirectories which contain further files and directories. The walk() method mirrors pathlib.Path.walk() — each step yields (dirpath, dirnames, filenames) in pre-order, descending into every subdirectory. The same call works against oaknut.dfs.DFSPath and oaknut.afs.AFSPath.

def walk_tree(start_dirpath) -> None:
    """Print an ADFS subtree with two-space indentation per level.

    Args:
        start_dirpath: Path to walk from. Usually ``adfs.root`` for
            the whole tree.
    """
    root_depth = len(start_dirpath.parts)
    for dirpath, dirnames, filenames in start_dirpath.walk():
        indent = "  " * (len(dirpath.parts) - root_depth)
        print(f"{indent}{dirpath.name}/")
        for filename in filenames:
            size = (dirpath / filename).stat().length
            print(f"{indent}  {filename:18s}  {size:>6d}")

Creating a disc with varied entries

The write_bytes() method writes a single file with optional load_address, exec_address, and access keyword arguments. The write_text() companion encodes a string as Acorn bytes first, translating Python "\n" to the on-disc "\r" line terminator in keeping with Python’s universal-newline convention. The access keyword takes an oaknut.file.Access flag combination: Access.LWR is the canonical “locked owner R+W”; Access.WR (or omitting access entirely) gives unlocked owner R+W.

def populate_disc(filepath: Path) -> None:
    """Lay down four entries that exercise the write surfaces.

    The entries:

    - $.README — plain text, Acorn-encoded by write_text. The default
      load/exec of 0 is fine for data files.
    - $.PROG — raw program bytes loaded at the BBC's canonical 0x1900,
      auto-running on *RUN.
    - $.DATA — arbitrary bytes at a non-default load address.
    - $.LOCKED — small file, locked via the named composite Access.LWR
      (locked + owner R+W).
    """
    with DFS.create_file(filepath, title="MyDisc", boot_option=2) as dfs:
        (dfs.root / "$.README").write_text(
            "Welcome to MyDisc.\nRun *EXEC $.README at the prompt.\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 / "$.DATA").write_bytes(
            bytes(range(64)),
            load_address=0x3000,
        )
        (dfs.root / "$.LOCKED").write_text(
            "do not delete\n",
            access=Access.LWR,
        )

Round-tripping a file through the host filesystem

The export_file() method writes a file’s bytes plus a metadata sidecar (in the chosen oaknut.file.MetaFormat) to a host path. The import_file() companion reads them back. Both methods live on every path class.

The recipe walks the source disc, exports each file with an INF sidecar, and re-imports the lot into a fresh disc. The closing assertions confirm the bytes and metadata are byte-identical on both sides.

def round_trip(image_filepath: Path, host_dirpath: Path) -> None:
    """Export every file in the ADFS root, then re-import to a fresh image.

    The pattern reads:

      source.export_file(host_path, meta_format=...) pulls bytes
      and metadata onto the host, dropping an INF sidecar so nothing
      is lost.

      destination.import_file(host_path, meta_formats=[...]) picks
      the sidecar back up at the destination, applying load, exec,
      and access in one call.

    Args:
        image_filepath: Source ADFS image to round-trip.
        host_dirpath: Directory on the host where the intermediate
            file plus INF sidecar are written, and where the fresh
            image is created.
    """
    fresh_filepath = host_dirpath / "round_trip.adl"
    with (
        ADFS.from_file(image_filepath) as source,
        ADFS.create_file(fresh_filepath, ADFS_L, title="RoundTrip") as target,
    ):
        for entry in source.root.iterdir():
            if entry.is_dir():
                continue
            host_path = host_dirpath / entry.name
            entry.export_file(host_path, meta_format=MetaFormat.INF_TRAD)
            (target.root / entry.name).import_file(host_path)

    # Verify the bits actually round-tripped intact.
    with (
        ADFS.from_file(image_filepath) as source,
        ADFS.from_file(fresh_filepath) as target,
    ):
        for entry in source.root.iterdir():
            if entry.is_dir():
                continue
            src_st = entry.stat()
            tgt_st = (target.root / entry.name).stat()
            assert src_st.load_address == tgt_st.load_address
            assert src_st.exec_address == tgt_st.exec_address
            # Access bits that the host metadata format can carry.
            assert bool(src_st.access & Access.L) == bool(tgt_st.access & Access.L)
            assert entry.read_bytes() == (target.root / entry.name).read_bytes()
    print(f"Round-trip OK: {fresh_filepath.name}")

Copying files across filesystems

The copy_to() method writes the source file’s bytes and metadata to a destination path. The destination’s filesystem decides how to encode the access bits; the call is identical whether the source and destination share a filesystem family or not.

The recipe copies every file from a DFS catalogue into the root of an ADFS hard disc.

def cross_copy(source_filepath: Path, target_filepath: Path) -> None:
    """Copy every file from a DFS floppy into the root of an ADFS image.

    The DFS catalogue is flat (single-character directory tags), the
    ADFS root is a real directory; copy_to does the right thing on
    both ends without the caller spelling out the conversion.

    Args:
        source_filepath: DFS .ssd or .dsd image to copy from.
        target_filepath: ADFS image (.dat or .adl) to copy into.
            Opened read-write; the source is opened read-only.
    """
    with (
        DFS.from_file(source_filepath) as dfs,
        ADFS.from_file(target_filepath) as adfs,
    ):
        for letter in dfs.root.iterdir():
            for entry in letter.iterdir():
                # ADFS filenames are <= 10 chars and case-preserving;
                # DFS gives us 7-char uppercase names that already fit.
                destination = adfs.root / entry.name
                entry.copy_to(destination)

Bulk-archiving a folder of floppies onto one hard disc

The recipe creates one ADFS subdirectory per source SSD and copies every file across. The subdirectory name is the first PascalCase word of the SSD filename — for Disc001-PlanetoidAKADefender.ssd the chosen name is Planetoid — and falls back to a truncated stem when the regex does not match.

def archive_floppies(ssd_filepaths: list[Path], archive_filepath: Path) -> None:
    """Copy every file from each SSD into its own ADFS subdirectory.

    The subdirectory name comes from the first PascalCase word in the
    SSD filename — Disc001-PlanetoidAKADefender.ssd becomes Planetoid,
    well within ADFS's 10-character filename limit.

    Args:
        ssd_filepaths: DFS floppies to archive, in the order they
            should appear on the destination disc.
        archive_filepath: Pre-existing ADFS hard-disc image opened
            read-write for the duration of the archive.
    """
    with ADFS.from_file(archive_filepath) as archive:
        for ssd in ssd_filepaths:
            name = _subdir_name_for(ssd)
            subdir = archive.root / name
            subdir.mkdir()
            with DFS.from_file(ssd) as floppy:
                for letter in floppy.root.iterdir():
                    for entry in letter.iterdir():
                        entry.copy_to(subdir / entry.name)

Building a Level 3 File Server disc from scratch

The AFS.create_file named constructor creates an ADFS hard-disc envelope and initialises an AFS partition inside it. The users keyword adds accounts on top of the built-in Syst / Boot / Welcome set; omit_users removes named built-ins from that set; and emplacements lays down a shipped library image ("Library", "Library1", "ArthurLib") or any .adl path.

The yielded AFS handle is open and writable for the duration of the with block, so the recipe finishes by creating a personal directory for the new user and writing a note into it.

def build_server_disc(filepath: Path) -> None:
    """Create a 10 MB L3FS hard disc with one custom user and one library.

    Args:
        filepath: Destination .dat path. The companion .dsc sidecar
            is written automatically.
    """
    herman_username = "Herman"
    with AFS.create_file(
        filepath,
        capacity="10MB",
        disc_name="MyServer",
        users=[UserSpec(herman_username, quota="2MB")],
        omit_users=["Welcome"],
        emplacements=["Library"],
    ) as afs:
        # initialise() laid down a User Root Directory for Herman as
        # part of account creation, so we can write straight into it.
        herman_user_root_dirpath = afs.root / herman_username
        (herman_user_root_dirpath / "Notes").write_text(
            "server built via AFS.create_file\n",
        )

Adding a user to an existing AFS image

The AFS.add_user method appends an account to the passwords file. An AFS file server always lives in the tail of an ADFS hard-disc image, so the recipe opens the disc as ADFS and reaches the partition through ADFS.open_afs_partition, which yields the AFS handle and calls AFS.close() on clean exit (or AFS.discard() if the body raises).

Buffered writes on the AFS side commit when the inner with block exits cleanly; an exception inside the block discards them, leaving the on-disc passwords file untouched. The image is opened writable when the host filesystem permits — no Python open() mode strings to remember.

def add_user(disc_filepath: Path, username: str, quota: str) -> None:
    """Add username with quota to the AFS partition on the given disc.

    The disc is opened as ADFS; ``adfs.afs_partition`` yields the AFS
    handle. Writes are buffered on the AFS side and flushed when the
    ``with`` block exits cleanly; an exception inside the block
    discards them, leaving the on-disc passwords file untouched.

    Args:
        disc_filepath: ADFS hard-disc image (``.dat`` + ``.dsc``
            sidecar) whose tail carries the AFS partition.
        username: Name of the account to add. Must not already exist.
        quota: Capacity string (``"2MB"``, ``"512KiB"``, ...) or raw
            int byte count.
    """
    with (
        ADFS.from_file(disc_filepath) as adfs,
        adfs.open_afs_partition() as afs,
    ):
        afs.add_user(username, quota=quota)