Skip to content

API reference

Everything the package exports, generated from the docstrings. The policy table says what each limit bounds and why.

sillybos: decide before decoding.

A σίλλυβος (sillybos, Latin sittybus) was the little parchment tag that hung from a rolled scroll in an ancient library, giving its title and author so that a reader could tell what a scroll was without unrolling it. This library is that tag for the files a scanning or OCR pipeline is handed. It answers one narrow question before any image library or PDF renderer opens a file: is this one of the formats I mean to read, within the limits I expect, and if not, why?

It reads the first bytes to learn what the file really is, whatever its name says; reads the header of a JPEG, PNG or TIFF with code of its own, never a decoder's; asks poppler's pdfinfo and pdfdetach, in a separate process, how many pages a PDF has, how large they are and whether it carries JavaScript, a form or attached files; and refuses, with a reason in plain words, anything outside a Policy.

from sillybos import check

verdict = check("scan.png")
verdict.ok, verdict.reasons  # (False, ('says PNG, is PostScript',))

The whole surface is check, which returns a Verdict; require, which raises Refused instead; Policy, with the presets DEFAULT and STRICT; and, with the [pillow] extra, sillybos.pillow.open_checked.

The check

check

check(path: str | PathLike[str], policy: Policy = DEFAULT) -> Verdict

Decide whether a decoder may open path, without opening it with one.

Parameters:

Name Type Description Default
path str | PathLike[str]

The file to check. It must exist; a missing file is the caller's error, not a verdict, and raises FileNotFoundError.

required
policy Policy

The limits to apply. sillybos.DEFAULT if not given.

DEFAULT

Returns:

Type Description
Verdict

A Verdict. Never raises for anything found in the file; a malformed header, a lying name and a page too large are all reasons, not exceptions.

Raises:

Type Description
FileNotFoundError

If the file does not exist.

PdfInfoMissing

If the file is a PDF and pdfinfo is not installed.

Source code in src/sillybos/check.py
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
def check(path: str | os.PathLike[str], policy: Policy = DEFAULT) -> Verdict:
    """Decide whether a decoder may open `path`, without opening it with one.

    Args:
        path: The file to check. It must exist; a missing file is the
            caller's error, not a verdict, and raises `FileNotFoundError`.
        policy: The limits to apply. [sillybos.DEFAULT][] if not given.

    Returns:
        A [Verdict][sillybos.Verdict]. Never raises for anything found *in*
            the file; a malformed header, a lying name and a page too large
            are all reasons, not exceptions.

    Raises:
        FileNotFoundError: If the file does not exist.
        sillybos.pdf.PdfInfoMissing: If the file is a PDF and `pdfinfo`
            is not installed.
    """
    path = Path(path)
    size = path.stat().st_size
    reasons: list[str] = []
    claimed = sniff.claimed_by(path.suffix)

    if size > policy.max_bytes:
        reasons.append(f"{size:,} bytes; the limit is {policy.max_bytes:,}")

    with path.open("rb") as f:
        head = f.read(sniff.HEAD_BYTES)
        if not head:
            return Verdict(path, False, None, claimed, None, ("the file is empty",))
        fmt = sniff.sniff(head)

        if fmt is None or not policy.allows(fmt):
            reasons.append(_wrong_format(fmt, claimed, policy))
            return Verdict(path, False, fmt, claimed, None, tuple(reasons))

        if policy.name_must_match and claimed is not None and claimed != fmt:
            reasons.append(f"says {sniff.describe(claimed)}, is {sniff.describe(fmt)}")
            return Verdict(path, False, fmt, claimed, None, tuple(reasons))

        if reasons:
            # Over the byte limit: do not read on, and in particular do not
            # start pdfinfo on it.
            return Verdict(path, False, fmt, claimed, None, tuple(reasons))

        try:
            facts = _facts(path, fmt, f, size, policy)
        except headers.HeaderError as exc:
            reasons.append(f"the header could not be read: {exc}")
            return Verdict(path, False, fmt, claimed, None, tuple(reasons))

    reasons.extend(_against_limits(facts, policy, fmt))
    return Verdict(path, not reasons, fmt, claimed, facts, tuple(reasons))

require

require(path: str | PathLike[str], policy: Policy = DEFAULT) -> Verdict

As check, but raise Refused instead of returning a refusal.

