diskstack 1.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.
Files changed (119) hide show
  1. diskstack/__init__.py +5 -0
  2. diskstack/_vendor/__init__.py +0 -0
  3. diskstack/_vendor/greaseweazle/COPYING +24 -0
  4. diskstack/_vendor/greaseweazle/__init__.py +2 -0
  5. diskstack/_vendor/greaseweazle/codec/__init__.py +0 -0
  6. diskstack/_vendor/greaseweazle/codec/amiga/__init__.py +0 -0
  7. diskstack/_vendor/greaseweazle/codec/amiga/amigados.py +221 -0
  8. diskstack/_vendor/greaseweazle/codec/apple2/__init__.py +0 -0
  9. diskstack/_vendor/greaseweazle/codec/apple2/apple2_gcr.py +223 -0
  10. diskstack/_vendor/greaseweazle/codec/bitcell.py +117 -0
  11. diskstack/_vendor/greaseweazle/codec/codec.py +362 -0
  12. diskstack/_vendor/greaseweazle/codec/commodore/__init__.py +0 -0
  13. diskstack/_vendor/greaseweazle/codec/commodore/c64_gcr.py +229 -0
  14. diskstack/_vendor/greaseweazle/codec/datageneral/__init__.py +0 -0
  15. diskstack/_vendor/greaseweazle/codec/datageneral/datageneral.py +211 -0
  16. diskstack/_vendor/greaseweazle/codec/hp/__init__.py +0 -0
  17. diskstack/_vendor/greaseweazle/codec/hp/hp_mmfm.py +258 -0
  18. diskstack/_vendor/greaseweazle/codec/ibm/__init__.py +0 -0
  19. diskstack/_vendor/greaseweazle/codec/ibm/ibm.py +1005 -0
  20. diskstack/_vendor/greaseweazle/codec/macintosh/__init__.py +0 -0
  21. diskstack/_vendor/greaseweazle/codec/macintosh/mac_gcr.py +238 -0
  22. diskstack/_vendor/greaseweazle/codec/micropolis/__init__.py +0 -0
  23. diskstack/_vendor/greaseweazle/codec/micropolis/micropolis.py +233 -0
  24. diskstack/_vendor/greaseweazle/codec/northstar/__init__.py +0 -0
  25. diskstack/_vendor/greaseweazle/codec/northstar/northstar.py +210 -0
  26. diskstack/_vendor/greaseweazle/data/__init__.py +0 -0
  27. diskstack/_vendor/greaseweazle/data/diskdefs.cfg +81 -0
  28. diskstack/_vendor/greaseweazle/data/diskdefs_acorn.cfg +119 -0
  29. diskstack/_vendor/greaseweazle/data/diskdefs_akai.cfg +27 -0
  30. diskstack/_vendor/greaseweazle/data/diskdefs_amiga.cfg +17 -0
  31. diskstack/_vendor/greaseweazle/data/diskdefs_apple2.cfg +31 -0
  32. diskstack/_vendor/greaseweazle/data/diskdefs_apricot.cfg +25 -0
  33. diskstack/_vendor/greaseweazle/data/diskdefs_atari.cfg +33 -0
  34. diskstack/_vendor/greaseweazle/data/diskdefs_atarist.cfg +76 -0
  35. diskstack/_vendor/greaseweazle/data/diskdefs_coco.cfg +98 -0
  36. diskstack/_vendor/greaseweazle/data/diskdefs_commodore.cfg +127 -0
  37. diskstack/_vendor/greaseweazle/data/diskdefs_datageneral.cfg +8 -0
  38. diskstack/_vendor/greaseweazle/data/diskdefs_dec.cfg +37 -0
  39. diskstack/_vendor/greaseweazle/data/diskdefs_dragon.cfg +61 -0
  40. diskstack/_vendor/greaseweazle/data/diskdefs_eagle.cfg +25 -0
  41. diskstack/_vendor/greaseweazle/data/diskdefs_ensoniq.cfg +39 -0
  42. diskstack/_vendor/greaseweazle/data/diskdefs_epson.cfg +95 -0
  43. diskstack/_vendor/greaseweazle/data/diskdefs_gem.cfg +15 -0
  44. diskstack/_vendor/greaseweazle/data/diskdefs_hp.cfg +25 -0
  45. diskstack/_vendor/greaseweazle/data/diskdefs_ibm.cfg +140 -0
  46. diskstack/_vendor/greaseweazle/data/diskdefs_kaypro.cfg +58 -0
  47. diskstack/_vendor/greaseweazle/data/diskdefs_luxor.cfg +96 -0
  48. diskstack/_vendor/greaseweazle/data/diskdefs_mac.cfg +71 -0
  49. diskstack/_vendor/greaseweazle/data/diskdefs_micropolis.cfg +73 -0
  50. diskstack/_vendor/greaseweazle/data/diskdefs_mm1.cfg +69 -0
  51. diskstack/_vendor/greaseweazle/data/diskdefs_msx.cfg +45 -0
  52. diskstack/_vendor/greaseweazle/data/diskdefs_northstar.cfg +37 -0
  53. diskstack/_vendor/greaseweazle/data/diskdefs_occ1.cfg +23 -0
  54. diskstack/_vendor/greaseweazle/data/diskdefs_olivetti.cfg +18 -0
  55. diskstack/_vendor/greaseweazle/data/diskdefs_pc98.cfg +69 -0
  56. diskstack/_vendor/greaseweazle/data/diskdefs_raw.cfg +25 -0
  57. diskstack/_vendor/greaseweazle/data/diskdefs_rm.cfg +98 -0
  58. diskstack/_vendor/greaseweazle/data/diskdefs_sci.cfg +15 -0
  59. diskstack/_vendor/greaseweazle/data/diskdefs_sega.cfg +13 -0
  60. diskstack/_vendor/greaseweazle/data/diskdefs_sharp.cfg +13 -0
  61. diskstack/_vendor/greaseweazle/data/diskdefs_thomson.cfg +78 -0
  62. diskstack/_vendor/greaseweazle/data/diskdefs_tsc.cfg +81 -0
  63. diskstack/_vendor/greaseweazle/data/diskdefs_xerox.cfg +59 -0
  64. diskstack/_vendor/greaseweazle/data/diskdefs_zx.cfg +245 -0
  65. diskstack/_vendor/greaseweazle/error.py +19 -0
  66. diskstack/_vendor/greaseweazle/flux.py +315 -0
  67. diskstack/_vendor/greaseweazle/image/__init__.py +0 -0
  68. diskstack/_vendor/greaseweazle/image/a2r.py +133 -0
  69. diskstack/_vendor/greaseweazle/image/acorn.py +28 -0
  70. diskstack/_vendor/greaseweazle/image/adf.py +15 -0
  71. diskstack/_vendor/greaseweazle/image/apple2.py +18 -0
  72. diskstack/_vendor/greaseweazle/image/apridisk.py +107 -0
  73. diskstack/_vendor/greaseweazle/image/caps.py +464 -0
  74. diskstack/_vendor/greaseweazle/image/d64.py +51 -0
  75. diskstack/_vendor/greaseweazle/image/d81.py +28 -0
  76. diskstack/_vendor/greaseweazle/image/d88.py +211 -0
  77. diskstack/_vendor/greaseweazle/image/dcp.py +45 -0
  78. diskstack/_vendor/greaseweazle/image/dim.py +53 -0
  79. diskstack/_vendor/greaseweazle/image/dmk.py +166 -0
  80. diskstack/_vendor/greaseweazle/image/dsk.py +31 -0
  81. diskstack/_vendor/greaseweazle/image/edsk.py +591 -0
  82. diskstack/_vendor/greaseweazle/image/fd.py +23 -0
  83. diskstack/_vendor/greaseweazle/image/fdi.py +41 -0
  84. diskstack/_vendor/greaseweazle/image/hdm.py +15 -0
  85. diskstack/_vendor/greaseweazle/image/hfe.py +742 -0
  86. diskstack/_vendor/greaseweazle/image/image.py +130 -0
  87. diskstack/_vendor/greaseweazle/image/imd.py +201 -0
  88. diskstack/_vendor/greaseweazle/image/img.py +111 -0
  89. diskstack/_vendor/greaseweazle/image/kryoflux.py +304 -0
  90. diskstack/_vendor/greaseweazle/image/mgt.py +15 -0
  91. diskstack/_vendor/greaseweazle/image/msa.py +153 -0
  92. diskstack/_vendor/greaseweazle/image/nfd.py +113 -0
  93. diskstack/_vendor/greaseweazle/image/nsi.py +47 -0
  94. diskstack/_vendor/greaseweazle/image/scp.py +471 -0
  95. diskstack/_vendor/greaseweazle/image/sf7.py +15 -0
  96. diskstack/_vendor/greaseweazle/image/sharp2d.py +15 -0
  97. diskstack/_vendor/greaseweazle/image/td0.py +141 -0
  98. diskstack/_vendor/greaseweazle/image/xdf.py +15 -0
  99. diskstack/_vendor/greaseweazle/optimised/__init__.py +22 -0
  100. diskstack/_vendor/greaseweazle/tools/__init__.py +0 -0
  101. diskstack/_vendor/greaseweazle/tools/util.py +133 -0
  102. diskstack/_vendor/greaseweazle/track.py +494 -0
  103. diskstack/cache.py +210 -0
  104. diskstack/candidates.py +396 -0
  105. diskstack/cli.py +348 -0
  106. diskstack/errors.py +5 -0
  107. diskstack/filler.py +29 -0
  108. diskstack/formats.py +381 -0
  109. diskstack/parallel.py +73 -0
  110. diskstack/report.py +353 -0
  111. diskstack/stack.py +317 -0
  112. diskstack-1.0.0.dist-info/METADATA +329 -0
  113. diskstack-1.0.0.dist-info/RECORD +119 -0
  114. diskstack-1.0.0.dist-info/WHEEL +5 -0
  115. diskstack-1.0.0.dist-info/entry_points.txt +2 -0
  116. diskstack-1.0.0.dist-info/licenses/LICENSE +21 -0
  117. diskstack-1.0.0.dist-info/licenses/NOTICE +55 -0
  118. diskstack-1.0.0.dist-info/licenses/diskstack/_vendor/greaseweazle/COPYING +24 -0
  119. diskstack-1.0.0.dist-info/top_level.txt +1 -0
