oaknut-filesystem 12.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,119 @@
1
+ """The pluggable filesystem contract for the oaknut family.
2
+
3
+ A *filesystem* (Acorn DFS, Watford DFS, ADFS, AFS, …) is the unit of
4
+ extension, registered on the ``oaknut.filesystem`` entry-point axis. Each
5
+ :class:`Filesystem` detects itself (:meth:`Filesystem.probe`), opens a
6
+ region into a :class:`Mount` exposing a small core plus opt-in capability
7
+ protocols, and declares its physical :class:`Geometry` grammar.
8
+
9
+ Geometry (the physical byte→sector layout) and the filesystem (the
10
+ logical structure) are kept strictly apart. :func:`identify` runs the
11
+ registered filesystems over an image, ranks the candidates, and recurses
12
+ into reserved regions to build a per-partition :class:`Identification`
13
+ tree — depending on, and importing, no concrete filesystem package.
14
+ """
15
+
16
+ from oaknut.filesystem.capabilities import (
17
+ AcornMetadata,
18
+ Bootable,
19
+ Compactable,
20
+ DirectoryTitled,
21
+ DiscGeometry,
22
+ Entry,
23
+ FreeMap,
24
+ FreeMapData,
25
+ FreeSpace,
26
+ HierarchicalDirectories,
27
+ Mount,
28
+ PhysicalGeometry,
29
+ RegionHost,
30
+ Sized,
31
+ Titled,
32
+ UserDatabase,
33
+ Validatable,
34
+ )
35
+ from oaknut.filesystem.coordinator import (
36
+ create_filesystem,
37
+ creating_filesystem,
38
+ describe_filesystem,
39
+ filesystem_names,
40
+ identify,
41
+ )
42
+ from oaknut.filesystem.exceptions import (
43
+ FilesystemError,
44
+ FilesystemExtensionError,
45
+ GeometryError,
46
+ ReadOnlyFilesystemError,
47
+ )
48
+ from oaknut.filesystem.filesystem import (
49
+ FILESYSTEM_KIND,
50
+ FILESYSTEM_NAMESPACE,
51
+ Filesystem,
52
+ )
53
+ from oaknut.filesystem.geometry import (
54
+ FLOPPY,
55
+ WINCHESTER,
56
+ Geometry,
57
+ GeometryGrammar,
58
+ floppy_geometry,
59
+ geometry_from_dsc,
60
+ region_reader,
61
+ winchester_geometry,
62
+ )
63
+ from oaknut.filesystem.identification import Confidence, Identification, Partition
64
+ from oaknut.filesystem.reader import ImageReader, ImageSource, reader_for
65
+
66
+ __version__ = "12.0.0"
67
+
68
+ __all__ = [
69
+ # contract
70
+ "Filesystem",
71
+ "FILESYSTEM_KIND",
72
+ "FILESYSTEM_NAMESPACE",
73
+ # capabilities
74
+ "Mount",
75
+ "Entry",
76
+ "HierarchicalDirectories",
77
+ "AcornMetadata",
78
+ "Titled",
79
+ "DirectoryTitled",
80
+ "Bootable",
81
+ "FreeSpace",
82
+ "FreeMap",
83
+ "FreeMapData",
84
+ "Sized",
85
+ "PhysicalGeometry",
86
+ "DiscGeometry",
87
+ "Compactable",
88
+ "Validatable",
89
+ "UserDatabase",
90
+ "RegionHost",
91
+ # geometry
92
+ "Geometry",
93
+ "GeometryGrammar",
94
+ "floppy_geometry",
95
+ "winchester_geometry",
96
+ "geometry_from_dsc",
97
+ "region_reader",
98
+ "FLOPPY",
99
+ "WINCHESTER",
100
+ # identification
101
+ "Confidence",
102
+ "Identification",
103
+ "Partition",
104
+ # coordinator
105
+ "identify",
106
+ "filesystem_names",
107
+ "describe_filesystem",
108
+ "create_filesystem",
109
+ "creating_filesystem",
110
+ # reader
111
+ "ImageReader",
112
+ "ImageSource",
113
+ "reader_for",
114
+ # exceptions
115
+ "FilesystemError",
116
+ "GeometryError",
117
+ "ReadOnlyFilesystemError",
118
+ "FilesystemExtensionError",
119
+ ]
@@ -0,0 +1,302 @@
1
+ """The mounted-filesystem interface: a small core plus opt-in capabilities.
2
+
3
+ Every mounted filesystem provides the :class:`Mount` core. Beyond that,
4
+ features differ wildly — a flat DFS catalogue, a hierarchical ADFS tree,
5
+ AFS user accounts, a foreign FAT with none of Acorn's metadata — so the
6
+ extras are **opt-in capability protocols** the CLI feature-detects with
7
+ ``isinstance`` (each is ``runtime_checkable``). A command is "available
8
+ when the mount provides capability X", never "when the filesystem is Y".
9
+
10
+ These signatures establish the *shape* of the contract; they will be
11
+ refined as the concrete filesystems are wrapped (Phase B).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Iterable
17
+ from dataclasses import dataclass
18
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
19
+
20
+ if TYPE_CHECKING:
21
+ from oaknut.file import AcornMeta, BootOption
22
+ from oaknut.filesystem.identification import Partition
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Entry:
27
+ """One directory entry, in filesystem-agnostic terms.
28
+
29
+ Acorn-specific metadata (load/exec/access) is reached through the
30
+ :class:`AcornMetadata` capability, not carried here, so a foreign
31
+ filesystem's entries need none of it.
32
+ """
33
+
34
+ name: str
35
+ is_dir: bool
36
+ length: int = 0
37
+ #: The entry's full in-partition path, so a caller can address it
38
+ #: (e.g. fetch its metadata) without re-joining in the filesystem's
39
+ #: own syntax. Empty only for a bare, unaddressed entry.
40
+ path: str = ""
41
+
42
+
43
+ @runtime_checkable
44
+ class Mount(Protocol):
45
+ """The core every mounted filesystem provides.
46
+
47
+ Paths are strings in the *filesystem's own* syntax (`$.DIR.FILE` for
48
+ Acorn, `\\DIR\\FILE` for FAT, `D.DIR.FILE` for a DDOS volume); the
49
+ filesystem parses them — the CLI never does.
50
+ """
51
+
52
+ def path_root(self) -> str:
53
+ """The root path string (e.g. ``"$"`` for Acorn filesystems)."""
54
+ ...
55
+
56
+ def stat(self, path: str) -> Entry:
57
+ """The :class:`Entry` for *path* (name, kind, length, full path)."""
58
+ ...
59
+
60
+ def join(self, parent: str, name: str) -> str:
61
+ """The path of child *name* under directory *parent*.
62
+
63
+ The filesystem owns its path syntax (``$.A`` for Acorn, the root
64
+ sometimes nameless), so the CLI builds new paths through this
65
+ rather than concatenating — needed when creating a path that does
66
+ not exist yet (e.g. a bulk import target).
67
+ """
68
+ ...
69
+
70
+ def iter_entries(self, path: str) -> Iterable[Entry]:
71
+ """Yield the entries of the directory at *path*."""
72
+ ...
73
+
74
+ def exists(self, path: str) -> bool:
75
+ """Whether anything exists at *path*."""
76
+ ...
77
+
78
+ def read_bytes(self, path: str) -> bytes:
79
+ """The contents of the file at *path*."""
80
+ ...
81
+
82
+ def write_bytes(self, path: str, data: bytes) -> None:
83
+ """Write *data* to *path*, creating or replacing the file."""
84
+ ...
85
+
86
+ def remove(self, path: str, *, force: bool = False) -> None:
87
+ """Delete the file or directory at *path*.
88
+
89
+ A directory is removed if the filesystem represents one (a flat
90
+ catalogue has none, so removing its notional directory is a
91
+ no-op). *force* overrides a lock — the filesystem unlocks the
92
+ entry first, owning its own locked-entry semantics so the access
93
+ byte's layout never leaks to the caller.
94
+ """
95
+ ...
96
+
97
+ def rename(self, old_path: str, new_path: str) -> None:
98
+ """Rename / move the entry at *old_path* to *new_path* in place."""
99
+ ...
100
+
101
+
102
+ @runtime_checkable
103
+ class HierarchicalDirectories(Protocol):
104
+ """The filesystem nests directories arbitrarily (ADFS, AFS, FAT, DDOS).
105
+
106
+ Its absence marks a flat catalogue (Acorn/Watford DFS: a single
107
+ top-level directory only).
108
+ """
109
+
110
+ def make_directory(
111
+ self,
112
+ path: str,
113
+ *,
114
+ parents: bool = False,
115
+ exist_ok: bool = False,
116
+ title: str | None = None,
117
+ ) -> None:
118
+ """Create a directory at *path*.
119
+
120
+ *parents* creates missing ancestors; *exist_ok* tolerates an
121
+ existing directory. *title* sets the new directory's title where
122
+ the filesystem supports per-directory titles (ADFS) and is
123
+ rejected (before anything is created) where it does not (AFS).
124
+ """
125
+ ...
126
+
127
+
128
+ @runtime_checkable
129
+ class AcornMetadata(Protocol):
130
+ """Files carry Acorn load/exec addresses and an access byte."""
131
+
132
+ def acorn_meta(self, path: str) -> "AcornMeta":
133
+ """The Acorn metadata of the file at *path*."""
134
+ ...
135
+
136
+ def set_acorn_meta(self, path: str, meta: "AcornMeta") -> None:
137
+ """Replace the Acorn metadata of the file at *path*."""
138
+ ...
139
+
140
+
141
+ @runtime_checkable
142
+ class Titled(Protocol):
143
+ """The disc carries a title / name (DFS, ADFS, AFS)."""
144
+
145
+ @property
146
+ def title(self) -> str:
147
+ """The disc title / name."""
148
+ ...
149
+
150
+ def set_title(self, title: str) -> None:
151
+ """Set the disc title / name."""
152
+ ...
153
+
154
+
155
+ @runtime_checkable
156
+ class DirectoryTitled(Protocol):
157
+ """Directories carry their own title, distinct from the disc's (ADFS).
158
+
159
+ Its absence marks a filesystem whose directories have no title field
160
+ (DFS, AFS) — setting one there is rejected.
161
+ """
162
+
163
+ def directory_title(self, path: str) -> str:
164
+ """The title of the directory at *path*."""
165
+ ...
166
+
167
+ def set_directory_title(self, path: str, title: str) -> None:
168
+ """Set the title of the directory at *path*."""
169
+ ...
170
+
171
+
172
+ @runtime_checkable
173
+ class Bootable(Protocol):
174
+ """The disc carries a ``*OPT 4`` boot option (DFS, ADFS)."""
175
+
176
+ @property
177
+ def boot_option(self) -> "BootOption":
178
+ """The disc's ``*OPT 4`` boot option."""
179
+ ...
180
+
181
+ def set_boot_option(self, option: "BootOption | int") -> None:
182
+ """Set the disc's ``*OPT 4`` boot option."""
183
+ ...
184
+
185
+
186
+ @runtime_checkable
187
+ class FreeSpace(Protocol):
188
+ """The filesystem reports its free space (ADFS, AFS)."""
189
+
190
+ def free_bytes(self) -> int:
191
+ """Free space remaining, in bytes."""
192
+ ...
193
+
194
+
195
+ @runtime_checkable
196
+ class Sized(Protocol):
197
+ """The filesystem reports its own occupied size (its partition's span)."""
198
+
199
+ def size_bytes(self) -> int:
200
+ """The size of this filesystem's partition, in bytes.
201
+
202
+ For a filesystem sharing a disc (ADFS with an AFS tail) this is
203
+ its slice, not the whole image — so partition sizes sum to the
204
+ disc.
205
+ """
206
+ ...
207
+
208
+
209
+ @dataclass(frozen=True)
210
+ class DiscGeometry:
211
+ """A disc's physical geometry, for the ``stat`` summary.
212
+
213
+ *label* is a human description in the filesystem's own vocabulary
214
+ (ADFS speaks cylinders/heads/track; AFS speaks cylinders/sectors-per-
215
+ cylinder). *sectors_per_cylinder* lets the caller place a partition's
216
+ logical-sector span into a cylinder range without parsing the label.
217
+ """
218
+
219
+ label: str
220
+ sectors_per_cylinder: int
221
+ total_sectors: int
222
+
223
+
224
+ @runtime_checkable
225
+ class PhysicalGeometry(Protocol):
226
+ """The filesystem knows the disc's physical geometry (ADFS, AFS).
227
+
228
+ A flat-catalogue floppy filesystem (DFS) records no geometry and does
229
+ not advertise this.
230
+ """
231
+
232
+ def disc_geometry(self) -> DiscGeometry:
233
+ """The disc's physical geometry."""
234
+ ...
235
+
236
+
237
+ @dataclass(frozen=True)
238
+ class FreeMapData:
239
+ """A filesystem's free space as partition-relative sector runs.
240
+
241
+ Carries no geometry: the renderer lays the *total_sectors* out as a
242
+ sector matrix sized to the terminal, marking those in *free_regions*
243
+ free and the rest used. Each region is ``(start_sector, length)``.
244
+ """
245
+
246
+ free_regions: tuple[tuple[int, int], ...]
247
+ total_sectors: int
248
+
249
+
250
+ @runtime_checkable
251
+ class FreeMap(Protocol):
252
+ """The filesystem can report which of its sectors are free."""
253
+
254
+ def free_map(self) -> FreeMapData:
255
+ """The free-space map as partition-relative sector runs."""
256
+ ...
257
+
258
+
259
+ @runtime_checkable
260
+ class Compactable(Protocol):
261
+ """The filesystem can defragment in place, consolidating free space."""
262
+
263
+ def compact(self) -> int:
264
+ """Defragment, returning a filesystem-defined measure of work done."""
265
+ ...
266
+
267
+
268
+ @runtime_checkable
269
+ class Validatable(Protocol):
270
+ """The filesystem can check its on-disc structure for defects."""
271
+
272
+ def validate(self) -> list:
273
+ """Return a list of structural defects (empty when clean).
274
+
275
+ The entries are the filesystem's own validation-error objects,
276
+ rendered by the CLI's error formatter; the caller treats them
277
+ opaquely.
278
+ """
279
+ ...
280
+
281
+
282
+ @runtime_checkable
283
+ class UserDatabase(Protocol):
284
+ """The filesystem has user accounts (AFS passwords / quota)."""
285
+
286
+ def user_names(self) -> tuple[str, ...]:
287
+ """The names of the registered users."""
288
+ ...
289
+
290
+
291
+ @runtime_checkable
292
+ class RegionHost(Protocol):
293
+ """The filesystem reserves regions another filesystem may occupy.
294
+
295
+ An ADFS host reserves tail cylinders that an AFS or (DRDOS) FAT
296
+ filesystem lives in; the coordinator recurses into these. The host
297
+ stays ignorant of *what* occupies them.
298
+ """
299
+
300
+ def reserved_regions(self) -> tuple["Partition", ...]:
301
+ """The regions reserved within this filesystem, for recursion."""
302
+ ...
@@ -0,0 +1,197 @@
1
+ """The identification cascade: run every registered filesystem, recurse, rank.
2
+
3
+ :func:`identify` probes an image with every filesystem on the
4
+ ``oaknut.filesystem`` axis, recurses into the regions a host filesystem
5
+ reserves (an ADFS tail), and returns the candidates for the whole image
6
+ ranked best-first — each carrying its recursively-identified
7
+ ``contained`` partitions. It depends on no concrete filesystem package;
8
+ discovery is purely by entry point, so any subset can be installed.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import replace
14
+
15
+ import stevedore
16
+ from oaknut.discimage import BYTES_PER_SECTOR
17
+ from oaknut.extension import (
18
+ create_extension,
19
+ describe_extension,
20
+ list_extensions,
21
+ load_failure_callback,
22
+ )
23
+ from oaknut.filesystem.exceptions import FilesystemExtensionError
24
+ from oaknut.filesystem.filesystem import (
25
+ FILESYSTEM_KIND,
26
+ FILESYSTEM_NAMESPACE,
27
+ Filesystem,
28
+ )
29
+ from oaknut.filesystem.geometry import Geometry, region_reader
30
+ from oaknut.filesystem.identification import Confidence, Identification, Partition
31
+ from oaknut.filesystem.reader import ImageReader, ImageSource, reader_for
32
+
33
+ __all__ = [
34
+ "identify",
35
+ "filesystem_names",
36
+ "describe_filesystem",
37
+ "create_filesystem",
38
+ "creating_filesystem",
39
+ ]
40
+
41
+
42
+ def filesystem_names() -> list[str]:
43
+ """The entry-point names of every registered filesystem."""
44
+ return list_extensions(FILESYSTEM_NAMESPACE)
45
+
46
+
47
+ def describe_filesystem(name: str, *, single_line: bool = False) -> str:
48
+ """The description of one filesystem (its class docstring)."""
49
+ return describe_extension(
50
+ FILESYSTEM_KIND,
51
+ FILESYSTEM_NAMESPACE,
52
+ name,
53
+ FilesystemExtensionError,
54
+ single_line=single_line,
55
+ )
56
+
57
+
58
+ def create_filesystem(name: str) -> Filesystem:
59
+ """Instantiate one filesystem by name."""
60
+ return create_extension(
61
+ kind=FILESYSTEM_KIND,
62
+ namespace=FILESYSTEM_NAMESPACE,
63
+ name=name,
64
+ exception_type=FilesystemExtensionError,
65
+ )
66
+
67
+
68
+ def creating_filesystem(
69
+ suffix: str, *, filesystems: dict[str, Filesystem] | None = None
70
+ ) -> str | None:
71
+ """The filesystem that creates *suffix* images by default, or ``None``.
72
+
73
+ ``disc create`` infers the filesystem from the target's extension via
74
+ each filesystem's ``creates`` set. Returns ``None`` when no installed
75
+ filesystem is the default creator for the extension (so the user must
76
+ pass ``--filesystem``).
77
+ """
78
+ suffix = suffix.lower()
79
+ active = _registered_filesystems() if filesystems is None else filesystems
80
+ for name, filesystem in active.items():
81
+ if suffix in filesystem.creates:
82
+ return name
83
+ return None
84
+
85
+
86
+ def _registered_filesystems() -> dict[str, Filesystem]:
87
+ """Instantiate every registered filesystem, keyed by entry-point name."""
88
+ manager = stevedore.ExtensionManager(
89
+ namespace=FILESYSTEM_NAMESPACE,
90
+ invoke_on_load=False,
91
+ on_load_failure_callback=load_failure_callback,
92
+ )
93
+ return {ext.name: ext.plugin(name=ext.name) for ext in manager}
94
+
95
+
96
+ def identify(
97
+ source: ImageSource,
98
+ *,
99
+ suffix_hint: str | None = None,
100
+ filesystems: dict[str, Filesystem] | None = None,
101
+ ) -> list[Identification]:
102
+ """Identify the filesystem(s) on *source*, best candidate first.
103
+
104
+ Returns the whole-image candidates ranked by confidence (extension
105
+ only a tie-breaker), each with its reserved regions recursively
106
+ identified in :attr:`Identification.contained`. An empty list means
107
+ no installed filesystem recognised the image.
108
+
109
+ *filesystems* overrides the discovered set — used by tests and to
110
+ simulate a partial install (the extensibility invariant).
111
+ """
112
+ with reader_for(source, suffix_hint=suffix_hint) as reader:
113
+ active = _registered_filesystems() if filesystems is None else filesystems
114
+ candidates = _probe_region(reader, active, reader.suffix)
115
+ whole = Partition(name="", start_sector=0, num_sectors=reader.size // BYTES_PER_SECTOR)
116
+ return [replace(c, partition=replace(whole, name=c.filesystem)) for c in candidates]
117
+
118
+
119
+ def _probe_region(
120
+ reader: ImageReader, filesystems: dict[str, Filesystem], suffix: str | None
121
+ ) -> list[Identification]:
122
+ """Ranked candidates for the region in *reader*, recursion attached."""
123
+ candidates: list[Identification] = []
124
+ for filesystem in filesystems.values():
125
+ identification = filesystem.probe(reader)
126
+ if identification is None:
127
+ continue
128
+ if identification.reserved_regions:
129
+ identification = identification.with_contained(
130
+ _recurse_regions(
131
+ identification.reserved_regions,
132
+ reader,
133
+ identification.geometry,
134
+ filesystems,
135
+ )
136
+ )
137
+ candidates.append(identification)
138
+ return _rank(candidates, filesystems, suffix)
139
+
140
+
141
+ def _recurse_regions(
142
+ regions: tuple[Partition, ...],
143
+ reader: ImageReader,
144
+ geometry: Geometry | None,
145
+ filesystems: dict[str, Filesystem],
146
+ ) -> tuple[Identification, ...]:
147
+ """Identify each reserved region; name partitions by what's found there.
148
+
149
+ Each region is read through the host's geometry (de-interleaved when
150
+ the host is an interleaved floppy) so the tail filesystem sees a
151
+ contiguous logical view.
152
+ """
153
+ contained: list[Identification] = []
154
+ counts: dict[str, int] = {}
155
+ for region in regions:
156
+ sub_reader = region_reader(reader, geometry, region.start_sector, region.num_sectors)
157
+ sub = _probe_region(sub_reader, filesystems, None)
158
+ if sub:
159
+ name = sub[0].filesystem
160
+ index = counts.get(name, 0)
161
+ counts[name] = index + 1
162
+ contained.append(replace(sub[0], partition=replace(region, name=name, index=index)))
163
+ else:
164
+ index = counts.get("", 0)
165
+ counts[""] = index + 1
166
+ contained.append(
167
+ Identification(
168
+ filesystem="",
169
+ confidence=Confidence.POSSIBLE,
170
+ evidence=("reserved region; no installed filesystem recognised it",),
171
+ partition=replace(region, name="", index=index),
172
+ )
173
+ )
174
+ return tuple(contained)
175
+
176
+
177
+ def _rank(
178
+ candidates: list[Identification],
179
+ filesystems: dict[str, Filesystem],
180
+ suffix: str | None,
181
+ ) -> list[Identification]:
182
+ """Order *candidates* best-first: confidence, then extension, then priority."""
183
+
184
+ def extension_agrees(identification: Identification) -> bool:
185
+ filesystem = filesystems.get(identification.filesystem)
186
+ return bool(suffix and filesystem and suffix in filesystem.extensions)
187
+
188
+ def priority(identification: Identification) -> int:
189
+ filesystem = filesystems.get(identification.filesystem)
190
+ return filesystem.priority if filesystem is not None else 0
191
+
192
+ ordered = sorted(candidates, key=lambda i: i.filesystem)
193
+ ordered.sort(
194
+ key=lambda i: (int(i.confidence), extension_agrees(i), priority(i)),
195
+ reverse=True,
196
+ )
197
+ return ordered
@@ -0,0 +1,35 @@
1
+ """Exceptions for the filesystem contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from oaknut.exception import DataError, ExitCode
6
+ from oaknut.extension import ExtensionError
7
+
8
+
9
+ class FilesystemError(DataError):
10
+ """Base for errors raised by the oaknut filesystem layer."""
11
+
12
+
13
+ class GeometryError(FilesystemError):
14
+ """A geometry specification could not be parsed, or is invalid."""
15
+
16
+
17
+ class ReadOnlyFilesystemError(FilesystemError):
18
+ """A mutating operation was attempted on a read-only mount.
19
+
20
+ Raised by a :class:`~oaknut.filesystem.Mount` whose backing
21
+ filesystem cannot be written (a ZIP archive, say). Carries the
22
+ "operation not permitted" exit code so the CLI reports a clear
23
+ refusal rather than a generic data error.
24
+ """
25
+
26
+ _exit_code = ExitCode.NO_PERM
27
+
28
+
29
+ class FilesystemExtensionError(ExtensionError):
30
+ """A filesystem extension could not be discovered or loaded.
31
+
32
+ A missing or mis-registered filesystem plug-in is an environment
33
+ problem, so this inherits the configuration exit code via
34
+ :class:`~oaknut.extension.ExtensionError`.
35
+ """