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 whenDFS.from_fileand 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.