diskstack/formats.py ADDED
@@ -0,0 +1,381 @@
1
+ """Disk formats, image files, and working out which is which.
2
+
3
+ diskstack needs one disk format for the whole run: it is the list of sectors
4
+ the disk is supposed to have, and without it a merge cannot say what is
5
+ missing. Auto-detection reads the geometry out of whichever input can state
6
+ it most directly -- an IMG boot sector, an IMD track header, or failing that
7
+ a scan of the first few cylinders of a flux capture.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import functools
13
+ import struct
14
+ from pathlib import Path
15
+ from typing import Dict, Iterable, List, Optional, Sequence, Tuple
16
+
17
+ from diskstack._vendor.greaseweazle import error as gw_error
18
+ from diskstack._vendor.greaseweazle.codec import codec as gw_codec
19
+ from diskstack._vendor.greaseweazle.codec.amiga import amigados
20
+ from diskstack._vendor.greaseweazle.codec.ibm import ibm
21
+ from diskstack._vendor.greaseweazle.image.adf import ADF
22
+ from diskstack._vendor.greaseweazle.image.hfe import HFE
23
+ from diskstack._vendor.greaseweazle.image.imd import IMD
24
+ from diskstack._vendor.greaseweazle.image.img import IMG
25
+ from diskstack._vendor.greaseweazle.image.kryoflux import KryoFlux
26
+ from diskstack._vendor.greaseweazle.image.scp import SCP
27
+ from diskstack._vendor.greaseweazle.track import PLL
28
+
29
+ from diskstack.errors import DiskStackError
30
+
31
+ # v1 is IBM FM/MFM and AmigaDOS MFM only. ibm.scan is excluded on purpose:
32
+ # it makes each track its own format, so it cannot state what the disk should
33
+ # contain, which is the one thing a merge needs.
34
+ SUPPORTED_PREFIXES = ('ibm.', 'amiga.')
35
+ EXCLUDED_FORMATS = frozenset({'ibm.scan'})
36
+ SCAN_CYLS = 5
37
+ PROBE_CYLS = 84
38
+
39
+ READERS = {'.scp': (SCP, 'scp'), '.img': (IMG, 'img'), '.ima': (IMG, 'img'),
40
+ '.adf': (ADF, 'adf'), '.imd': (IMD, 'imd'), '.st': (IMG, 'img'),
41
+ '.raw': (KryoFlux, 'kryoflux'), '.hfe': (HFE, 'hfe')}
42
+ WRITERS = {'.img': IMG, '.ima': IMG, '.adf': ADF, '.imd': IMD, '.st': IMG}
43
+
44
+ # Inputs that have to be decoded, and inputs that are a flat run of sector
45
+ # payloads with no per-sector framing at all.
46
+ FLUX_KINDS = frozenset({'scp', 'kryoflux', 'hfe'})
47
+ FLAT_KINDS = frozenset({'img', 'adf'})
48
+
49
+ # Every standard geometry this tool supports has a distinct raw image size.
50
+ SIZE_FORMATS = {
51
+ 163840: 'ibm.160', 184320: 'ibm.180', 327680: 'ibm.320',
52
+ 368640: 'ibm.360', 737280: 'ibm.720', 819200: 'ibm.800',
53
+ 901120: 'amiga.amigados', 1228800: 'ibm.1200', 1474560: 'ibm.1440',
54
+ 1720320: 'ibm.1680', 1802240: 'amiga.amigados_hd', 2949120: 'ibm.2880',
55
+ }
56
+
57
+
58
+ def supported_formats() -> List[str]:
59
+ """Names of the disk formats diskstack can merge."""
60
+ all_fmts = gw_codec.get_all_formats('', gw_codec.DiskDef_File(name=None))
61
+ return sorted(f for f in all_fmts
62
+ if f.startswith(SUPPORTED_PREFIXES)
63
+ and f not in EXCLUDED_FORMATS)
64
+
65
+
66
+ def get_format(name: str) -> gw_codec.DiskDef:
67
+ """Look up a disk format by name, or explain why it is not usable."""
68
+ if name in EXCLUDED_FORMATS or not name.startswith(SUPPORTED_PREFIXES):
69
+ raise DiskStackError(
70
+ f"Unsupported format '{name}'. diskstack v1 handles IBM FM/MFM "
71
+ f"and AmigaDOS MFM only.\nSupported: "
72
+ f"{', '.join(supported_formats())}")
73
+ try:
74
+ fmt = gw_codec.get_diskdef(name)
75
+ except gw_error.Fatal as exc:
76
+ raise DiskStackError(f"Bad format '{name}': {exc}") from exc
77
+ if fmt is None:
78
+ raise DiskStackError(
79
+ f"Unknown format '{name}'.\nSupported: "
80
+ f"{', '.join(supported_formats())}")
81
+ return fmt
82
+
83
+
84
+ class Geometry:
85
+ """Enough shape to pick a disk format: cylinders, heads, sectors, size."""
86
+
87
+ def __init__(self, cyls: int, heads: int, nsec: int, secsz: int,
88
+ amiga: bool = False) -> None:
89
+ self.cyls, self.heads = cyls, heads
90
+ self.nsec, self.secsz, self.amiga = nsec, secsz, amiga
91
+
92
+ def __str__(self) -> str:
93
+ kind = 'AmigaDOS' if self.amiga else 'IBM'
94
+ return (f'{kind} {self.cyls} cyls, {self.heads} heads, '
95
+ f'{self.nsec} sectors of {self.secsz} bytes')
96
+
97
+
98
+ @functools.lru_cache(maxsize=None)
99
+ def _format_geometry(name: str) -> Optional[Geometry]:
100
+ fmt = gw_codec.get_diskdef(name)
101
+ track = fmt.mk_track(0, 0)
102
+ if track is None:
103
+ return None
104
+ if isinstance(track, amigados.AmigaDOS):
105
+ return Geometry(fmt.cyls, fmt.heads, track.nsec, 512, amiga=True)
106
+ sizes = {len(s.dam.data) for s in track.sectors}
107
+ if len(sizes) != 1:
108
+ return None
109
+ return Geometry(fmt.cyls, fmt.heads, len(track.sectors), sizes.pop())
110
+
111
+
112
+ def match_geometry(geom: Geometry) -> Optional[str]:
113
+ """Name of the supported format matching ``geom`` exactly, if any."""
114
+ for name in supported_formats():
115
+ other = _format_geometry(name)
116
+ if other is None:
117
+ continue
118
+ if (other.cyls, other.heads, other.nsec, other.secsz, other.amiga) == \
119
+ (geom.cyls, geom.heads, geom.nsec, geom.secsz, geom.amiga):
120
+ return name
121
+ return None
122
+
123
+
124
+ def _bpb_geometry(dat: bytes) -> Optional[Geometry]:
125
+ """Geometry from a DOS BIOS Parameter Block, if the boot sector has one."""
126
+ if len(dat) < 512:
127
+ return None
128
+ bps, = struct.unpack('<H', dat[11:13])
129
+ total, = struct.unpack('<H', dat[19:21])
130
+ spt, heads = struct.unpack('<HH', dat[24:28])
131
+ if bps not in (128, 256, 512, 1024) or spt == 0 or heads not in (1, 2):
132
+ return None
133
+ if total == 0 or total * bps != len(dat) or total % (spt * heads):
134
+ return None
135
+ return Geometry(total // (spt * heads), heads, spt, bps)
136
+
137
+
138
+ def _flux_extent(image) -> Optional[Tuple[int, int]]:
139
+ """(cylinders, heads) a flux container holds, found by probing for tracks.
140
+
141
+ Flux readers do not agree on how to list their tracks, and the KryoFlux
142
+ one cannot list them at all: it goes looking for a file per track.
143
+ """
144
+ top = next((cyl for cyl in reversed(range(PROBE_CYLS))
145
+ if image.get_track(cyl, 0) is not None
146
+ or image.get_track(cyl, 1) is not None), None)
147
+ if top is None:
148
+ return None
149
+ return top + 1, 2 if image.get_track(0, 1) is not None else 1
150
+
151
+
152
+ def _scan_flux_geometry(path: Path, pll: Optional[PLL]) -> Optional[Geometry]:
153
+ """Decode the first few cylinders of a flux capture to see what is there.
154
+
155
+ A track with a damaged sector reports one sector fewer than the disk
156
+ really has, which would pick a smaller format, so several tracks are
157
+ scanned and the fullest one wins.
158
+ """
159
+ reader, _ = READERS[path.suffix.lower()]
160
+ image = reader.from_file(str(path), None, {})
161
+ extent = _flux_extent(image)
162
+ if extent is None:
163
+ return None
164
+ cyls, heads = extent
165
+
166
+ scan_fmt = gw_codec.get_diskdef('ibm.scan')
167
+ best = None
168
+ for cyl in range(min(SCAN_CYLS, cyls)):
169
+ for head in range(heads):
170
+ flux = image.get_track(cyl, head)
171
+ if flux is None:
172
+ continue
173
+ track = scan_fmt.mk_track(cyl, head)
174
+ track.decode_flux(flux)
175
+ inner = track.track
176
+ sizes = {len(s.dam.data) for s in inner.sectors}
177
+ if inner.sectors and len(sizes) == 1:
178
+ geom = Geometry(cyls, heads, len(inner.sectors), sizes.pop())
179
+ if best is None or geom.nsec > best.nsec:
180
+ best = geom
181
+ elif best is None:
182
+ # No IBM sectors: try AmigaDOS, which uses a different sync.
183
+ for cls in (amigados.AmigaDOS_DD, amigados.AmigaDOS_HD):
184
+ proto = cls(cyl, head)
185
+ proto.decode_flux(flux, pll)
186
+ if proto.nr_missing() < proto.nsec:
187
+ return Geometry(cyls, heads, proto.nsec, 512,
188
+ amiga=True)
189
+ return best
190
+
191
+
192
+ def _imd_geometry(path: Path) -> Optional[Geometry]:
193
+ image = IMD.from_file(str(path), None, {})
194
+ if not image.to_track:
195
+ return None
196
+ cyls = max(c for c, _ in image.to_track) + 1
197
+ heads = max(h for _, h in image.to_track) + 1
198
+ track = image.to_track[min(image.to_track)]
199
+ sizes = {len(s.dam.data) for s in track.sectors}
200
+ if len(sizes) != 1:
201
+ return None
202
+ return Geometry(cyls, heads, len(track.sectors), sizes.pop())
203
+
204
+
205
+ def detect_format(paths: Sequence[Path],
206
+ pll: Optional[PLL] = None) -> Tuple[str, str]:
207
+ """Guess the disk format from the inputs.
208
+
209
+ Returns ``(format_name, how_it_was_detected)``. Sector images go first:
210
+ their size and boot sector say the geometry outright, where a flux capture
211
+ has to be decoded to find out.
212
+ """
213
+ ordered = sorted(paths, key=is_flux)
214
+ tried = []
215
+ for path in ordered:
216
+ kind = READERS.get(path.suffix.lower(), (None, None))[1]
217
+ try:
218
+ if kind in FLAT_KINDS:
219
+ size = path.stat().st_size
220
+ # An ADF is pure sector data, with no boot sector to read.
221
+ geom = (None if kind == 'adf'
222
+ else _bpb_geometry(path.read_bytes()))
223
+ if geom is not None and (name := match_geometry(geom)):
224
+ return name, f'{path.name} boot sector ({geom})'
225
+ if size in SIZE_FORMATS:
226
+ return SIZE_FORMATS[size], f'{path.name} size ({size} bytes)'
227
+ tried.append(f'{path.name}: {size} bytes matches no format')
228
+ continue
229
+ if kind == 'imd':
230
+ geom = _imd_geometry(path)
231
+ elif kind in FLUX_KINDS:
232
+ geom = _scan_flux_geometry(path, pll)
233
+ else:
234
+ continue
235
+ except (gw_error.Fatal, OSError, struct.error) as exc:
236
+ tried.append(f'{path.name}: {exc}')
237
+ continue
238
+ if geom is None:
239
+ tried.append(f'{path.name}: no sectors decoded on its first cylinders')
240
+ continue
241
+ name = match_geometry(geom)
242
+ if name is not None:
243
+ return name, f'{path.name} ({geom})'
244
+ tried.append(f'{path.name}: {geom} matches no supported format')
245
+
246
+ detail = '\n '.join(tried) if tried else 'no usable inputs'
247
+ raise DiskStackError(
248
+ 'Could not work out the disk format automatically:\n ' + detail
249
+ + '\nPass --format explicitly, e.g. --format ibm.720.\nSupported: '
250
+ + ', '.join(supported_formats()))
251
+
252
+
253
+ def kind_of(path: Path) -> Optional[str]:
254
+ """Which reader this extension maps to, or None if diskstack has none."""
255
+ return READERS.get(path.suffix.lower(), (None, None))[1]
256
+
257
+
258
+ def is_flux(path: Path) -> bool:
259
+ """True if this input has to be decoded rather than simply read."""
260
+ return kind_of(path) in FLUX_KINDS
261
+
262
+
263
+ def input_files(path: Path, kind: str) -> List[Path]:
264
+ """Every file one input is made of. A KryoFlux input names one of a set.
265
+
266
+ The set is enumerated the way the reader opens it, one name per track, so
267
+ a second capture sitting in the same directory is not part of this one.
268
+ """
269
+ if kind != 'kryoflux':
270
+ return [path]
271
+ base = KryoFlux(str(path), None).basename
272
+ return [f for f in (Path(f'{base}{cyl:02d}.{head}.raw')
273
+ for cyl in range(PROBE_CYLS) for head in (0, 1))
274
+ if f.exists()]
275
+
276
+
277
+ def input_size(path: Path, kind: str) -> int:
278
+ """Bytes an input occupies, the whole stream set for a KryoFlux one."""
279
+ return sum(f.stat().st_size for f in input_files(path, kind))
280
+
281
+
282
+ def check_readable(path: Path) -> str:
283
+ """Kind of input this is, rejecting one diskstack cannot open at all.
284
+
285
+ Called for every input before any of them is decoded, so a typo in the
286
+ last filename does not cost a capture's worth of decoding first.
287
+ """
288
+ if not path.exists():
289
+ raise DiskStackError(f'{path}: no such file')
290
+ if path.is_dir():
291
+ raise DiskStackError(f'{path}: is a directory, not a disk image')
292
+ entry = READERS.get(path.suffix.lower())
293
+ if entry is None:
294
+ raise DiskStackError(
295
+ f'{path}: unrecognised extension. diskstack reads '
296
+ + ', '.join(sorted(READERS)))
297
+ if entry[1] == 'kryoflux':
298
+ # The name is how the reader finds the rest of the set, so it is part
299
+ # of the input rather than a detail of this one file.
300
+ try:
301
+ KryoFlux(str(path), None)
302
+ except gw_error.Fatal as exc:
303
+ raise DiskStackError(
304
+ f'{path}: a KryoFlux input is named for one track of a set, '
305
+ f'like track00.0.raw. Name any one of them and diskstack '
306
+ f'reads the rest of the set from the same directory.') from exc
307
+ return entry[1]
308
+
309
+
310
+ def open_image(path: Path, fmt: gw_codec.DiskDef):
311
+ """Open one input file, returning ``(image, kind)``."""
312
+ kind = check_readable(path)
313
+ cls = READERS[path.suffix.lower()][0]
314
+ try:
315
+ return cls.from_file(str(path), fmt, {}), kind
316
+ except gw_error.Fatal as exc:
317
+ raise DiskStackError(f'{path}: {exc}') from exc
318
+ except (struct.error, IndexError) as exc:
319
+ raise DiskStackError(f'{path}: truncated or corrupt {kind} file '
320
+ f'({exc})') from exc
321
+ except OSError as exc:
322
+ raise DiskStackError(f'{path}: {exc.strerror or exc}') from exc
323
+
324
+
325
+ def fill_track(track, data: Dict[int, bytes], bad: Iterable[int]):
326
+ """Put merged sector payloads into a freshly-made track object."""
327
+ bad = set(bad)
328
+ if isinstance(track, amigados.AmigaDOS):
329
+ track.map = list(range(track.nsec))
330
+ for sec_id in range(track.nsec):
331
+ payload = data.get(sec_id)
332
+ if payload is not None:
333
+ track.sector[sec_id] = bytes(16), payload
334
+ return track
335
+ for sec in track.sectors:
336
+ payload = data.get(sec.idam.r)
337
+ if payload is None:
338
+ continue
339
+ sec.dam.data = payload
340
+ # IMD carries a per-sector error flag and writes it from dam.crc, so
341
+ # an unresolved sector survives a round trip through .imd.
342
+ bad_crc = 0xffff if sec.idam.r in bad else 0
343
+ sec.crc = sec.idam.crc = sec.dam.crc = bad_crc
344
+ return track
345
+
346
+
347
+ def check_writable(path: Path) -> None:
348
+ """Reject an output path now rather than after decoding every input."""
349
+ if path.suffix.lower() not in WRITERS:
350
+ raise DiskStackError(
351
+ f'{path}: cannot write that format. diskstack writes '
352
+ + ', '.join(sorted(WRITERS)))
353
+ if path.parent and not path.parent.exists():
354
+ raise DiskStackError(f'{path.parent}: output directory does not exist')
355
+
356
+
357
+ def write_image(path: Path, fmt: gw_codec.DiskDef,
358
+ data: Dict[Tuple[int, int], Dict[int, bytes]],
359
+ bad: Dict[Tuple[int, int], Iterable[int]]) -> None:
360
+ """Write the merged sectors out as a sector image."""
361
+ check_writable(path)
362
+ cls = WRITERS[path.suffix.lower()]
363
+ try:
364
+ with cls.to_file(str(path), fmt, False, {}) as out:
365
+ for cyl, head in [(t.cyl, t.head) for t in fmt.tracks]:
366
+ track = fmt.mk_track(cyl, head)
367
+ if track is None:
368
+ continue
369
+ fill_track(track, data.get((cyl, head), {}),
370
+ bad.get((cyl, head), ()))
371
+ out.emit_track(cyl, head, track)
372
+ except gw_error.Fatal as exc:
373
+ raise DiskStackError(f'{path}: {exc}') from exc
374
+ except OSError as exc:
375
+ raise DiskStackError(f'{path}: {exc.strerror or exc}') from exc
376
+
377
+
378
+ __all__ = ['Geometry', 'check_readable', 'check_writable', 'detect_format',
379
+ 'fill_track', 'get_format', 'ibm', 'input_files', 'input_size',
380
+ 'is_flux', 'kind_of', 'match_geometry', 'open_image',
381
+ 'supported_formats', 'write_image']
diskstack/parallel.py ADDED
@@ -0,0 +1,73 @@
1
+ """Decode a flux capture one track per worker process.
2
+
3
+ Nearly all of a run is the PLL walking flux, and tracks are independent, so
4
+ this is the one place worth parallelising. Results are collected in track
5
+ order, so a parallel run produces byte-identical candidates to a serial one.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from concurrent.futures import ProcessPoolExecutor
12
+ from pathlib import Path
13
+ from typing import Callable, List, Optional, Tuple
14
+
15
+ from diskstack._vendor.greaseweazle.codec import codec as gw_codec
16
+ from diskstack._vendor.greaseweazle.track import PLL
17
+ from diskstack.candidates import (Candidate, SourceInfo, track_candidates,
18
+ track_list)
19
+
20
+ MAX_JOBS = 8
21
+
22
+ _worker: dict = {}
23
+
24
+
25
+ def default_jobs() -> int:
26
+ """Worker count to use when the user did not ask for one."""
27
+ return max(1, min(os.cpu_count() or 1, MAX_JOBS))
28
+
29
+
30
+ def _init(path: Path, fmt: gw_codec.DiskDef, pll: Optional[PLL],
31
+ revs: Optional[int]) -> None:
32
+ from diskstack import formats
33
+
34
+ image, kind = formats.open_image(path, fmt)
35
+ _worker.update(path=path, fmt=fmt, image=image, kind=kind, pll=pll,
36
+ revs=revs)
37
+
38
+
39
+ def _decode(track: Tuple[int, int]) -> Tuple[List[Candidate], SourceInfo]:
40
+ cyl, head = track
41
+ info = SourceInfo(path=_worker['path'], kind=_worker['kind'])
42
+ cands = track_candidates(_worker['image'], _worker['fmt'], cyl, head,
43
+ _worker['path'], info, _worker['pll'],
44
+ _worker['revs'])
45
+ return cands, info
46
+
47
+
48
+ def merge_info(into: SourceInfo, part: SourceInfo) -> None:
49
+ """Fold one track's tally into the tally for the whole file."""
50
+ into.revolutions = max(into.revolutions, part.revolutions)
51
+ into.candidates += part.candidates
52
+ into.good += part.good
53
+ into.unexpected += part.unexpected
54
+
55
+
56
+ def flux_candidates(path: Path, fmt: gw_codec.DiskDef, info: SourceInfo, *,
57
+ pll: Optional[PLL] = None, revs: Optional[int] = None,
58
+ jobs: int = 2,
59
+ on_track: Optional[Callable[[], None]] = None
60
+ ) -> List[Candidate]:
61
+ """Same result as :func:`diskstack.candidates.flux_candidates`, in
62
+ parallel."""
63
+ tracks = track_list(fmt)
64
+ out: List[Candidate] = []
65
+ with ProcessPoolExecutor(max_workers=min(jobs, len(tracks) or 1),
66
+ initializer=_init,
67
+ initargs=(path, fmt, pll, revs)) as pool:
68
+ for cands, part in pool.map(_decode, tracks, chunksize=2):
69
+ merge_info(info, part)
70
+ out += cands
71
+ if on_track is not None:
72
+ on_track()
73
+ return out