Source code in src/sillybos/check.py
206
207
208
209
210
211
def require(path: str | os.PathLike[str], policy: Policy = DEFAULT) -> Verdict:
    """As [check][sillybos.check.check], but raise [Refused][sillybos.Refused] instead of returning a refusal."""
    verdict = check(path, policy)
    if not verdict.ok:
        raise Refused(verdict)
    return verdict

Verdict dataclass

Verdict(path: Path, ok: bool, format: str | None, claimed: str | None, facts: Facts | None, reasons: tuple[str, ...])

The answer to "may a decoder open this file?".

Attributes:

Name Type Description
path Path

The file.

ok bool

True if nothing was found against it.

format str | None

What the file really is, from its contents: a name from sillybos.FORMATS, another name the library knows only to refuse ("postscript"), or None for an unknown format.

claimed str | None

What its extension says it is, or None if the extension is not one the library maps.

facts Facts | None

What the header said, or None if it was never read.

reasons tuple[str, ...]

Why the file was refused, one sentence each. Empty when ok.

summary

summary() -> str

One line of facts: "JPEG, 4000x6000, 1 page, 2.1 MB".

Source code in src/sillybos/check.py
133
134
135
136
137
138
139
140
def summary(self) -> str:
    """One line of facts: `"JPEG, 4000x6000, 1 page, 2.1 MB"`."""
    parts = [sniff.describe(self.format)]
    if self.facts is not None:
        parts.append(f"{self.facts.width}x{self.facts.height}")
        parts.append(f"{self.facts.pages} page{'s' if self.facts.pages != 1 else ''}")
        parts.append(f"{self.facts.bytes / 1_000_000:.1f} MB")
    return ", ".join(parts)

Facts dataclass

Facts(bytes: int, width: int, height: int, pages: int, max_pixels: int | None, max_side: int | None, encrypted: bool = False, pages_exact: bool = True, coding: str | None = None, compression: str | None = None, metadata: tuple[str, int] | None = None, colour: str | None = None, depth: int | None = None, interlaced: bool = False, tile: tuple[int, int] | None = None, subsampling: str | None = None, features: tuple[str, ...] = ())

What was read from the file before any decoder saw it.

Attributes:

Name Type Description
bytes int

The file's size.

width int

Pixels across the first page or frame; for a PDF, at the policy's render dpi.

height int

Pixels down, likewise.

pages int

Pages in a PDF, frames in a TIFF or animated PNG, 1 otherwise. For a file with more than the policy allows, the walk may have stopped early: the number is then "more than" the limit.

max_pixels int | None

The most pixels any one page or frame would decode to, or None if the page count was over the limit and the sizes were not read.

max_side int | None

The longest side, in pixels, of any one page or frame; None in the same case.

encrypted bool

Whether a PDF is encrypted. Always False for an image.

pages_exact bool

False when the frame walk stopped at the limit, so pages is a floor, not a count.

coding str | None

How a JPEG's frame is coded, by a name from sillybos.JPEG_FRAMES; None for the other formats.

compression str | None

How a TIFF's frames are compressed, by a name from sillybos.TIFF_COMPRESSIONS; None for the other formats.

metadata tuple[str, int] | None

The largest block of metadata in an image, as (what it is, bytes): a PNG chunk, a TIFF tag's data, a JPEG colour profile. None for a PDF or an image with none.

colour str | None

A PNG's colour type, by a name from sillybos.PNG_COLOUR_TYPES, or a TIFF's photometric interpretation, from sillybos.TIFF_PHOTOMETRICS; None for a JPEG or a PDF.

depth int | None

A PNG's or TIFF's bits per sample; None otherwise.

interlaced bool

Whether a PNG is Adam7 interlaced.

tile tuple[int, int] | None

A tiled TIFF's largest tile as (width, height); None for a TIFF in strips and for the other formats.

subsampling str | None

A YCbCr TIFF's chroma subsampling, horizontal by vertical, as "2x2"; None otherwise.

features tuple[str, ...]

Which of sillybos.PDF_FEATURES a PDF carries: JavaScript, a form, attached files, a launch action. Empty for an image and for a PDF with none.

Refused

Refused(verdict: Verdict)

Bases: Exception

A file did not pass. Raised by require; carries the Verdict.

Source code in src/sillybos/check.py
146
147
148
def __init__(self, verdict: Verdict) -> None:
    self.verdict = verdict
    super().__init__(str(verdict))

PdfInfoMissing

PdfInfoMissing(tool: str = 'pdfinfo')

