Source code for iiisight.conformance

"""Image API conformance probing.

Given a loaded :class:`~iiisight.model.ImageService`, this plans a battery of
Image API requests appropriate to the service's claimed compliance level, and —
for requests whose output size is predictable — verifies that the server returns
an image of the **expected pixel dimensions**, not merely an HTTP 200.

Dimensions are read straight from the returned image's header (JPEG / PNG / GIF)
with :func:`image_size`, so no image-decoding dependency is required. When a
response is in a format we can't measure, the check verifies reachability only and
says so. The client drives the requests (:meth:`iiisight.AsyncClient.check_compliance`);
the planning and evaluation here are pure.
"""

from dataclasses import dataclass
from typing import NamedTuple

from .model import ImageService

_LEVELS = {"level0": 0, "level1": 1, "level2": 2}

#: Allowed per-axis pixel tolerance — servers round downscaled dimensions slightly.
_TOLERANCE = 2


[docs] @dataclass(frozen=True) class Check: """The result of a single conformance request.""" name: str level: str url: str ok: bool detail: str
[docs] @dataclass(frozen=True) class ConformanceReport: """The result of probing an image service.""" service_id: str claimed_level: str | None checks: tuple[Check, ...] @property def conforms(self) -> bool: """True if every check at or below the claimed level passed. When the level is unknown, every check must pass. """ ceiling = _LEVELS.get(self.claimed_level or "", 2) relevant = [c for c in self.checks if _LEVELS.get(c.level, 0) <= ceiling] return all(c.ok for c in relevant) if relevant else True @property def passed(self) -> tuple[Check, ...]: return tuple(c for c in self.checks if c.ok) @property def failed(self) -> tuple[Check, ...]: return tuple(c for c in self.checks if not c.ok)
[docs] def summary(self) -> str: verdict = "conforms" if self.conforms else "does NOT conform" return ( f"{len(self.passed)}/{len(self.checks)} checks passed; " f"{verdict} to {self.claimed_level or 'its claimed level'}" )
[docs] class CheckSpec(NamedTuple): """A planned request: what to fetch and the expected output dimensions.""" name: str level: str url: str expected: tuple[int, int] | None # None => verify reachability only
[docs] def plan_checks(service: ImageService) -> list[CheckSpec]: """Plan the conformance requests for a loaded image service.""" if service.width is None or service.height is None: return [] width, height = service.width, service.height level = _LEVELS.get(service.compliance_level or "", None) full_w, full_h = _max_full_size(service) specs = [ CheckSpec("full image", "level0", service.url(region="full", size="max"), (full_w, full_h)), ] if service.sizes: size = service.sizes[0] specs.append( CheckSpec( "listed size", "level0", service.url(region="full", size=f"{size.width},"), (size.width, size.height), ) ) if level is None or level >= 1: half_w = max(1, width // 2) specs.append( CheckSpec( "sizeByW", "level1", service.url(region="full", size=f"{half_w},"), (half_w, round(height * half_w / width)), ) ) region_w, region_h = max(1, width // 2), max(1, height // 2) specs.append( CheckSpec( "regionByPx", "level1", service.url(region=f"0,0,{region_w},{region_h}", size="max"), (region_w, region_h), ) ) if level is None or level >= 2: specs.append( CheckSpec( "regionByPct", "level2", service.url(region="pct:0,0,50,50", size="max"), (round(width * 0.5), round(height * 0.5)), ) ) specs.append( CheckSpec( "rotationBy90", "level2", service.url(region="full", size="max", rotation="90"), (full_h, full_w), # a 90° rotation swaps the axes ) ) return specs
[docs] def evaluate(spec: CheckSpec, status: int, content_type: str, data: bytes) -> Check: """Turn an HTTP response to a planned request into a :class:`Check`.""" if status != 200: return Check(spec.name, spec.level, spec.url, False, f"HTTP {status}") if not _is_image(content_type, data): return Check(spec.name, spec.level, spec.url, False, f"not an image ({content_type})") if spec.expected is None: return Check(spec.name, spec.level, spec.url, True, f"HTTP 200, {content_type}") dims = image_size(data) if dims is None: return Check(spec.name, spec.level, spec.url, True, "HTTP 200; dimensions not verified") ex_w, ex_h = spec.expected ok = abs(dims[0] - ex_w) <= _TOLERANCE and abs(dims[1] - ex_h) <= _TOLERANCE detail = f"got {dims[0]}x{dims[1]}, expected ~{ex_w}x{ex_h}" return Check(spec.name, spec.level, spec.url, ok, detail)
[docs] def image_size(data: bytes) -> tuple[int, int] | None: """Read (width, height) from an image's header, without decoding pixels. Supports PNG, JPEG, and GIF — the formats IIIF Image API servers return in practice. Returns ``None`` for anything else. """ if data[:8] == b"\x89PNG\r\n\x1a\n" and data[12:16] == b"IHDR": return int.from_bytes(data[16:20], "big"), int.from_bytes(data[20:24], "big") if data[:2] == b"\xff\xd8": return _jpeg_size(data) if data[:6] in (b"GIF87a", b"GIF89a"): return int.from_bytes(data[6:8], "little"), int.from_bytes(data[8:10], "little") return None
def _jpeg_size(data: bytes) -> tuple[int, int] | None: i, n = 2, len(data) while i < n - 8: # need indices up to i+8 to read the SOF width/height if data[i] != 0xFF: i += 1 continue marker = data[i + 1] if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC): return int.from_bytes(data[i + 7 : i + 9], "big"), int.from_bytes( data[i + 5 : i + 7], "big" ) i += 2 + int.from_bytes(data[i + 2 : i + 4], "big") return None def _is_image(content_type: str, data: bytes) -> bool: return content_type.lower().startswith("image/") or image_size(data) is not None def _max_full_size(service: ImageService) -> tuple[int, int]: """The dimensions ``full/max`` should return, honoring any max constraints.""" width, height = service.width, service.height scale = 1.0 if service.max_width: scale = min(scale, service.max_width / width) if service.max_height: scale = min(scale, service.max_height / height) if service.max_area: scale = min(scale, (service.max_area / (width * height)) ** 0.5) return round(width * scale), round(height * scale)