auroraomics 0.1.0.dev0__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.
auroraomics/pack.py ADDED
@@ -0,0 +1,667 @@
1
+ """Build and validate the ``patches`` container.
2
+
3
+ A prediction takes one ``.zip`` per sample: the tiles as images, a ``tiles.csv``
4
+ naming each tile and where on the slide it came from, and optionally a
5
+ thumbnail. It is a zip and not an array file because zip members stream — a
6
+ loader can pull one tile at a time out of a 50k-tile archive, while a single
7
+ packed array has to be materialised whole before the first tile is available.
8
+
9
+ Two directions, with very different obligations:
10
+
11
+ :func:`pack_tiles` **produces** an archive from tiles you already have. It runs
12
+ quality control as the tiles stream past, writes only the ones that pass,
13
+ strips every scrap of metadata from the images it writes, and reports what
14
+ it dropped and why. Metadata stripping is not cosmetic: a tile cut from a
15
+ scanned slide can inherit the slide label, the scanner serial and a capture
16
+ timestamp, which is patient-adjacent data leaving the building inside an
17
+ image nobody looks inside.
18
+
19
+ :func:`read_archive` **consumes** one, and treats it as hostile. Everything it
20
+ can check from the archive's own index — member names, declared sizes, the
21
+ manifest, the image headers — is checked BEFORE a single pixel is decoded,
22
+ because a decoder is the largest attack surface in the whole path and the
23
+ sizes a zip declares are written by whoever produced it.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import csv
29
+ import io
30
+ import numbers
31
+ import re
32
+ import zipfile
33
+ from collections import Counter
34
+ from collections.abc import Iterable, Iterator
35
+ from dataclasses import dataclass
36
+ from pathlib import Path, PurePosixPath
37
+ from typing import Any
38
+
39
+ import cv2
40
+ import numpy as np
41
+ from PIL import Image
42
+
43
+ from . import contracts
44
+ from .qc import DEFAULT_THRESHOLDS, QcResult, QcThresholds, qc_tile
45
+
46
+ #: The input kind whose registry row bounds an archive.
47
+ KIND = "patches"
48
+
49
+ #: Decimal places each QC statistic is written with. Percentages to two places
50
+ #: and a variance to one is the precision the manifest carries, and a packer
51
+ #: that writes more digits produces a file no committed expectation can pin.
52
+ STAT_DECIMALS = {"foreground_ratio": 2, "hsv_ratio": 2, "laplacian_var": 1}
53
+
54
+ #: Every member of a fixed archive timestamp, so two runs over the same tiles
55
+ #: produce the same bytes. Zip cannot represent anything before 1980, and a
56
+ #: real time would make the archive unhashable and every diff of two archives
57
+ #: a diff of when they were built.
58
+ _FIXED_TIMESTAMP = (1980, 1, 1, 0, 0, 0)
59
+
60
+ #: Unix permissions for a written member: readable, never executable.
61
+ _MEMBER_MODE = 0o644 << 16
62
+
63
+ #: Image formats Pillow may report for a member, by extension.
64
+ _FORMATS = {".png": {"PNG"}, ".jpg": {"JPEG"}, ".jpeg": {"JPEG"}}
65
+
66
+
67
+ class PackError(ValueError):
68
+ """An archive cannot be built from what was passed."""
69
+
70
+
71
+ class ArchiveError(ValueError):
72
+ """An archive is not a valid container and must not be trusted."""
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class Tile:
77
+ """One tile: an RGB ``uint8`` image and its position on the slide.
78
+
79
+ ``x`` and ``y`` are the tile's top-left corner in full-resolution slide
80
+ pixels, which is the frame every coordinate in the result is expressed in.
81
+ """
82
+
83
+ image: np.ndarray
84
+ x: int
85
+ y: int
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class Rejection:
90
+ """One tile that quality control dropped, and the numbers behind it."""
91
+
92
+ x: int
93
+ y: int
94
+ reason: str
95
+ stats: dict[str, float]
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class PackReport:
100
+ """What :func:`pack_tiles` wrote, and what it did not."""
101
+
102
+ path: Path
103
+ written: int
104
+ rejected: int
105
+ rejected_by_reason: dict[str, int]
106
+ rejections: tuple[Rejection, ...]
107
+ thresholds: QcThresholds
108
+ mpp: float
109
+
110
+ @property
111
+ def considered(self) -> int:
112
+ """Tiles handed in, whether they were written or not."""
113
+ return self.written + self.rejected
114
+
115
+
116
+ @dataclass(frozen=True, slots=True)
117
+ class ArchiveRow:
118
+ """One row of an archive's manifest, parsed.
119
+
120
+ ``slots=True`` and no untyped spillover dict: an archive may declare 50k
121
+ tiles and :class:`PatchArchive` holds every row for its whole open life, so
122
+ a per-row dict of the same values as strings is tens of megabytes of exact
123
+ duplicate. A reader that needs the extra QC columns should parse the
124
+ manifest itself rather than have every reader pay for them.
125
+ """
126
+
127
+ tile_id: str
128
+ file: str
129
+ x: int
130
+ y: int
131
+ width: int
132
+ height: int
133
+ mpp: float
134
+
135
+
136
+ def _spec() -> dict[str, Any]:
137
+ return contracts.patch_archive()
138
+
139
+
140
+ def _tile_name_pattern() -> re.Pattern[str]:
141
+ return re.compile(_spec()["tile_name_pattern"])
142
+
143
+
144
+ def _name_for(index: int) -> str:
145
+ """The member name of the ``index``-th written tile.
146
+
147
+ The width of the number comes out of the container's own name pattern
148
+ rather than being spelled here, so the two cannot disagree — and when the
149
+ pattern cannot express an index, that is a hard error rather than a name a
150
+ validator on the other side will reject.
151
+ """
152
+ pattern = _spec()["tile_name_pattern"]
153
+ digits = re.search(r"\{(\d+)\}", pattern)
154
+ if digits is None:
155
+ # Falling back to a guessed width made the refusal below misstate the
156
+ # pattern's capacity as 10,000, which is a worse answer than saying
157
+ # the width could not be read.
158
+ raise PackError(
159
+ f"the container's tile name pattern {pattern} declares no digit "
160
+ "width, so a tile's name cannot be derived from it"
161
+ )
162
+ width = int(digits.group(1))
163
+ name = f"tile_{index:0{width}d}.png"
164
+ if not re.match(pattern, name):
165
+ raise PackError(
166
+ f"tile {index} cannot be named inside this container: the name "
167
+ f"pattern {pattern} holds at most {10 ** width} tiles. Reduce the "
168
+ "tile count first — auroraomics.subsample picks the densest square."
169
+ )
170
+ return name
171
+
172
+
173
+ def _encode_png(image: np.ndarray) -> bytes:
174
+ """Encode RGB ``uint8`` as PNG carrying no metadata at all.
175
+
176
+ The encoder is handed nothing but pixels, so there is nothing for it to
177
+ write into a text chunk, and it writes no timestamp of its own. A test
178
+ walks the chunk types of a written tile to keep that true.
179
+ """
180
+ ok, buffer = cv2.imencode(".png", cv2.cvtColor(image, cv2.COLOR_RGB2BGR))
181
+ if not ok: # pragma: no cover - only a broken build gets here
182
+ raise PackError("the image encoder refused a tile")
183
+ return buffer.tobytes()
184
+
185
+
186
+ def _encode_jpeg(image: np.ndarray, quality: int = 90) -> bytes:
187
+ """Encode RGB ``uint8`` as JPEG carrying no EXIF and no thumbnail."""
188
+ ok, buffer = cv2.imencode(
189
+ ".jpg",
190
+ cv2.cvtColor(image, cv2.COLOR_RGB2BGR),
191
+ [int(cv2.IMWRITE_JPEG_QUALITY), quality],
192
+ )
193
+ if not ok: # pragma: no cover
194
+ raise PackError("the image encoder refused the thumbnail")
195
+ return buffer.tobytes()
196
+
197
+
198
+ def _write_member(archive: zipfile.ZipFile, name: str, payload: bytes) -> None:
199
+ info = zipfile.ZipInfo(name, date_time=_FIXED_TIMESTAMP)
200
+ info.compress_type = zipfile.ZIP_DEFLATED
201
+ info.external_attr = _MEMBER_MODE
202
+ archive.writestr(info, payload)
203
+
204
+
205
+ def _format_stat(name: str, value: float) -> str:
206
+ return str(round(float(value), STAT_DECIMALS[name]))
207
+
208
+
209
+ def pack_tiles(
210
+ tiles: Iterable[Tile],
211
+ out_zip: str | Path,
212
+ *,
213
+ mpp: float,
214
+ thresholds: QcThresholds | None = None,
215
+ thumbnail: np.ndarray | None = None,
216
+ ) -> PackReport:
217
+ """Write the tiles that pass quality control into one archive.
218
+
219
+ Parameters
220
+ ----------
221
+ tiles:
222
+ Any iterable of :class:`Tile`. It is consumed lazily and one tile at a
223
+ time, so a generator that decodes a slide region per step never holds
224
+ more than one tile — which is what makes a whole slide packable on a
225
+ laptop.
226
+ out_zip:
227
+ Where to write. An existing file is replaced.
228
+ mpp:
229
+ Micrometres per pixel of the tiles, at the resolution they were cut.
230
+ Recorded for every tile: without it a tile is a picture of unknown
231
+ physical size and no model can be matched to it.
232
+ thresholds:
233
+ Quality-control floors. The defaults if omitted.
234
+ thumbnail:
235
+ An optional RGB overview image, written as the container's thumbnail.
236
+ Any metadata it carries is dropped, as for every tile.
237
+
238
+ Returns
239
+ -------
240
+ PackReport
241
+ Counts, plus one :class:`Rejection` per dropped tile naming the
242
+ threshold that dropped it. A caller that gets an unexpectedly small
243
+ archive can see immediately whether the slide was empty or a threshold
244
+ was wrong.
245
+ """
246
+ # numbers.Real rather than (int, float), because a resolution usually comes
247
+ # out of a slide's own metadata as a numpy scalar; bool is excluded because
248
+ # True would otherwise be accepted as 1 micrometre per pixel.
249
+ if isinstance(mpp, bool) or not isinstance(mpp, numbers.Real) or not mpp > 0:
250
+ raise PackError(f"mpp must be a positive number of micrometres, got {mpp!r}")
251
+ thresholds = thresholds or DEFAULT_THRESHOLDS
252
+ spec = _spec()
253
+ columns = list(spec["manifest_columns"]) + list(QcResult.stat_names())
254
+
255
+ out_zip = Path(out_zip)
256
+ rows: list[list[str]] = []
257
+ rejections: list[Rejection] = []
258
+ by_reason: dict[str, int] = {}
259
+
260
+ with zipfile.ZipFile(out_zip, "w", zipfile.ZIP_DEFLATED) as archive:
261
+ for tile in tiles:
262
+ verdict = qc_tile(tile.image, thresholds)
263
+ if not verdict.passes:
264
+ reason = verdict.rejected_by or "unknown"
265
+ rejections.append(
266
+ Rejection(
267
+ x=int(tile.x), y=int(tile.y), reason=reason,
268
+ stats=verdict.stats(),
269
+ )
270
+ )
271
+ by_reason[reason] = by_reason.get(reason, 0) + 1
272
+ continue
273
+
274
+ name = _name_for(len(rows))
275
+ height, width = tile.image.shape[:2]
276
+ _write_member(archive, name, _encode_png(tile.image))
277
+ values = {
278
+ "tile_id": PurePosixPath(name).stem,
279
+ "file": name,
280
+ "x": str(int(tile.x)),
281
+ "y": str(int(tile.y)),
282
+ "width": str(int(width)),
283
+ "height": str(int(height)),
284
+ "mpp": str(mpp),
285
+ **{
286
+ stat: _format_stat(stat, value)
287
+ for stat, value in verdict.stats().items()
288
+ },
289
+ }
290
+ rows.append([values[column] for column in columns])
291
+
292
+ buffer = io.StringIO()
293
+ writer = csv.writer(buffer)
294
+ writer.writerow(columns)
295
+ writer.writerows(rows)
296
+ _write_member(archive, spec["manifest"], buffer.getvalue().encode("utf-8"))
297
+
298
+ if thumbnail is not None:
299
+ _write_member(
300
+ archive, spec["thumbnail"], _encode_jpeg(np.asarray(thumbnail))
301
+ )
302
+
303
+ return PackReport(
304
+ path=out_zip,
305
+ written=len(rows),
306
+ rejected=len(rejections),
307
+ rejected_by_reason=by_reason,
308
+ rejections=tuple(rejections),
309
+ thresholds=thresholds,
310
+ mpp=float(mpp),
311
+ )
312
+
313
+
314
+ # ── reading someone else's archive ──────────────────────────────────────────
315
+
316
+
317
+ class PatchArchive:
318
+ """A validated archive, open for reading one tile at a time.
319
+
320
+ Constructed by :func:`read_archive`, which has already checked everything
321
+ checkable without decoding. Decoding happens here, per tile, on demand.
322
+
323
+ The instance holds the archive OPEN, because a zip's index is read on open
324
+ and re-opening per tile would make reading an archive quadratic in the
325
+ number of tiles — which at 50k tiles is the difference between seconds and
326
+ an afternoon. Close it when done, or use it as a context manager:
327
+
328
+ >>> with read_archive("sample.zip") as archive: # doctest: +SKIP
329
+ ... for tile in archive.tiles():
330
+ ... ...
331
+ """
332
+
333
+ def __init__(
334
+ self,
335
+ path: Path,
336
+ handle: zipfile.ZipFile,
337
+ rows: tuple[ArchiveRow, ...],
338
+ tile_names: tuple[str, ...],
339
+ has_thumbnail: bool,
340
+ ) -> None:
341
+ self.path = path
342
+ self.rows = rows
343
+ self.tile_names = tile_names
344
+ self.has_thumbnail = has_thumbnail
345
+ self._handle = handle
346
+ # read_tile checks membership once per tile and tiles() calls it once
347
+ # per row, so a linear scan of tile_names here is what makes reading a
348
+ # whole archive quadratic — the very cost this class exists to avoid.
349
+ self._tiles = frozenset(tile_names)
350
+
351
+ def __len__(self) -> int:
352
+ return len(self.rows)
353
+
354
+ def close(self) -> None:
355
+ """Release the archive's file handle."""
356
+ self._handle.close()
357
+
358
+ def __enter__(self) -> PatchArchive:
359
+ return self
360
+
361
+ def __exit__(self, *exc: object) -> None:
362
+ self.close()
363
+
364
+ @property
365
+ def mpp(self) -> float:
366
+ """The archive's resolution, which every row must agree on."""
367
+ values = {row.mpp for row in self.rows}
368
+ if len(values) != 1:
369
+ raise ArchiveError(
370
+ f"the manifest declares {len(values)} different mpp values; "
371
+ "tiles of one sample are cut at one resolution"
372
+ )
373
+ return values.pop()
374
+
375
+ def _payload(self, name: str) -> bytes:
376
+ return _read_bounded(self._handle, name)
377
+
378
+ def read_tile(self, name: str) -> np.ndarray:
379
+ """Decode one tile to an RGB ``uint8`` array."""
380
+ if name not in self._tiles:
381
+ raise ArchiveError(f"{name!r} is not a tile of this archive")
382
+ image = cv2.imdecode(
383
+ np.frombuffer(self._payload(name), dtype=np.uint8), cv2.IMREAD_COLOR
384
+ )
385
+ if image is None:
386
+ raise ArchiveError(f"member {name} is not a decodable image")
387
+ return cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
388
+
389
+ def tiles(self) -> Iterator[Tile]:
390
+ """Every tile in manifest order, decoded one at a time."""
391
+ for row in self.rows:
392
+ yield Tile(image=self.read_tile(row.file), x=row.x, y=row.y)
393
+
394
+ def thumbnail(self) -> np.ndarray | None:
395
+ """The overview image as RGB ``uint8``, or ``None`` if there is none."""
396
+ if not self.has_thumbnail:
397
+ return None
398
+ payload = self._payload(_spec()["thumbnail"])
399
+ image = cv2.imdecode(np.frombuffer(payload, dtype=np.uint8), cv2.IMREAD_COLOR)
400
+ if image is None:
401
+ raise ArchiveError("the thumbnail is not a decodable image")
402
+ return cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
403
+
404
+
405
+ def _read_bounded(handle: zipfile.ZipFile, name: str) -> bytes:
406
+ """Read one member, refusing to accept more bytes than its index declares.
407
+
408
+ The declared size is what :func:`_check_declared_sizes` bounded, so it is
409
+ the number the caller has already agreed to spend memory on. Reading one
410
+ byte past it and refusing on a non-empty remainder turns a lying header
411
+ into an error instead of an unbounded read.
412
+ """
413
+ info = handle.getinfo(name)
414
+ with handle.open(info) as member:
415
+ payload = member.read(info.file_size + 1)
416
+ if len(payload) > info.file_size:
417
+ raise ArchiveError(
418
+ f"member {name} holds more bytes than its index declares — the "
419
+ "archive is inconsistent and was not decoded"
420
+ )
421
+ return payload
422
+
423
+
424
+ def _check_member_name(name: str) -> None:
425
+ """Refuse any name that is not a plain file at the archive root.
426
+
427
+ A member name is attacker-controlled text, and the two classic shapes are
428
+ an absolute path and a ``../`` walk out of the extraction directory. This
429
+ package never extracts to disk, but a caller might, and a name that cannot
430
+ escape is a name nobody has to remember to sanitise.
431
+ """
432
+ if not name or name != PurePosixPath(name).name:
433
+ raise ArchiveError(
434
+ f"member {name!r} is not a plain file name at the archive root; a "
435
+ "container holds no directories and no paths"
436
+ )
437
+ if name.startswith(".") or "\\" in name or ".." in name:
438
+ raise ArchiveError(f"member {name!r} is not an acceptable file name")
439
+
440
+
441
+ def _check_declared_sizes(infos: list[zipfile.ZipInfo], cap: int) -> None:
442
+ """Bound the archive by what it DECLARES, before anything is decompressed.
443
+
444
+ This is the zip-bomb check, and it has to happen here: a few hundred
445
+ kilobytes of deflate can declare — and expand to — gigabytes, so the only
446
+ safe moment to refuse is while the numbers are still just numbers.
447
+ """
448
+ total = 0
449
+ for info in infos:
450
+ if info.file_size < 0:
451
+ raise ArchiveError(f"member {info.filename} declares a negative size")
452
+ if info.file_size > cap:
453
+ raise ArchiveError(
454
+ f"member {info.filename} declares {info.file_size} bytes, over "
455
+ f"the {cap}-byte limit for one {KIND} input"
456
+ )
457
+ total += info.file_size
458
+ if total > cap:
459
+ raise ArchiveError(
460
+ f"the archive declares {total} bytes uncompressed, over the {cap}-byte "
461
+ f"limit for one {KIND} input"
462
+ )
463
+
464
+
465
+ def _check_member_set(
466
+ names: list[str],
467
+ tile_pattern: re.Pattern[str],
468
+ manifest_name: str,
469
+ thumbnail_name: str,
470
+ ) -> tuple[str, ...]:
471
+ """Every member is named once, and named something the container defines.
472
+
473
+ A named step rather than two comprehensions inside :func:`read_archive`,
474
+ because it is the one part of validation that runs over EVERY member and so
475
+ is the one part whose cost has to be linear. Both checks were written as a
476
+ scan-inside-a-scan and cost ~49 s on a 50k-tile archive — the container's
477
+ own ceiling — for work that takes milliseconds. Keeping it here means it
478
+ can be tested at that ceiling without building a 50k-tile zip.
479
+
480
+ Returns the tile members, in archive order.
481
+ """
482
+ # A zip may physically hold two members with the SAME name, and which one a
483
+ # reader gets is up to the reader — so one tool validates a member and
484
+ # another decodes the other. There is no legitimate reason for a container
485
+ # to carry one, so it is a refusal rather than a choice.
486
+ duplicates = sorted(n for n, seen in Counter(names).items() if seen > 1)
487
+ if duplicates:
488
+ raise ArchiveError(
489
+ f"the archive holds more than one member named: "
490
+ f"{', '.join(duplicates)}"
491
+ )
492
+ tile_names = tuple(name for name in names if tile_pattern.match(name))
493
+ defined = frozenset(tile_names) | {manifest_name, thumbnail_name}
494
+ stray = [name for name in names if name not in defined]
495
+ if stray:
496
+ raise ArchiveError(
497
+ f"the archive holds member(s) the container does not define: "
498
+ f"{', '.join(sorted(stray))}"
499
+ )
500
+ return tile_names
501
+
502
+
503
+ def _parse_manifest(payload: bytes, columns: list[str]) -> tuple[ArchiveRow, ...]:
504
+ try:
505
+ text = payload.decode("utf-8")
506
+ except UnicodeDecodeError as exc:
507
+ raise ArchiveError("the manifest is not UTF-8 text") from exc
508
+ reader = csv.reader(io.StringIO(text, newline=""))
509
+ try:
510
+ header = next(reader)
511
+ except StopIteration:
512
+ raise ArchiveError("the manifest is empty") from None
513
+ if header[: len(columns)] != columns:
514
+ raise ArchiveError(
515
+ f"the manifest must start with the columns {', '.join(columns)}; it "
516
+ f"starts with {', '.join(header[: len(columns)]) or '(nothing)'}"
517
+ )
518
+
519
+ rows: list[ArchiveRow] = []
520
+ for number, values in enumerate(reader, start=2):
521
+ if not values:
522
+ continue
523
+ if len(values) != len(header):
524
+ raise ArchiveError(
525
+ f"manifest line {number} has {len(values)} fields for "
526
+ f"{len(header)} columns"
527
+ )
528
+ raw = dict(zip(header, values))
529
+ try:
530
+ rows.append(
531
+ ArchiveRow(
532
+ tile_id=raw["tile_id"],
533
+ file=raw["file"],
534
+ x=int(raw["x"]),
535
+ y=int(raw["y"]),
536
+ width=int(raw["width"]),
537
+ height=int(raw["height"]),
538
+ mpp=float(raw["mpp"]),
539
+ )
540
+ )
541
+ except ValueError as exc:
542
+ raise ArchiveError(f"manifest line {number}: {exc}") from exc
543
+ if not rows:
544
+ raise ArchiveError("the manifest declares no tiles")
545
+ return tuple(rows)
546
+
547
+
548
+ def _check_image_header(name: str, payload: bytes) -> tuple[int, int]:
549
+ """Width and height from the image HEADER, without decoding the pixels.
550
+
551
+ Pillow parses the header and stops, and it refuses implausible dimensions
552
+ on its own, so the geometry can be checked while the pixel data is still
553
+ untouched. It also reports the real format, which catches a member whose
554
+ extension says one thing and whose bytes say another.
555
+ """
556
+ suffix = PurePosixPath(name).suffix.lower()
557
+ try:
558
+ with Image.open(io.BytesIO(payload)) as image:
559
+ width, height = image.size
560
+ found = image.format
561
+ except Exception as exc: # Pillow raises a family of errors here
562
+ raise ArchiveError(f"member {name} is not a readable image: {exc}") from exc
563
+ if found not in _FORMATS.get(suffix, set()):
564
+ raise ArchiveError(
565
+ f"member {name} is a {found} file, which does not match its "
566
+ f"{suffix} extension"
567
+ )
568
+ if width <= 0 or height <= 0:
569
+ raise ArchiveError(f"member {name} declares an empty image")
570
+ return width, height
571
+
572
+
573
+ def read_archive(zip_path: str | Path) -> PatchArchive:
574
+ """Validate an archive and return it, open for reading.
575
+
576
+ Every check a server has to make, in the order that keeps the risky ones
577
+ last:
578
+
579
+ 1. it is a zip at all;
580
+ 2. every member is a plain file name at the root — no paths, no traversal;
581
+ 3. every DECLARED size, and their total, is inside the cap for this input
582
+ kind — before anything is decompressed;
583
+ 4. the member names are exactly the manifest, the tiles matching the
584
+ container's name pattern, and at most the thumbnail: a stray member is a
585
+ refusal, not something to ignore;
586
+ 5. the manifest parses, starts with the agreed columns, and its rows and
587
+ the tile members name each other exactly — in both directions;
588
+ 6. every tile's header agrees with its manifest row and with every other
589
+ tile, so one sample is one geometry.
590
+
591
+ Raises :class:`ArchiveError` on the first failure, naming what failed.
592
+ """
593
+ zip_path = Path(zip_path)
594
+ spec = _spec()
595
+ manifest_name = spec["manifest"]
596
+ thumbnail_name = spec["thumbnail"]
597
+ tile_pattern = _tile_name_pattern()
598
+ columns = list(spec["manifest_columns"])
599
+
600
+ try:
601
+ archive = zipfile.ZipFile(zip_path)
602
+ except zipfile.BadZipFile as exc:
603
+ raise ArchiveError(f"{zip_path.name} is not a zip archive") from exc
604
+
605
+ # Every refusal below closes the handle on the way out: a rejected archive
606
+ # must not leave a file open in the caller's process.
607
+ try:
608
+ infos = [info for info in archive.infolist() if not info.is_dir()]
609
+ if not infos:
610
+ raise ArchiveError("the archive is empty")
611
+ for info in infos:
612
+ _check_member_name(info.filename)
613
+ _check_declared_sizes(infos, contracts.max_bytes(KIND))
614
+
615
+ names = [info.filename for info in infos]
616
+ tile_names = _check_member_set(
617
+ names, tile_pattern, manifest_name, thumbnail_name
618
+ )
619
+ if manifest_name not in names:
620
+ raise ArchiveError(f"the archive has no {manifest_name}")
621
+
622
+ rows = _parse_manifest(_read_bounded(archive, manifest_name), columns)
623
+
624
+ declared = [row.file for row in rows]
625
+ if len(set(declared)) != len(declared):
626
+ raise ArchiveError("the manifest names the same tile twice")
627
+ missing = sorted(set(declared) - set(tile_names))
628
+ if missing:
629
+ raise ArchiveError(
630
+ f"the manifest names tile(s) the archive does not contain: "
631
+ f"{', '.join(missing)}"
632
+ )
633
+ undeclared = sorted(set(tile_names) - set(declared))
634
+ if undeclared:
635
+ raise ArchiveError(
636
+ f"the archive contains tile(s) the manifest does not name: "
637
+ f"{', '.join(undeclared)}"
638
+ )
639
+
640
+ geometry: set[tuple[int, int]] = set()
641
+ for row in rows:
642
+ width, height = _check_image_header(
643
+ row.file, _read_bounded(archive, row.file)
644
+ )
645
+ if (width, height) != (row.width, row.height):
646
+ raise ArchiveError(
647
+ f"member {row.file} is {width}x{height} but its manifest row "
648
+ f"says {row.width}x{row.height}"
649
+ )
650
+ geometry.add((width, height))
651
+ if len(geometry) > 1:
652
+ sizes = ", ".join(f"{w}x{h}" for w, h in sorted(geometry))
653
+ raise ArchiveError(
654
+ f"the tiles are not one geometry ({sizes}); a model is matched to "
655
+ "one tile size and one resolution"
656
+ )
657
+ except BaseException:
658
+ archive.close()
659
+ raise
660
+
661
+ return PatchArchive(
662
+ path=zip_path,
663
+ handle=archive,
664
+ rows=rows,
665
+ tile_names=tile_names,
666
+ has_thumbnail=thumbnail_name in names,
667
+ )
auroraomics/py.typed ADDED
File without changes