Bases: RuntimeError

A poppler tool (pdfinfo, pdfdetach) is not on the PATH, so no PDF can be checked.

Source code in src/sillybos/pdf.py
69
70
71
72
73
def __init__(self, tool: str = "pdfinfo") -> None:
    super().__init__(
        f"{tool} (from poppler) is needed to check a PDF and is not on the PATH: "
        "`brew install poppler` on macOS, `apt install poppler-utils` on Debian"
    )

The policy

Policy dataclass

Policy(formats: frozenset[str] = (lambda: frozenset(FORMATS))(), max_bytes: int = 512 * MIB, max_pixels: int = 89478485, max_side: int = 30000, max_pages: int = 500, max_metadata_bytes: int = 4 * MIB, render_dpi: int = 200, name_must_match: bool = True, jpeg_frames: frozenset[str] = (lambda: frozenset({'baseline', 'extended', 'progressive'}))(), png_colour_types: frozenset[str] = (lambda: frozenset(PNG_COLOUR_TYPES))(), max_png_bit_depth: int = 16, allow_interlaced_png: bool = False, allow_tiled_tiff: bool = True, tiff_photometrics: frozenset[str] = (lambda: frozenset({'greyscale', 'rgb', 'palette', 'cmyk', 'ycbcr'}))(), tiff_subsamplings: frozenset[str] = (lambda: frozenset({'1x1', '2x1', '1x2', '2x2'}))(), tiff_compressions: frozenset[str] = (lambda: frozenset({'none', 'ccitt', 'lzw', 'jpeg', 'deflate', 'packbits'}))(), allow_encrypted_pdf: bool = False, pdf_features: frozenset[str] = frozenset(), pdfinfo_timeout: float = 30.0)

The limits a file must sit inside to pass sillybos.check.

Parameters:

Name Type Description Default
formats frozenset[str]

Which of FORMATS may pass. A file of any other format is refused whatever its name says.

(lambda: frozenset(FORMATS))()
max_bytes int

The largest file, in bytes. Checked before anything is read, so an enormous file is never handed to pdfinfo.

512 * MIB
max_pixels int

The most pixels in any one page or frame once decoded. For a PDF page, the pixels it would render to at render_dpi. The default is the threshold at which Pillow warns of a decompression bomb, enforced here rather than left to a global that another library may have raised.

89478485
max_side int

The most pixels along either side of any one page, frame or tile. A pixel budget alone lets through a page a few pixels wide and a million tall, and an abnormally tall image is what drove libtiff's RGBA reader to write colour data to an arbitrary address (CVE-2025-9900). No scanner writes a side longer than a few tens of thousands of pixels: the default is three times the longest side in the corpus (9,933 pixels, A3 at 600 dpi).

30000
max_pages int

The most pages in a PDF, or frames in a multi-frame TIFF or animated PNG. Bounds the time and the memory a run takes, and stops the header walk itself on a file with a million empty frames.

500
max_metadata_bytes int

The largest block of metadata in an image: a PNG chunk other than image data, a TIFF tag's out-of-line data, a JPEG colour profile across its segments. A scan carries a few kilobytes of EXIF and a colour profile well under a megabyte; a block the size of the file is where parsers' quadratic paths and allocation flaws have been found (CVE-2026-96643, CVE-2023-6277). Pillow refuses a PNG text chunk over 1 MiB on its own; the default leaves room for a large profile or XMP.

4 * MIB
render_dpi int

The resolution the PDF renderer will be run at, so a page's size in points can be turned into pixels before the renderer allocates them.

200
name_must_match bool

Refuse a file whose extension says one allowed format and whose contents are another (a JPEG called .png). The readers would cope, but a file whose name lies is not one to trust, and the mismatch is the first thing an operator wants to know.

True
jpeg_frames frozenset[str]

Which of JPEG_FRAMES may pass. The default is what a scanner writes: baseline, extended and progressive. A lossless, hierarchical or arithmetic-coded frame goes down a path of the decoder that almost no file exercises; a lossless frame was the way into a heap overflow in Pillow's JPEG decoder (CVE-2026-112403). A multi-picture JPEG's pictures count as pages and are each read.

(lambda: frozenset({'baseline', 'extended', 'progressive'}))()
png_colour_types frozenset[str]

Which of PNG_COLOUR_TYPES may pass. The default is all five; a policy for scans alone can keep to greyscale and RGB, which closes libpng's palette paths (CVE-2025-64720, CVE-2025-66293, CVE-2026-33636).

