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/cli.py ADDED
@@ -0,0 +1,348 @@
1
+ """Command line: read the dumps, stack them, write the image and the report."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from concurrent.futures.process import BrokenProcessPool
7
+ from contextlib import contextmanager
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import List, Optional, Sequence, Tuple
11
+
12
+ import click
13
+
14
+ from diskstack import (__version__, cache, candidates, formats, parallel,
15
+ report, stack)
16
+ from diskstack._vendor.greaseweazle.track import PLL
17
+ from diskstack.errors import DiskStackError
18
+ from diskstack.filler import make_filler
19
+
20
+ FILL_CHOICES = ('best', 'filler', 'zero')
21
+
22
+
23
+ @dataclass
24
+ class Decode:
25
+ """Everything that decides what reading one input produces."""
26
+
27
+ fmt: object
28
+ fmt_name: str
29
+ plls: List[Optional[PLL]] = field(default_factory=lambda: [None])
30
+ revs: Optional[int] = None
31
+ detect_filler: bool = True
32
+ jobs: int = 1
33
+ cache_dir: Optional[Path] = None
34
+
35
+
36
+ def _parse_plls(specs: Sequence[str]) -> List[Optional[PLL]]:
37
+ """Turn ``--pll`` strings into PLL objects; no flag means the default PLL."""
38
+ if not specs:
39
+ return [None]
40
+ plls = []
41
+ for spec in specs:
42
+ try:
43
+ plls.append(PLL(spec))
44
+ except (ValueError, AttributeError) as exc:
45
+ raise DiskStackError(
46
+ f"Bad --pll '{spec}'. Expected key=value pairs joined by ':', "
47
+ f"from period=<pct>, phase=<pct>, lowpass=<us>, "
48
+ f"e.g. --pll period=5:phase=60") from exc
49
+ return plls
50
+
51
+
52
+ @contextmanager
53
+ def reading(path: Path, steps: int, quiet: bool):
54
+ """Show progress while a file is read. Decoding flux takes real time."""
55
+ if quiet:
56
+ yield None
57
+ return
58
+ with click.progressbar(length=steps, file=sys.stderr,
59
+ label=f'Reading {path.name}') as bar:
60
+ yield lambda: bar.update(1)
61
+
62
+
63
+ def decode_one(path: Path, opts: Decode, pll: Optional[PLL], on_track
64
+ ) -> Tuple[List[candidates.Candidate], candidates.SourceInfo]:
65
+ """One pass over one file, falling back to one process if workers fail."""
66
+ try:
67
+ return candidates.load(path, opts.fmt, pll=pll, revs=opts.revs,
68
+ detect_filler=opts.detect_filler,
69
+ jobs=opts.jobs, on_track=on_track)
70
+ except (BrokenProcessPool, ImportError, OSError, RuntimeError) as exc:
71
+ if opts.jobs <= 1:
72
+ raise
73
+ click.echo(f'Parallel decode unavailable ({exc}); using one process.',
74
+ err=True)
75
+ return candidates.load(path, opts.fmt, pll=pll, revs=opts.revs,
76
+ detect_filler=opts.detect_filler, jobs=1,
77
+ on_track=on_track)
78
+
79
+
80
+ def cache_name(path: Path, opts: Decode, pll: Optional[PLL]) -> Optional[str]:
81
+ """Entry this decode belongs under, or None if it is not worth caching.
82
+
83
+ Only flux is: reading a sector image is a file read, so an entry for one
84
+ would cost about what it saves.
85
+ """
86
+ if opts.cache_dir is None or not formats.is_flux(path):
87
+ return None
88
+ try:
89
+ return cache.key(formats.input_files(path, formats.kind_of(path)),
90
+ cache.settings(opts.fmt_name, pll, opts.revs,
91
+ opts.detect_filler))
92
+ except OSError:
93
+ return None
94
+
95
+
96
+ def read_one(path: Path, opts: Decode, pll: Optional[PLL], on_track
97
+ ) -> Tuple[List[candidates.Candidate], candidates.SourceInfo]:
98
+ """One pass over one file, taken from the cache when it is already there."""
99
+ name = cache_name(path, opts, pll)
100
+ if name is not None:
101
+ hit = cache.load(opts.cache_dir, name, path)
102
+ if hit is not None:
103
+ if on_track is not None:
104
+ for _ in candidates.track_list(opts.fmt):
105
+ on_track()
106
+ return hit
107
+ cands, info = decode_one(path, opts, pll, on_track)
108
+ if name is not None:
109
+ cache.store(opts.cache_dir, name, cands, info)
110
+ return cands, info
111
+
112
+
113
+ def disk_format(paths: Sequence[Path], pll: Optional[PLL],
114
+ cache_dir: Optional[Path]) -> Tuple[str, str]:
115
+ """The disk format, remembered from the last run over the same inputs.
116
+
117
+ Detection decodes the first cylinders of a flux capture, which with the
118
+ decodes themselves cached is the slowest thing left in a repeat run.
119
+ """
120
+ name = None
121
+ if cache_dir is not None:
122
+ files = [f for path in paths
123
+ for f in formats.input_files(path, formats.kind_of(path))]
124
+ try:
125
+ name = cache.key(files, cache.settings('detect', pll, None, True))
126
+ except OSError:
127
+ name = None
128
+ known = (cache.recall(cache_dir, name) or {}) if name else {}
129
+ if (isinstance(known.get('format'), str)
130
+ and isinstance(known.get('detail'), str)):
131
+ return known['format'], known['detail']
132
+ fmt_name, detail = formats.detect_format(paths, pll)
133
+ if name is not None:
134
+ cache.note(cache_dir, name, {'format': fmt_name, 'detail': detail})
135
+ return fmt_name, detail
136
+
137
+
138
+ def load_source(path: Path, opts: Decode, on_track=None
139
+ ) -> Tuple[List[candidates.Candidate], candidates.SourceInfo]:
140
+ """Read one input file, decoding flux once per requested PLL setting.
141
+
142
+ Two PLLs disagreeing about a marginal bitcell give two independent
143
+ attempts at the same sector, which is exactly what the vote wants.
144
+ """
145
+ cands, info = read_one(path, opts, opts.plls[0], on_track)
146
+ if info.kind not in formats.FLUX_KINDS:
147
+ return cands, info
148
+ for pll in opts.plls[1:]:
149
+ more, extra = read_one(path, opts, pll, on_track)
150
+ cands += more
151
+ parallel.merge_info(info, extra)
152
+ info.cached = info.cached and extra.cached
153
+ return cands, info
154
+
155
+
156
+ def apply_fill(result: stack.StackResult, how: str) -> None:
157
+ """Replace the payload of sectors diskstack could not confirm."""
158
+ if how == 'best':
159
+ return
160
+ for res in result.resolutions:
161
+ if res.resolved:
162
+ continue
163
+ res.data = (bytes(res.size) if how == 'zero'
164
+ else make_filler(res.size))
165
+
166
+
167
+ def _list_formats(ctx, param, value):
168
+ if not value or ctx.resilient_parsing:
169
+ return
170
+ click.echo('\n'.join(formats.supported_formats()))
171
+ ctx.exit()
172
+
173
+
174
+ @click.command(context_settings={'help_option_names': ['-h', '--help']})
175
+ @click.argument('inputs', nargs=-1, required=True,
176
+ type=click.Path(path_type=Path))
177
+ @click.option('-o', '--output', required=True, type=click.Path(path_type=Path),
178
+ help='Merged sector image to write (.img, .ima, .st, .adf, '
179
+ '.imd).')
180
+ @click.option('-r', '--report', 'report_path', type=click.Path(path_type=Path),
181
+ help='JSON report path [default: diskstack-report.json beside '
182
+ 'the output]')
183
+ @click.option('--no-report', is_flag=True, help='Do not write the JSON report.')
184
+ @click.option('-f', '--format', 'fmt_name',
185
+ help='Disk format, e.g. ibm.720 or amiga.amigados. Detected '
186
+ 'from the inputs when not given.')
187
+ @click.option('--list-formats', is_flag=True, is_eager=True, expose_value=False,
188
+ callback=_list_formats,
189
+ help='Print the disk formats diskstack understands and exit.')
190
+ @click.option('--pll', 'pll_specs', multiple=True, metavar='SPEC',
191
+ help='Flux PLL settings, e.g. period=5:phase=60:lowpass=1.5. '
192
+ 'Repeat to decode each capture several ways and let the '
193
+ 'results compete.')
194
+ @click.option('--revs', type=click.IntRange(min=1), metavar='N',
195
+ help='Use only the first N revolutions of each flux capture '
196
+ '[default: all of them]')
197
+ @click.option('--keep-filler', is_flag=True,
198
+ help="Trust sectors written as '-=[BAD SECTOR]=-' filler "
199
+ 'instead of treating them as failed reads.')
200
+ @click.option('--fill-unresolved', type=click.Choice(FILL_CHOICES),
201
+ default='best', show_default=True,
202
+ help='What to write for a sector no input confirmed: its best '
203
+ 'guess, bad-sector filler, or zeroes.')
204
+ @click.option('--no-vote', is_flag=True,
205
+ help='Skip the byte-wise majority vote; keep only sectors whose '
206
+ 'own CRC passes.')
207
+ @click.option('-j', '--jobs', type=click.IntRange(min=0), metavar='N',
208
+ default=0, help='Worker processes for decoding flux '
209
+ '[default: one per core, up to '
210
+ f'{parallel.MAX_JOBS}]')
211
+ @click.option('--cache', 'cache_dir', type=click.Path(path_type=Path),
212
+ metavar='DIR',
213
+ help='Where to keep decoded flux between runs [default: '
214
+ '.diskstack-cache beside the output]')
215
+ @click.option('--no-cache', is_flag=True,
216
+ help='Decode every flux capture again instead of reusing the '
217
+ 'decode a previous run stored.')
218
+ @click.option('--retry-name', default='retry.scp', show_default=True,
219
+ metavar='NAME', help='Filename used in the printed gw read '
220
+ 'command.')
221
+ @click.option('-q', '--quiet', is_flag=True,
222
+ help='Print the summary and the re-read command, no tables.')
223
+ @click.version_option(__version__, '-V', '--version', prog_name='diskstack')
224
+ def main(**opts):
225
+ """Merge several dumps of one floppy disk into the best possible image.
226
+
227
+ INPUTS are two or more files of the same disk, any mix of flux captures
228
+ (.scp, .raw, .hfe) and decoded sector images (.img, .ima, .st, .adf,
229
+ .imd):
230
+
231
+ diskstack disk_a.scp disk_b.scp disk_c.img -o merged.img
232
+
233
+ Every sector is taken from whichever read got a passing CRC. Sectors no
234
+ read got right are rebuilt from the attempts and kept only if the result
235
+ satisfies a check value off the disk, and diskstack prints a gw read
236
+ command naming only the tracks that are still bad, so the next pass over
237
+ the disk is short.
238
+ """
239
+ try:
240
+ run(**opts)
241
+ except DiskStackError as exc:
242
+ raise click.ClickException(str(exc)) from exc
243
+ except KeyboardInterrupt:
244
+ click.echo('Interrupted.', err=True)
245
+ raise SystemExit(130) from None
246
+
247
+
248
+ def run(inputs, output, report_path, no_report, fmt_name, pll_specs, revs,
249
+ keep_filler, fill_unresolved, no_vote, jobs, cache_dir, no_cache,
250
+ retry_name, quiet):
251
+ paths = [Path(p) for p in inputs]
252
+ if len(paths) < 2:
253
+ raise DiskStackError(
254
+ 'diskstack needs two or more dumps of the same disk; '
255
+ f'got {len(paths)}.\nWith one dump there is nothing to stack.')
256
+ seen = set()
257
+ for path in paths:
258
+ resolved = path.resolve()
259
+ if resolved in seen:
260
+ raise DiskStackError(f'{path}: listed twice. Each dump can only '
261
+ f'count once towards the vote.')
262
+ seen.add(resolved)
263
+ formats.check_readable(path)
264
+ if output.resolve() in seen:
265
+ raise DiskStackError(f'{output}: that is one of the inputs. Writing '
266
+ f'the merge over a dump would destroy it.')
267
+ if report_path is not None and report_path.resolve() in seen:
268
+ raise DiskStackError(f'{report_path}: that is one of the inputs.')
269
+ if report_path is not None and report_path.resolve() == output.resolve():
270
+ raise DiskStackError(f'{report_path}: that is the merged image. The '
271
+ f'report would be written over it.')
272
+ if cache_dir is not None and cache_dir.exists() and not cache_dir.is_dir():
273
+ raise DiskStackError(f'{cache_dir}: --cache wants a directory to keep '
274
+ f'decoded flux in.')
275
+ formats.check_writable(output)
276
+
277
+ plls = _parse_plls(pll_specs)
278
+ cache_dir = (None if no_cache
279
+ else cache_dir or output.parent / cache.DIR_NAME)
280
+
281
+ if fmt_name:
282
+ fmt, detail = formats.get_format(fmt_name), 'given with --format'
283
+ else:
284
+ fmt_name, detail = disk_format(paths, plls[0], cache_dir)
285
+ fmt = formats.get_format(fmt_name)
286
+
287
+ opts = Decode(fmt=fmt, fmt_name=fmt_name, plls=plls, revs=revs,
288
+ detect_filler=not keep_filler,
289
+ jobs=jobs or parallel.default_jobs(), cache_dir=cache_dir)
290
+
291
+ all_cands, sources = [], []
292
+ tracks = len(candidates.track_list(fmt))
293
+ for path in paths:
294
+ steps = tracks * len(plls) if formats.is_flux(path) else 1
295
+ with reading(path, steps, quiet) as on_track:
296
+ cands, info = load_source(path, opts, on_track)
297
+ if on_track is not None and not formats.is_flux(path):
298
+ on_track()
299
+ all_cands += cands
300
+ sources.append(info)
301
+
302
+ if not all_cands:
303
+ raise DiskStackError(
304
+ 'No sectors could be read from any input. Either these are not '
305
+ f'{fmt_name} disks or every track failed to decode.\n'
306
+ 'Try --format, or --pll lowpass=1.5 for a weak capture.')
307
+
308
+ expected = candidates.expected_sectors(fmt)
309
+ sizes = {key: candidates.sector_size(fmt, *key) for key in expected}
310
+ for info in sources:
311
+ if info.kind in formats.FLAT_KINDS:
312
+ info.expected_size = sum(sizes.values())
313
+ result = stack.stack(all_cands, expected, sizes,
314
+ [info.path for info in sources], vote=not no_vote)
315
+ apply_fill(result, fill_unresolved)
316
+
317
+ formats.write_image(output, fmt, result.sector_data(),
318
+ result.bad_sectors())
319
+
320
+ progress = None
321
+ if not no_report:
322
+ target = report_path or output.parent / 'diskstack-report.json'
323
+ # Read before writing: the file about to be overwritten is the last
324
+ # run of this same command, which is what the loop wants compared.
325
+ previous = report.read_previous(target)
326
+ if previous is not None:
327
+ progress = report.compare(previous, result)
328
+ data = report.build(result, sources, fmt_name, detail, output,
329
+ retry_name, progress)
330
+ try:
331
+ report.write(data, target)
332
+ except OSError as exc:
333
+ raise DiskStackError(f'{target}: {exc.strerror or exc}') from exc
334
+
335
+ click.echo(report.render(result, sources, fmt_name, detail, retry_name,
336
+ tables=not quiet, progress=progress))
337
+ click.echo('')
338
+ written = [f'{output} ({output.stat().st_size} bytes)']
339
+ if not no_report:
340
+ written.append(str(target))
341
+ click.echo('Wrote ' + ' and '.join(written))
342
+
343
+ if result.counts()[stack.UNRESOLVED] or result.counts()[stack.MISSING]:
344
+ raise SystemExit(2)
345
+
346
+
347
+ if __name__ == '__main__':
348
+ main()
diskstack/errors.py ADDED
@@ -0,0 +1,5 @@
1
+ """Errors that reach the user as a message rather than a traceback."""
2
+
3
+
4
+ class DiskStackError(Exception):
5
+ """Anything the user can act on: bad input, unknown format, missing file."""
diskstack/filler.py ADDED
@@ -0,0 +1,29 @@
1
+ """Detection of Greaseweazle's bad-sector filler inside decoded images.
2
+
3
+ A sector image has nowhere to record "this sector was unreadable", so
4
+ Greaseweazle writes ``-=[BAD SECTOR]=-`` repeated across the whole sector
5
+ instead. Reading such an image back gives a sector that looks perfectly
6
+ valid, which is the complaint in greaseweazle issues #97 and #565. diskstack
7
+ treats any sector that is exactly that repeated pattern as a failed read, so
8
+ another dump can supply the real bytes.
9
+ """
10
+
11
+ FILLER = b'-=[BAD SECTOR]=-'
12
+
13
+
14
+ def is_filler(data: bytes) -> bool:
15
+ """True if ``data`` is Greaseweazle's bad-sector filler and nothing else."""
16
+ n = len(data)
17
+ if n < len(FILLER) or n % len(FILLER) != 0:
18
+ return False
19
+ return bytes(data) == FILLER * (n // len(FILLER))
20
+
21
+
22
+ def make_filler(size: int) -> bytes:
23
+ """Bad-sector filler for a sector of ``size`` bytes.
24
+
25
+ Matches Greaseweazle's own ``FILLER * (size // 16)``, which leaves a short
26
+ tail of zeroes when the sector size is not a multiple of 16.
27
+ """
28
+ dat = FILLER * (size // len(FILLER))
29
+ return dat + bytes(size - len(dat))