Path objects

Each filing system exposes a path object with a uniform interface modelled on pathlib.Path. The three concrete classes inherit from oaknut.file.AcornPath, which carries the shared surface — slash-join, iterdir, stat, read_bytes, write_bytes, walk, touch, copy_to, and the rest — and delegates the filesystem-specific primitives to each subclass:

Callers wanting “a path on any Acorn filesystem” can type-hint with AcornPath directly:

from oaknut.file import AcornPath

def summarise(p: AcornPath) -> None:
    print(p.path, p.stat().length)

A path is obtained from an open filesystem handle:

with ADFS.from_file("disc.adl") as adfs:
    elite = adfs.root / "Games" / "Elite"
    elite.read_bytes()

Paths bind to a filesystem handle and become stale when the handle closes — never escape a path out of its with block and reach for it later.

Shared shape

Every concrete path class inherits the surface below from oaknut.file.AcornPath, so a function that takes “a path on any Acorn filesystem” can be written without caring which family produced it. The methods fall into seven groups: navigation (slash-join and the path properties), querying (exists / is_dir / is_file / stat), iteration (iterdir, __iter__, walk), read (read_bytes / read_text), write (write_bytes / write_text / touch), mutate (rename, unlink, lock / unlock, the address setters), and host-side round-trip (export_file / import_file / copy_to).

The shape mirrors pathlib’s own division: AcornPath plays the role pathlib.PurePath and pathlib.Path play in the standard library — a concrete base with abstract-by-NotImplementedError filesystem primitives and default implementations for everything that can be expressed in terms of those primitives.

class oaknut.file.AcornPath

Concrete base for DFSPath, ADFSPath, and AFSPath.

Inheriting concrete classes set the class attributes EntryExistsError and DirectoryError so the shared touch() default raises the right filesystem-specific exception. Otherwise subclasses only need to implement the abstract primitives below.

supports_title: bool = False

Whether this path type can carry a directory title (a human-readable label distinct from the name). Only ADFS directories can; DFS and AFS directories cannot. Callers that want to fail before mutating (e.g. mkdir --title) check this flag rather than catching TitleNotSupportedError after the fact.

__truediv__(name)

Slash-join a path fragment, returning a new path.

Splits name on . and appends each component literally via _join_name(). The Acorn shell’s ^ parent token is stored as a literal path component too — call resolve() to collapse ^ runs against their preceding directories. This mirrors pathlib.PurePath which stores .. literally rather than resolving on join.

Examples:

p / "Games" / "Elite"      # join two names
p / "Games.Elite"          # equivalent, single string
p / "^"                    # literal ^ component; resolve()
                           # collapses it to p.parent
p / "^^.Docs.ReadMe"       # ^^ stored as one component;
                           # resolve() walks up two then in
Parameters:

name (str)

Return type:

AcornPath

resolve()

Collapse any ^ components against their preceding parts.

Returns a new path with every caret component (^, ^^, ^^^ …) removed, after walking one directory up per caret character. Carets that would walk past the root clamp at the root, mirroring parent’s behaviour.

Pure operation — does not touch the filesystem. ^ is reserved Acorn syntax and cannot appear in a legitimate on-disc name, so I/O methods call resolve() automatically before reading or writing.

Return type:

AcornPath

property parent: AcornPath

The containing path. The root’s parent is the root itself.

property name: str

The final path component.

property parts: tuple[str, ...]

Path components as a tuple.

property path: str

The full path string.

property title: str

The directory’s human-readable title.

A title is distinct from the name: the name is the structural component used in paths, the title is a label stored inside the directory. Only ADFS directories have one, so the base implementation raises TitleNotSupportedError; oaknut.adfs.ADFSPath overrides it.

exists()

Whether something exists at this path.

Return type:

bool

is_dir()

Whether this path resolves to a directory.

Return type:

bool

is_file()

Whether this path resolves to a file.

Return type:

bool

stat()

Return file metadata as an oaknut.file.Stat.

Return type:

Stat

iterdir()

Yield the immediate children of this directory.

Return type:

Iterator[AcornPath]

read_bytes()

Read this file’s raw bytes.

Return type:

bytes

write_bytes(data, *, load_address=0, exec_address=0, access=None, date=None)

Write data to this path with the given metadata.

Parameters:
Return type:

None

rename(target)

Rename this entry; return the new path.

Parameters:

target (str | AcornPath)

Return type:

AcornPath

Delete the file (or empty directory) at this path.

Return type:

None

lock()

Set the locked bit on this entry.

Return type:

None

unlock()

Clear the locked bit on this entry.

Return type:

None

set_load_address(address)

Set the load address of this file.

Parameters:

address (int)

Return type:

None

set_exec_address(address)

Set the execution address of this file.

Parameters:

address (int)

Return type:

None

walk()

Pre-order walk yielding (dirpath, dirnames, filenames).