(lambda: frozenset(PNG_COLOUR_TYPES))()
max_png_bit_depth int

The deepest PNG sample. A scanner writes 8 bits; some archival software writes 16-bit greyscale, so the default allows it and the strict preset does not.

16
allow_interlaced_png bool

Pass an Adam7-interlaced PNG. No scanner writes one, and the interlaced 16-bit path was the way into two libpng overflows (CVE-2025-65018, CVE-2026-22695), so the default refuses.

False
tiff_photometrics frozenset[str]

Which of TIFF_PHOTOMETRICS may pass. The default is what a scanner writes: greyscale, RGB, palette, CMYK and YCbCr. Every frame of a file must share one.

(lambda: frozenset({'greyscale', 'rgb', 'palette', 'cmyk', 'ycbcr'}))()
tiff_subsamplings frozenset[str]

Which of TIFF_SUBSAMPLINGS a YCbCr TIFF may declare. The default is the four a scanner writes; the factor-of-four paths are where libtiff's most recent heap write was found (CVE-2026-4775).

(lambda: frozenset({'1x1', '2x1', '1x2', '2x2'}))()
allow_tiled_tiff bool

Pass a TIFF laid out in tiles rather than strips. A scanner writes strips; digitisation and mapping software write tiles, so the default allows them and the strict preset does not. A tile is what the decoder allocates whole, so a tile is also held to max_pixels on its own; the tiled decode path is where two of Pillow's recent over-reads were (CVE-2026-112395, CVE-2026-112404).

True
tiff_compressions frozenset[str]

Which of TIFF_COMPRESSIONS may pass. The default is what a scanner writes: none, CCITT, LZW, JPEG, Deflate and PackBits. The other codecs are where most of libtiff's flaws have been found (PixarLog most recently, CVE-2026-12912), and a scan never needs them. Every frame of a file must use the same scheme.

(lambda: frozenset({'none', 'ccitt', 'lzw', 'jpeg', 'deflate', 'packbits'}))()
allow_encrypted_pdf bool

Pass a PDF that pdfinfo reports as encrypted. A scanned book never is, so the default refuses.

False
pdf_features frozenset[str]

Which of PDF_FEATURES a PDF may carry: JavaScript, a form, attached files, a launch action. Each is a way for a document to make the viewer act rather than draw, and a scanned book has none, so the default allows none. Forms are read from pdfinfo's report and attached files from pdfdetach's, both of which see inside compressed object streams; a launch action is found by its name in the file, since no poppler tool reports one, so an action hidden in a compressed stream is not seen; JavaScript is looked for both ways.

frozenset()
pdfinfo_timeout float

Seconds to give pdfinfo before giving up on a PDF, which is then refused. A crafted file can make a parser loop; a limit turns that into one file's failure.

30.0

allows

allows(fmt: str) -> bool

Whether fmt (a name from FORMATS) may pass.

Source code in src/sillybos/policy.py
277
278
279
def allows(self, fmt: str) -> bool:
    """Whether `fmt` (a name from [FORMATS][sillybos.FORMATS]) may pass."""
    return fmt in self.formats

DEFAULT module-attribute

DEFAULT = Policy()

For files from a trusted laptop or your own scanner.

STRICT module-attribute

STRICT = Policy(max_bytes=128 * MIB, max_pixels=50000000, max_side=15000, max_pages=100, max_metadata_bytes=1 * MIB, max_png_bit_depth=8, allow_tiled_tiff=False)

For files from outside: smaller, shorter, and no more lenient on format.

The names

What a policy's fields and a verdict's facts are spelt with. Each tuple is every name the library knows, in the order the reasons list them; a policy chooses from it.

FORMATS module-attribute

FORMATS: tuple[str, ...] = ('jpeg', 'png', 'tiff', 'pdf')

The only formats the library will ever pass: the ones a scan can be. Everything else is refused by name. Adding a format is adding attack surface, so one is added only when someone shows a scan in it.

JPEG_FRAMES module-attribute

JPEG_FRAMES: tuple[str, ...] = ('baseline', 'extended', 'progressive', 'lossless', 'hierarchical', 'arithmetic')

Every way a JPEG frame can be coded, as its SOF marker says. A scanner writes the first three. The rest are legal, rare, and the decoder's least exercised code.

PDF_FEATURES module-attribute

