rails49-r49 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ .pnpm-store/
2
+ node_modules/
3
+ dist/
4
+ .venv/
5
+ __pycache__/
6
+ packages/r49/LICENSE
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bernhard Boser
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.5
2
+ Name: rails49-r49
3
+ Version: 0.1.0
4
+ Summary: Read .r49 layout archives
5
+ Project-URL: Repository, https://github.com/rails49/r49
6
+ Project-URL: Issues, https://github.com/rails49/r49/issues
7
+ License-Expression: MIT
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: pydantic>=2.11
10
+ Description-Content-Type: text/markdown
11
+
12
+ # rails49-r49
13
+
14
+ Reads `.r49` railroad layout archives in Python: a zip holding a `manifest.json`
15
+ plus the layout photographs it describes. The format is defined in
16
+ [FORMAT.md](https://github.com/rails49/r49/blob/main/FORMAT.md); this package is a
17
+ reader of it, and agrees with the TypeScript package
18
+ [`@rails49/r49`](https://github.com/rails49/r49/tree/main/packages/r49) on every
19
+ [conformance case](https://github.com/rails49/r49/tree/main/conformance).
20
+
21
+ Read-only: it loads, validates and fits archives, and never writes one.
22
+
23
+ ## Interface
24
+
25
+ See
26
+ [`src/r49/__init__.py`](https://github.com/rails49/r49/blob/main/packages/r49-py/src/r49/__init__.py),
27
+ the package's public surface.
28
+ Anything not exported there is internal and may change without notice.
29
+
30
+ The scale table and the vocabulary are read from the TypeScript package's
31
+ [`data.json`](https://github.com/rails49/r49/blob/main/packages/r49/src/data.json),
32
+ linked into the package as `src/r49/data.json`; no value of them is declared
33
+ in code. The sdist holds a copy of the file in place of the link.
34
+
35
+ ## Example
36
+
37
+ ```python
38
+ import r49
39
+
40
+ archive = r49.load("layout.r49") # a path, or the archive's bytes
41
+ manifest = archive.manifest
42
+ print(manifest.layout.name, manifest.layout.scale)
43
+
44
+ result = r49.calibration_fit(manifest)
45
+ print(result.dpt, result.residual) # None where the fit cannot answer
46
+ for left_out in result.left_out:
47
+ print(left_out.index, left_out.reason)
48
+
49
+ jpeg = archive.image(manifest.images[0].filename)
50
+ for mismatch in archive.image_mismatches(): # empty when the images match
51
+ print(mismatch.rule, mismatch.filename)
52
+
53
+ r49.is_known_class("stock.loco.steam") # True
54
+ ```
55
+
56
+ A refusal is an `r49.R49Error` whose `rule` is the format rule id broken, such
57
+ as `manifest/unknown-key`. The id is the contract; the message is not. An
58
+ archive whose images and manifest disagree still loads; `image_mismatches()`
59
+ lists each disagreement under `images/missing` or `images/orphan`.
60
+ `r49.parse_manifest` reads a bare `manifest.json`, which is exempt from the zip
61
+ and image rules.
62
+
63
+ A car label's `class` is `class_` in Python; the manifest key stays `class`.
64
+
65
+ ## Build and test
66
+
67
+ Managed with [uv](https://docs.astral.sh/uv/):
68
+
69
+ ```sh
70
+ uv sync --locked
71
+ uv run pytest # the conformance cases and the reader's own tests
72
+ uv run ruff check .
73
+ uv run black --check .
74
+ uv run pyright
75
+ uv build # the sdist, then the wheel from it
76
+ ```
77
+
78
+ Installed with `pip install rails49-r49`.
79
+
80
+ ## Licence
81
+
82
+ [MIT](https://github.com/rails49/r49/blob/main/LICENSE-MIT).
@@ -0,0 +1,71 @@
1
+ # rails49-r49
2
+
3
+ Reads `.r49` railroad layout archives in Python: a zip holding a `manifest.json`
4
+ plus the layout photographs it describes. The format is defined in
5
+ [FORMAT.md](https://github.com/rails49/r49/blob/main/FORMAT.md); this package is a
6
+ reader of it, and agrees with the TypeScript package
7
+ [`@rails49/r49`](https://github.com/rails49/r49/tree/main/packages/r49) on every
8
+ [conformance case](https://github.com/rails49/r49/tree/main/conformance).
9
+
10
+ Read-only: it loads, validates and fits archives, and never writes one.
11
+
12
+ ## Interface
13
+
14
+ See
15
+ [`src/r49/__init__.py`](https://github.com/rails49/r49/blob/main/packages/r49-py/src/r49/__init__.py),
16
+ the package's public surface.
17
+ Anything not exported there is internal and may change without notice.
18
+
19
+ The scale table and the vocabulary are read from the TypeScript package's
20
+ [`data.json`](https://github.com/rails49/r49/blob/main/packages/r49/src/data.json),
21
+ linked into the package as `src/r49/data.json`; no value of them is declared
22
+ in code. The sdist holds a copy of the file in place of the link.
23
+
24
+ ## Example
25
+
26
+ ```python
27
+ import r49
28
+
29
+ archive = r49.load("layout.r49") # a path, or the archive's bytes
30
+ manifest = archive.manifest
31
+ print(manifest.layout.name, manifest.layout.scale)
32
+
33
+ result = r49.calibration_fit(manifest)
34
+ print(result.dpt, result.residual) # None where the fit cannot answer
35
+ for left_out in result.left_out:
36
+ print(left_out.index, left_out.reason)
37
+
38
+ jpeg = archive.image(manifest.images[0].filename)
39
+ for mismatch in archive.image_mismatches(): # empty when the images match
40
+ print(mismatch.rule, mismatch.filename)
41
+
42
+ r49.is_known_class("stock.loco.steam") # True
43
+ ```
44
+
45
+ A refusal is an `r49.R49Error` whose `rule` is the format rule id broken, such
46
+ as `manifest/unknown-key`. The id is the contract; the message is not. An
47
+ archive whose images and manifest disagree still loads; `image_mismatches()`
48
+ lists each disagreement under `images/missing` or `images/orphan`.
49
+ `r49.parse_manifest` reads a bare `manifest.json`, which is exempt from the zip
50
+ and image rules.
51
+
52
+ A car label's `class` is `class_` in Python; the manifest key stays `class`.
53
+
54
+ ## Build and test
55
+
56
+ Managed with [uv](https://docs.astral.sh/uv/):
57
+
58
+ ```sh
59
+ uv sync --locked
60
+ uv run pytest # the conformance cases and the reader's own tests
61
+ uv run ruff check .
62
+ uv run black --check .
63
+ uv run pyright
64
+ uv build # the sdist, then the wheel from it
65
+ ```
66
+
67
+ Installed with `pip install rails49-r49`.
68
+
69
+ ## Licence
70
+
71
+ [MIT](https://github.com/rails49/r49/blob/main/LICENSE-MIT).
@@ -0,0 +1,47 @@
1
+ [project]
2
+ name = "rails49-r49"
3
+ version = "0.1.0"
4
+ description = "Read .r49 layout archives"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.10"
8
+ dependencies = ["pydantic>=2.11"]
9
+
10
+ [project.urls]
11
+ Repository = "https://github.com/rails49/r49"
12
+ Issues = "https://github.com/rails49/r49/issues"
13
+
14
+ [dependency-groups]
15
+ dev = ["black>=25.1", "pyright>=1.1.400", "pytest>=8.3", "ruff>=0.11"]
16
+
17
+ [build-system]
18
+ requires = ["hatchling"]
19
+ build-backend = "hatchling.build"
20
+
21
+ [tool.hatch.build.targets.wheel]
22
+ packages = ["src/r49"]
23
+
24
+ # The sdist holds a copy of ../r49/src/data.json in place of the link, and a
25
+ # copy of the repository's LICENSE-MIT, which the wheel built from it lists as
26
+ # its license file.
27
+ [tool.hatch.build.targets.sdist]
28
+ exclude = ["src/r49/data.json"]
29
+
30
+ [tool.hatch.build.targets.sdist.force-include]
31
+ "../r49/src/data.json" = "src/r49/data.json"
32
+ "../../LICENSE-MIT" = "LICENSE"
33
+
34
+ [tool.black]
35
+ line-length = 100
36
+
37
+ [tool.ruff]
38
+ line-length = 100
39
+
40
+ [tool.ruff.lint]
41
+ select = ["E", "F", "I", "UP", "B"]
42
+
43
+ [tool.pyright]
44
+ include = ["src", "tests"]
45
+ typeCheckingMode = "strict"
46
+ venvPath = "."
47
+ venv = ".venv"
@@ -0,0 +1,76 @@
1
+ """Read ``.r49`` layout archives.
2
+
3
+ This module is the package's interface; anything not exported here is internal.
4
+ FORMAT.md defines the format, and the conformance cases are how this reader
5
+ shows it agrees with the TypeScript package, @rails49/r49.
6
+
7
+ Read-only: nothing here writes an archive.
8
+ """
9
+
10
+ from .archive import R49Archive, load
11
+ from .calibration_fit import (
12
+ CalibrationFit,
13
+ CalibrationPair,
14
+ LeftOutPoint,
15
+ LeftOutReason,
16
+ calibration_fit,
17
+ )
18
+ from .errors import FORMAT_RULES, FormatRule, ImageMismatch, R49Error
19
+ from .manifest import (
20
+ MANIFEST_VERSION,
21
+ VALID_SCALES,
22
+ Calibration,
23
+ Camera,
24
+ CarLabel,
25
+ Image,
26
+ Layout,
27
+ Manifest,
28
+ Point,
29
+ Provenance,
30
+ ReferencePoint,
31
+ Resolution,
32
+ Scale,
33
+ Sensor,
34
+ WorldPoint,
35
+ parse_manifest,
36
+ )
37
+ from .vocabulary import (
38
+ VocabularyNode,
39
+ is_known_class,
40
+ is_segment_prefix,
41
+ vocabulary_subtypes,
42
+ )
43
+
44
+ __all__ = [
45
+ "FORMAT_RULES",
46
+ "MANIFEST_VERSION",
47
+ "VALID_SCALES",
48
+ "Calibration",
49
+ "CalibrationFit",
50
+ "CalibrationPair",
51
+ "Camera",
52
+ "CarLabel",
53
+ "FormatRule",
54
+ "Image",
55
+ "ImageMismatch",
56
+ "Layout",
57
+ "LeftOutPoint",
58
+ "LeftOutReason",
59
+ "Manifest",
60
+ "Point",
61
+ "Provenance",
62
+ "R49Archive",
63
+ "R49Error",
64
+ "ReferencePoint",
65
+ "Resolution",
66
+ "Scale",
67
+ "Sensor",
68
+ "VocabularyNode",
69
+ "WorldPoint",
70
+ "calibration_fit",
71
+ "is_known_class",
72
+ "is_segment_prefix",
73
+ "load",
74
+ "parse_manifest",
75
+ "vocabulary_subtypes",
76
+ ]
@@ -0,0 +1,84 @@
1
+ """Loading a ``.r49`` archive: a zip of ``manifest.json`` and the images it names."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import os
7
+ import zipfile
8
+ import zlib
9
+ from pathlib import Path
10
+
11
+ from .errors import ImageMismatch, R49Error
12
+ from .manifest import Manifest, parse_manifest
13
+
14
+ MANIFEST_FILE = "manifest.json"
15
+
16
+
17
+ class R49Archive:
18
+ """A loaded archive, read-only. Get one from :func:`load`."""
19
+
20
+ def __init__(self, manifest: Manifest, files: dict[str, bytes]) -> None:
21
+ self._manifest = manifest
22
+ self._files = files
23
+
24
+ @property
25
+ def manifest(self) -> Manifest:
26
+ """The validated manifest."""
27
+ return self._manifest
28
+
29
+ def entries(self) -> list[str]:
30
+ """Every file in the zip, ``manifest.json`` included, in zip order.
31
+ Directory entries are left out."""
32
+ return list(self._files)
33
+
34
+ def image(self, filename: str) -> bytes | None:
35
+ """The bytes of the file at ``filename``, or None when the zip has none."""
36
+ return self._files.get(filename)
37
+
38
+ def image_mismatches(self) -> list[ImageMismatch]:
39
+ """Every way the manifest and the zip disagree about which images this
40
+ archive holds: ``images/missing`` for each image entry whose file the
41
+ zip lacks, in manifest order, then ``images/orphan`` for each file
42
+ other than ``manifest.json`` that no image names, in zip order. Empty
43
+ for an archive that follows both rules.
44
+
45
+ Reported, not raised: :func:`load` accepts an archive with either."""
46
+ named = [image.filename for image in self._manifest.images]
47
+ missing = [
48
+ ImageMismatch("images/missing", name) for name in named if name not in self._files
49
+ ]
50
+ orphan = [
51
+ ImageMismatch("images/orphan", path)
52
+ for path in self._files
53
+ if path != MANIFEST_FILE and path not in named
54
+ ]
55
+ return missing + orphan
56
+
57
+
58
+ def load(source: bytes | str | os.PathLike[str]) -> R49Archive:
59
+ """Load an archive from its bytes or from a path, applying every format rule
60
+ but the two image rules, which :meth:`R49Archive.image_mismatches` reports.
61
+
62
+ :raises R49Error: naming the format rule the archive breaks.
63
+ :raises OSError: if a path cannot be read; that is no verdict on an archive.
64
+ """
65
+ data = source if isinstance(source, bytes) else Path(source).read_bytes()
66
+ if not data:
67
+ raise R49Error("archive/empty", "Cannot load .r49 archive: data is empty")
68
+ files = _read_zip(data)
69
+
70
+ if MANIFEST_FILE not in files:
71
+ raise R49Error("archive/no-manifest", "manifest.json not found in archive")
72
+ return R49Archive(parse_manifest(files[MANIFEST_FILE]), files)
73
+
74
+
75
+ def _read_zip(data: bytes) -> dict[str, bytes]:
76
+ """Every file in the zip by path. Read whole on load, so that a zip that
77
+ cannot be read is refused here rather than when an image is asked for."""
78
+ try:
79
+ with zipfile.ZipFile(io.BytesIO(data)) as zf:
80
+ return {info.filename: zf.read(info) for info in zf.infolist() if not info.is_dir()}
81
+ except (zipfile.BadZipFile, zlib.error, EOFError, NotImplementedError, RuntimeError) as err:
82
+ raise R49Error(
83
+ "archive/not-zip", f"Failed to load .r49 archive (size: {len(data)} bytes): {err}"
84
+ ) from None
@@ -0,0 +1,135 @@
1
+ """The calibration fit, as FORMAT.md § The calibration fit defines it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from dataclasses import dataclass
7
+ from typing import Literal
8
+
9
+ from .data import SCALE_TO_RATIO, STANDARD_GAUGE
10
+ from .manifest import Calibration, Manifest
11
+
12
+ LeftOutReason = Literal["off-reference-plane", "above-camera", "no-partner"]
13
+ """Why a reference point is not in the fit, in FORMAT.md's order:
14
+
15
+ * ``off-reference-plane``: there is no camera height and the point's ``z`` is
16
+ not the reference height.
17
+ * ``above-camera``: the point's ``z`` is the camera height or more.
18
+ * ``no-partner``: no other point still in the fit has the same ``z``.
19
+ """
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class LeftOutPoint:
24
+ """A reference point the fit does not use."""
25
+
26
+ index: int
27
+ """The point's index in ``calibration.points``."""
28
+ reason: LeftOutReason
29
+
30
+
31
+ CalibrationPair = tuple[int, int]
32
+ """A pair the fit uses, by the indexes of its two points in
33
+ ``calibration.points``, the lower first."""
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class CalibrationFit:
38
+ """What a fit reports, together."""
39
+
40
+ dpt: float | None
41
+ """DPT, or None where it does not resolve: no pair with a nonzero separation,
42
+ or a camera height at or below the reference height."""
43
+ residual: float | None
44
+ """The RMS residual in image pixels, or None with fewer than two pairs."""
45
+ pairs: tuple[CalibrationPair, ...]
46
+ """The pairs used."""
47
+ left_out: tuple[LeftOutPoint, ...]
48
+ """The points left out, by index, each with the first reason that applies."""
49
+ camera_height_fits: bool | None
50
+ """None without a camera height; otherwise whether it is above every
51
+ reference point's ``z``. False is no format error, but the DPT should not
52
+ be trusted."""
53
+
54
+
55
+ def calibration_fit(manifest: Manifest) -> CalibrationFit:
56
+ """Fit the layout's scale through its reference points.
57
+
58
+ ``d_mm²`` weighting trusts long baselines more; heights compare exactly,
59
+ because they are typed by hand.
60
+ """
61
+ calibration = manifest.layout.calibration
62
+ h = calibration.camera_height_mm
63
+ z_ref = calibration.reference_height_mm or 0.0
64
+
65
+ left_out, pairs = _select(calibration)
66
+ separations = [_separations(calibration, i, j) for i, j in pairs]
67
+
68
+ numerator = sum(d_px * d_mm for d_px, d_mm in separations)
69
+ denominator = sum(d_mm * d_mm for _, d_mm in separations)
70
+ scale = numerator / denominator if denominator != 0 else None
71
+
72
+ dpt: float | None = None
73
+ if scale is not None and (h is None or h > z_ref):
74
+ # Without a camera height the fit already sits on the reference plane;
75
+ # with one it is at layout zero, and this lifts it to the track.
76
+ at_reference = scale if h is None else scale * h / (h - z_ref)
77
+ dpt = at_reference * STANDARD_GAUGE / SCALE_TO_RATIO[manifest.layout.scale]
78
+
79
+ residual: float | None = None
80
+ if scale is not None and len(separations) >= 2:
81
+ residual = math.sqrt(
82
+ sum((d_px - scale * d_mm) ** 2 for d_px, d_mm in separations) / len(separations)
83
+ )
84
+
85
+ fits: bool | None = None
86
+ if h is not None:
87
+ fits = all(left.reason != "above-camera" for left in left_out)
88
+
89
+ return CalibrationFit(
90
+ dpt=dpt,
91
+ residual=residual,
92
+ pairs=tuple(pairs),
93
+ left_out=tuple(left_out),
94
+ camera_height_fits=fits,
95
+ )
96
+
97
+
98
+ def _select(calibration: Calibration) -> tuple[list[LeftOutPoint], list[CalibrationPair]]:
99
+ """The points left out and the pairs: every unordered pair of the rest that
100
+ shares a ``z``."""
101
+ h = calibration.camera_height_mm
102
+ z_ref = calibration.reference_height_mm or 0.0
103
+ points = calibration.points
104
+
105
+ left_out: dict[int, LeftOutReason] = {}
106
+ for index, point in enumerate(points):
107
+ if h is None and point.world.z != z_ref:
108
+ left_out[index] = "off-reference-plane"
109
+ elif h is not None and point.world.z >= h:
110
+ left_out[index] = "above-camera"
111
+
112
+ kept = [index for index in range(len(points)) if index not in left_out]
113
+ pairs = [
114
+ (i, j)
115
+ for n, i in enumerate(kept)
116
+ for j in kept[n + 1 :]
117
+ if points[i].world.z == points[j].world.z
118
+ ]
119
+ paired = {index for pair in pairs for index in pair}
120
+ for index in kept:
121
+ if index not in paired:
122
+ left_out[index] = "no-partner"
123
+
124
+ return [LeftOutPoint(index, left_out[index]) for index in sorted(left_out)], pairs
125
+
126
+
127
+ def _separations(calibration: Calibration, i: int, j: int) -> tuple[float, float]:
128
+ """A pair's pixel separation, scaled to layout zero when the camera height
129
+ is known, and its separation on the layout in mm."""
130
+ a, b = calibration.points[i], calibration.points[j]
131
+ h = calibration.camera_height_mm
132
+ d_px = math.hypot(a.px.x - b.px.x, a.px.y - b.px.y)
133
+ if h is not None:
134
+ d_px *= (h - a.world.z) / h
135
+ return d_px, math.hypot(a.world.x - b.world.x, a.world.y - b.world.y)
@@ -0,0 +1,12 @@
1
+ {
2
+ "standard_gauge": 1435.0,
3
+ "scale_to_ratio": { "G": 25, "O": 48, "S": 64, "HO": 87, "T": 120, "N": 160, "Z": 220 },
4
+ "vocabulary": {
5
+ "stock": {
6
+ "loco": { "steam": {}, "diesel": {}, "electric": {} },
7
+ "passenger": {},
8
+ "freight": {}
9
+ }
10
+ },
11
+ "recommended_dpt": 20
12
+ }
@@ -0,0 +1,25 @@
1
+ """The format's tables, read from ``data.json``.
2
+
3
+ The file is the TypeScript package's own (``packages/r49/src/data.json``, linked
4
+ here), so both readers compute with the same numbers. FORMAT.md § Scales and
5
+ gauge and § The vocabulary state them in prose. No value is restated in code.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from importlib.resources import files
12
+ from typing import Any, Final
13
+
14
+ _DATA: Final[dict[str, Any]] = json.loads(
15
+ files(__package__).joinpath("data.json").read_text(encoding="utf-8")
16
+ )
17
+
18
+ STANDARD_GAUGE: Final[float] = _DATA["standard_gauge"]
19
+ """The prototype's standard gauge in mm."""
20
+
21
+ SCALE_TO_RATIO: Final[dict[str, float]] = _DATA["scale_to_ratio"]
22
+ """Scale name to reduction ratio (HO is 1:87)."""
23
+
24
+ VOCABULARY: Final[dict[str, Any]] = _DATA["vocabulary"]
25
+ """The vocabulary: what a label's ``class`` should match."""
@@ -0,0 +1,52 @@
1
+ """The one error this package raises for an archive that breaks a format rule,
2
+ and the image mismatches an archive reports instead of raising."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass
7
+ from typing import Final, Literal, get_args
8
+
9
+ FormatRule = Literal[
10
+ "archive/empty",
11
+ "archive/not-zip",
12
+ "archive/no-manifest",
13
+ "manifest/not-json",
14
+ "manifest/version",
15
+ "manifest/unknown-key",
16
+ "manifest/duplicate-id",
17
+ "manifest/provenance",
18
+ "manifest/schema",
19
+ "images/missing",
20
+ "images/orphan",
21
+ ]
22
+ """A format rule id, as FORMAT.md § Format rules lists them."""
23
+
24
+ FORMAT_RULES: Final[tuple[FormatRule, ...]] = get_args(FormatRule)
25
+ """Every format rule id, in FORMAT.md's order.
26
+
27
+ The id is the contract; the wording of a message is not. A caller that needs to
28
+ tell one refusal from another switches on :attr:`R49Error.rule`, never on the
29
+ text.
30
+ """
31
+
32
+
33
+ class R49Error(Exception):
34
+ """An archive or manifest broke a format rule. Anything else raised is a bug
35
+ or a misuse, not a verdict on the archive."""
36
+
37
+ rule: FormatRule
38
+ """The format rule the archive or manifest broke."""
39
+
40
+ def __init__(self, rule: FormatRule, message: str) -> None:
41
+ super().__init__(message)
42
+ self.rule = rule
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class ImageMismatch:
47
+ """One way the manifest and the zip disagree about which images an archive
48
+ holds. Reported by :meth:`R49Archive.image_mismatches`, never raised."""
49
+
50
+ rule: Literal["images/missing", "images/orphan"]
51
+ filename: str
52
+ """The manifest filename, or the zip entry, the disagreement is about."""