Mirrors pathlib.Path.walk(). Descends into every subdirectory; leaf directories yield a single tuple with empty dirnames.

Return type:

Iterator[tuple[AcornPath, list[str], list[str]]]

read_text(*, encoding='acorn', newline=None)

Read file contents as text via oaknut.file.decode_text().

Parameters:
Return type:

str

write_text(text, *, encoding='acorn', newline='\r', load_address=0, exec_address=0, access=None, date=None)

Write text via oaknut.file.encode_text() + write_bytes().

Parameters:
Return type:

None

touch(*, access=None, exist_ok=True)

Create an empty file at this path; mirrors pathlib.Path.touch().

Raises DirectoryError when a directory already exists at this path, and EntryExistsError when a file exists and exist_ok is False.

Parameters:
Return type:

None

copy_to(dst)

Copy this file’s bytes and metadata to dst.

Sugar for oaknut.file.copy_file(). The destination may live on any Acorn filesystem family.

Parameters:

dst (AcornPath)

Return type:

None

The cookbook recipes lean on this uniformity: code that walks a directory and prints its contents looks the same on DFS, ADFS, and AFS.

Where the path models diverge

The same surface hides a structural difference that bites if you assume Unix or ADFS shape when reading DFS code.

ADFS and AFS — hierarchical trees. adfs.root and afs.root are the actual top-level directory $. Walking with / descends into named subdirectories:

adfs.root                       # represents $
adfs.root / "Games"             # $.Games (a directory)
adfs.root / "Games" / "Elite"   # $.Games.Elite (a file)

mkdir() works on ADFSPath / AFSPath. parent walks up the tree as far as the root (the root’s parent is itself). iterdir() on a subdirectory yields its immediate children.

The Acorn shell’s ^ parent-token works as a slash-join component too — stored literally, exactly the way pathlib.PurePath stores ... Dots between consecutive hats are optional (^^ and ^.^ parse to one component and two components respectively but resolve identically), matching the syntax the Acorn *DIR command accepts. AcornPath.resolve() collapses any carets present:

elite = adfs.root / "Games" / "Elite"
(elite / "^").path                 # "$.Games.Elite.^"   (literal)
(elite / "^").resolve().path       # "$.Games"           (collapsed)
(elite / "^^").parts               # ("$", "Games", "Elite", "^^")
(elite / "^^.Docs.ReadMe").resolve().path
                                   # "$.Docs.ReadMe"     (up two, into Docs)

I/O methods (read_bytes(), stat(), iterdir(), and the rest) call resolve() automatically before touching the disc, so caret-bearing paths read and write correctly without the caller having to remember to resolve. Pure path operations (parts, path, name, parent, slash-join, equality) preserve the literal form, just as pathlib does.

DFS — single-character directories under a nameless root. A DFS catalogue holds up to 31 file entries (62 on Watford DFS). Each lives in one of 27 directories — $ and AZ — and all 27 are children of a nameless root. $ is the default directory DFS assumes when a path omits one (per the Acorn DFS User Guide); it is not a parent of AZ, just a sibling with one extra job:

dfs.root                       # the nameless root
dfs.root / "$" / "HELLO"       # $.HELLO in the default directory
dfs.root / "A" / "GAME"        # A.GAME, a sibling of $.HELLO

Empty directories cannot exist on DFS — a directory comes into being the first time you write a file under it and disappears again when its last file is deleted. There is no DFSPath.mkdir(). parent does walk up: a file’s parent is its single-character directory, whose parent is the nameless root, whose parent is itself — so you can climb to the root and slash back down into a sibling directory the same way you would on ADFS. iterdir() on the nameless root yields one entry per populated directory letter; iterdir() on a single-letter directory yields the files filed under it.

See Paths and compound paths for the CLI-side companion to this explanation.

Binding to a filesystem handle

Path objects split into two layers of operation:

Pure path manipulation — slash-join, parent, name, parts, path, equality, str(), repr() — only inspects the path string. These work whether or not the underlying image is open, the same way pathlib.PurePath operations work on a string that doesn’t correspond to any file on disc:

with ADFS.from_file("disc.adl") as adfs:
    elite = adfs.root / "Games" / "Elite"
# adfs is closed; pure ops still work.
print(elite.path)                       # '$.Games.Elite'
sibling = elite.parent / "Manic"        # ADFSPath at $.Games.Manic

I/Oread_bytes(), write_bytes(), stat(), iterdir(), walk(), exists(), and friends — needs the filesystem handle to be open. Calling them after the with block has exited raises a clear error:

with ADFS.from_file("disc.adl") as adfs:
    elite = adfs.root / "Games" / "Elite"
elite.read_bytes()                      # raises: filesystem closed

Pull the bytes you need before exiting if you want to use them later. Pure path objects are safe to keep around indefinitely.