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.
- oaknut/filesystem/__init__.py +119 -0
- oaknut/filesystem/capabilities.py +302 -0
- oaknut/filesystem/coordinator.py +197 -0
- oaknut/filesystem/exceptions.py +35 -0
- oaknut/filesystem/filesystem.py +110 -0
- oaknut/filesystem/geometry.py +316 -0
- oaknut/filesystem/identification.py +94 -0
- oaknut/filesystem/reader.py +194 -0
- oaknut_filesystem-12.0.0.dist-info/METADATA +63 -0
- oaknut_filesystem-12.0.0.dist-info/RECORD +13 -0
- oaknut_filesystem-12.0.0.dist-info/WHEEL +5 -0
- oaknut_filesystem-12.0.0.dist-info/licenses/LICENSE +21 -0
- oaknut_filesystem-12.0.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
"""
|