PDF_FEATURES: tuple[str, ...] = ('javascript', 'forms', 'attachments', 'launch')

Everything a PDF can carry that makes a viewer do something other than draw the page: JavaScript, an interactive form, attached (embedded) files, and a launch action that runs a program. A scanned book has none of them, so the default policy refuses them all; a policy names the ones it will pass.

PNG_COLOUR_TYPES module-attribute

PNG_COLOUR_TYPES: tuple[str, ...] = ('greyscale', 'rgb', 'palette', 'greyscale-alpha', 'rgba')

Every colour type a PNG can have, by name. A scanner writes greyscale or RGB; the palette paths are where several of libpng's recent flaws were.

TIFF_COMPRESSIONS module-attribute

TIFF_COMPRESSIONS: tuple[str, ...] = ('none', 'ccitt', 'lzw', 'jpeg', 'deflate', 'packbits', 'old-jpeg', 'next', 'thunderscan', 'it8', 'pixar', 'jbig', 'sgilog', 'jpeg2000', 'lerc', 'lzma', 'zstd', 'webp', 'jxl')

Every TIFF compression scheme the library knows, by name. A scanner writes the first six. The rest are codecs in libtiff that a scan never needs and that most of its security history lives in; a code outside this list is refused whatever the policy says.

TIFF_PHOTOMETRICS module-attribute

TIFF_PHOTOMETRICS: tuple[str, ...] = ('greyscale', 'rgb', 'palette', 'cmyk', 'ycbcr', 'mask', 'cielab', 'icclab', 'itulab', 'cfa', 'logl', 'logluv', 'linear-raw')

Every photometric interpretation a TIFF can declare, by name. A scanner writes the first five (YCbCr is what a JPEG-compressed scan is); the rest are colour models a scan never uses. A value outside this list is refused whatever the policy says.

TIFF_SUBSAMPLINGS module-attribute

TIFF_SUBSAMPLINGS: tuple[str, ...] = ('1x1', '2x1', '1x2', '2x2', '4x1', '1x4', '4x2', '2x4', '4x4')

Every chroma subsampling a YCbCr TIFF can declare, horizontal by vertical. A scanner writes the first four; the factor-of-four paths are the least exercised in libtiff (CVE-2026-4775).

With Pillow

Check a file, then open it with Pillow using only the readers the policy allows.

The one-line fix that October 2026's Pillow advisories made everyone want, packaged: Image.open takes a formats list, and a file that is not one of them is never handed to the other readers, whatever it claims to be. open_checked runs sillybos.require first, so a file that is too large, lies about its format or has a page beyond the limit is refused before Pillow sees it, and then opens it with formats set from the policy.

Needs the [pillow] extra: pip install 'sillybos[pillow]'.

pillow_formats

pillow_formats(policy: Policy = DEFAULT) -> list[str]

The formats argument for Image.open, from a policy: ["JPEG", "PNG", "TIFF"].

Source code in src/sillybos/pillow.py
28
29
30
def pillow_formats(policy: Policy = DEFAULT) -> list[str]:
    """The `formats` argument for `Image.open`, from a policy: `["JPEG", "PNG", "TIFF"]`."""
    return [_PILLOW_NAMES[name] for name in sorted(policy.formats) if name in _PILLOW_NAMES]

open_checked

open_checked(path: str | PathLike[str], policy: Policy = DEFAULT) -> Image

Check path against policy, then open it with only the allowed Pillow readers.

A PDF passes the check but Pillow does not read PDFs; opening one raises PIL.UnidentifiedImageError, as it would for any file outside formats.

Raises:

Type Description
Refused

If the file does not pass the policy.

ImportError

If Pillow is not installed.

Source code in src/sillybos/pillow.py
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
def open_checked(path: str | os.PathLike[str], policy: Policy = DEFAULT) -> Image.Image:
    """Check `path` against `policy`, then open it with only the allowed Pillow readers.

    A PDF passes the check but Pillow does not read PDFs; opening one raises
    `PIL.UnidentifiedImageError`, as it would for any file outside
    `formats`.

    Raises:
        sillybos.Refused: If the file does not pass the policy.
        ImportError: If Pillow is not installed.
    """
    try:
        from PIL import Image
    except ImportError:
        raise ImportError("open_checked needs Pillow: pip install 'sillybos[pillow]'") from None
    require(path, policy)
    return Image.open(path, formats=pillow_formats(policy))