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 ($,
A–Z), 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)