Skip to content

Core

Configuration models used by the LaTeX renderer.

CommonConfig

build_dir (Path | None)
Base directory for LaTeX artifacts. Provide an absolute or project-relative path to override the default export root that books inherit when they do not specify one.
mermaid_config (Path | None)
Path to a Mermaid configuration file. Point to a .json or .mermaid document to customise diagram rendering.
project_dir (Path | None)
MkDocs project root used to resolve relative paths when copying additional assets.
BCP 47 language code forwarded to LaTeX for hyphenation, translations, and
metadata localisation.
legacy_latex_accents (bool)
When True, escape accented characters, ligatures, and typographic punctuation using legacy LaTeX macros. When False, keep Unicode glyphs compatible with LuaLaTeX/XeLaTeX (default).
language (str | None)
BCP 47 language code forwarded to LaTeX for hyphenation, translations, and metadata localisation.

CoverConfig

name (str)
Identifier of the cover template to apply. The value must match a template declared in the cover bundle.
color (str | None)
Primary colour override applied by the cover template.
logo (str | None)
Project-relative path to a logo asset displayed on the cover.

BookConfig

root (str | None)
Navigation entry treated as the starting point for the book. Use it when the root differs from the first MkDocs page.
title (str | None)
Title displayed on the cover and in output metadata. Falls back to site_name when omitted.
subtitle (str | None)
Optional subtitle appended to the cover and metadata.
author (str | None)
Primary author string rendered in the book metadata.
year (int | None)
Publication year to freeze in the output when site_date is not supplied.
email (str | None)
Contact address printed in the credits.
folder (Path | None)
Output directory for the rendered book. Defaults to a slug of the title when not provided.
frontmatter (list[str])
MkDocs page titles moved before the main matter.
backmatter (list[str])
MkDocs page titles grouped into the appendices.
base_level (int)
Heading offset applied to align section numbering with the template expectations.
copy_files (dict[str, str])
Mapping of glob patterns to destination paths for copying additional assets alongside the book.
index_is_foreword (bool)
Treat the index page as a foreword, typically removing numbering.
drop_title_index (bool)
Suppress the index page heading when it acts as a foreword.
cover (CoverConfig)
Nested configuration controlling the book cover.

LaTeXConfig

enabled (bool)
Toggle LaTeX generation without discarding configuration.
books (list[BookConfig])
Collection of books to produce, inheriting defaults from CommonConfig.
clean_assets (bool)
Remove stale assets from build_dir to avoid accumulating unused files.

BookConfig

Bases: CommonConfig

Configuration for an individual book.

CommonConfig

Bases: BaseModel

Common configuration propagated to each book.

CoverConfig

Bases: BaseModel

Metadata used to render book covers.

LaTeXConfig

Bases: CommonConfig

Configuration for LaTeX taken from mkdocs.yml.

propagate

propagate() -> LaTeXConfig

Propagate common values to nested book configurations.

Source code in src/texsmith/core/config.py
158
159
160
161
162
163
164
165
166
167
168
169
170
171
@model_validator(mode="after")
def propagate(self) -> LaTeXConfig:
    """Propagate common values to nested book configurations."""
    to_propagate = (
        "build_dir",
        "mermaid_config",
        "project_dir",
        "language",
    )
    for book in self.books:
        for key in to_propagate:
            if getattr(book, key) is None:
                setattr(book, key, getattr(self, key))
    return self

Rendering context primitives shared across the LaTeX pipeline.

AssetRegistry dataclass

AssetRegistry(
    output_root: Path,
    assets_map: MutableMapping[str, Path] = dict(),
    copy_assets: bool = True,
)

Centralised registry for rendered assets.

get

get(key: str) -> Path

Retrieve a previously registered artefact.

Source code in src/texsmith/core/context.py
124
125
126
127
128
129
def get(self, key: str) -> Path:
    """Retrieve a previously registered artefact."""
    try:
        return Path(self.assets_map[key])
    except KeyError as exc:
        raise AssetMissingError(f"Missing asset '{key}'") from exc

items

items() -> Iterable[tuple[str, Path]]

Iterate over registered assets yielding key/path pairs.

Source code in src/texsmith/core/context.py
131
132
133
def items(self) -> Iterable[tuple[str, Path]]:
    """Iterate over registered assets yielding key/path pairs."""
    return ((k, Path(v)) for k, v in self.assets_map.items())

latex_path

latex_path(path: Path | str) -> str

Return a LaTeX-friendly path for an artefact.

Source code in src/texsmith/core/context.py
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
def latex_path(self, path: Path | str) -> str:
    """Return a LaTeX-friendly path for an artefact."""
    candidate = Path(path)
    if not candidate.is_absolute():
        return candidate.as_posix()

    output_dir = self.output_root.parent
    try:
        reference = candidate.relative_to(output_dir)
    except ValueError:
        try:
            reference = Path(os.path.relpath(candidate, output_dir))
        except ValueError:
            reference = candidate

    return reference.as_posix()

lookup

lookup(key: str) -> Path | None

Return a previously registered artefact when available.

Source code in src/texsmith/core/context.py
119
120
121
122
def lookup(self, key: str) -> Path | None:
    """Return a previously registered artefact when available."""
    stored = self.assets_map.get(key)
    return Path(stored) if stored is not None else None

register

register(key: str, artefact: Path | str) -> Path

Register a generated artefact and return its resolved path.

Source code in src/texsmith/core/context.py
111
112
113
114
115
116
117
def register(self, key: str, artefact: Path | str) -> Path:
    """Register a generated artefact and return its resolved path."""
    path = Path(artefact)
    if not path.is_absolute():
        path = (self.output_root / path).resolve() if self.copy_assets else Path(path)
    self.assets_map[key] = path
    return path

DocumentState dataclass

DocumentState(
    abbreviations: dict[str, str] = dict(),
    acronym_keys: dict[str, str] = dict(),
    acronyms: dict[str, tuple[str, str]] = dict(),
    acronym_entry_groups: dict[str, str] = dict(),
    acronym_groups: list[tuple[str, str]] = list(),
    has_index_entries: bool = False,
    requires_shell_escape: bool = False,
    bibliography: dict[str, dict[str, Any]] = dict(),
    citations: list[str] = list(),
    pygments_styles: dict[str, str] = dict(),
    script_usage: list[dict[str, Any]] = list(),
    fallback_summary: list[dict[str, Any]] = list(),
    fonts_scanned: bool = False,
    callouts_used: bool = False,
    required_fragments: set[str] = set(),
    required_packages: list[str] = list(),
    index_registries: list[str] = list(),
    contract_path: bool = False,
)

In-memory state accumulated while rendering a document.

record_citation

record_citation(key: str) -> None

Track citation keys used throughout the document.

Source code in src/texsmith/core/context.py
 95
 96
 97
 98
 99
100
def record_citation(self, key: str) -> None:
    """Track citation keys used throughout the document."""
    if key in self._citation_index:
        return
    self._citation_index.add(key)
    self.citations.append(key)

remember_abbreviation

remember_abbreviation(
    term: str,
    description: str,
    *,
    emitter: DiagnosticEmitter | None = None,
) -> str

Track abbreviation definitions while ensuring consistency.

Source code in src/texsmith/core/context.py
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
def remember_abbreviation(
    self, term: str, description: str, *, emitter: DiagnosticEmitter | None = None
) -> str:
    """Track abbreviation definitions while ensuring consistency."""
    normalised_term = term.strip()
    normalised_description = description.strip()
    if not normalised_term or not normalised_description:
        return ""

    existing_description = self.abbreviations.get(normalised_term)
    if existing_description is not None:
        if existing_description != normalised_description:
            emit_diagnostic(
                emitter,
                "metadata-invalid",
                f"Inconsistent acronym definition for '{normalised_term}': "
                f"'{existing_description}' vs '{normalised_description}'",
            )
        return self.acronym_keys.get(normalised_term, "")

    key = self._generate_acronym_key(normalised_term)
    self.abbreviations[normalised_term] = normalised_description
    self.acronym_keys[normalised_term] = key
    self.acronyms[key] = (normalised_term, normalised_description)
    return key

RenderContext dataclass

RenderContext(
    config: BookConfig,
    document: Any,
    assets: AssetRegistry,
    state: DocumentState = DocumentState(),
    runtime: dict[str, Any] = dict(),
)

Shared context passed to the asset helpers during a conversion.

Context objects used during document conversion.

ConversionContext dataclass

ConversionContext(
    document: Document,
    request: ConversionRequest,
    output_dir: Path,
    language: str,
    generation: GenerationStrategy,
    template_runtime: TemplateRuntime | None = None,
    template_overrides: dict[str, Any] = dict(),
    slot_requests: dict[str, str] = dict(),
    bibliography_collection: BibliographyCollection
    | None = None,
    bibliography_map: dict[str, dict[str, Any]] = dict(),
    config: BookConfig | None = None,
    template_binding: TemplateBinding | None = None,
    resolution: ResolutionChain | None = None,
)

Per-document resolved state for the conversion pipeline.

The context is built in two successive phases:

  • :func:resolve_conversion_context (in :mod:.conversion.execution) fills the inputs derived from the document, request and front matter. At this point :attr:config and :attr:template_binding are still None because no template has been chosen yet.
  • :func:bind_template (in :mod:.conversion.templates) resolves the selected template and populates the binding-dependent fields.

Per-slot render flags (slot_options) are read from document.slot_options on demand rather than copied here, to avoid duplicating state that the :class:Document already owns.

GenerationStrategy dataclass

GenerationStrategy(
    copy_assets: bool = True,
    convert_assets: bool = False,
    hash_assets: bool = False,
)

Rendering strategy toggles shared across conversion workflows.

from_request classmethod

from_request(
    request: ConversionRequest,
) -> GenerationStrategy

The asset flags of a request, as every backend must read them.

Source code in src/texsmith/core/conversion_contexts.py
29
30
31
32
33
34
35
36
@classmethod
def from_request(cls, request: ConversionRequest) -> GenerationStrategy:
    """The asset flags of a request, as every backend must read them."""
    return cls(
        copy_assets=request.copy_assets,
        convert_assets=request.convert_assets,
        hash_assets=request.hash_assets,
    )

Shared conversion primitives exposed by the core package.

ConversionRequest dataclass

ConversionRequest(
    documents: Sequence[Path] = tuple(),
    bibliography_files: Sequence[Path] = list(),
    front_matter: Mapping[str, Any] | None = None,
    front_matter_paths: Sequence[Path] = tuple(),
    slot_assignments: Mapping[
        Path, Sequence[SlotAssignment]
    ] = dict(),
    include_paths: Sequence[Path] = tuple(),
    default_include_paths: Sequence[Path] = tuple(),
    root_dir: Path | None = None,
    base_level: int = 0,
    strip_heading_all: bool = False,
    strip_heading_first_document: bool = False,
    promote_title: bool = True,
    suppress_title: bool = False,
    numbered: bool = True,
    template: str | None = None,
    render_dir: Path | None = None,
    template_options: Mapping[str, Any] = dict(),
    embed_documents: bool = False,
    enable_fragments: Sequence[str] = tuple(),
    disable_fragments: Sequence[str] = tuple(),
    copy_assets: bool = True,
    convert_assets: bool = False,
    hash_assets: bool = False,
    manifest: bool = False,
    persist_debug_ir: bool = False,
    language: str | None = None,
    http_user_agent: str | None = None,
    legacy_latex_accents: bool = False,
    diagrams_backend: str | None = None,
    emitter: DiagnosticEmitter | None = None,
)

Immutable description of conversion inputs and engine settings.

Frozen, so the docstring holds: a caller that needs a variation asks for one with :meth:replace, which deep-copies every field but the emitter.

replace

replace(**changes: Any) -> ConversionRequest

A deep copy of this request with changes applied.

The emitter is shared, not copied: it collects the run's diagnostics and a copy would collect nothing.

Source code in src/texsmith/core/conversion/models.py
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
def replace(self, **changes: Any) -> ConversionRequest:
    """A deep copy of this request with ``changes`` applied.

    The emitter is shared, not copied: it collects the run's diagnostics
    and a copy would collect nothing.
    """
    payload: dict[str, Any] = {}
    for definition in fields(self):
        if definition.name in changes:
            payload[definition.name] = changes[definition.name]
            continue
        value = getattr(self, definition.name)
        payload[definition.name] = (
            value if definition.name == "emitter" else copy.deepcopy(value)
        )
    unknown = set(changes) - {definition.name for definition in fields(self)}
    if unknown:
        raise TypeError(f"ConversionRequest has no field(s): {', '.join(sorted(unknown))}")
    return ConversionRequest(**payload)

SlotAssignment dataclass

SlotAssignment(
    slot: str, selector: str | None, include_document: bool
)

Directive mapping a document onto a template slot.

SlotOptions dataclass

SlotOptions(flatten: bool = False)

Per-slot rendering flags parsed from front matter.

UnsupportedInputError

Bases: Exception

Raised when a CLI input argument cannot be processed.

coerce_slot_selector

coerce_slot_selector(payload: Any) -> str | None

Normalise a selector definition coming from front matter.

Source code in src/texsmith/core/conversion/inputs.py
35
36
37
38
39
40
41
42
43
44
45
def coerce_slot_selector(payload: Any) -> str | None:
    """Normalise a selector definition coming from front matter."""
    if isinstance(payload, str):
        candidate = payload.strip()
        return candidate or None
    if isinstance(payload, Mapping):
        for key in ("label", "title", "section"):
            value = payload.get(key)
            if isinstance(value, str) and value.strip():
                return value.strip()
    return None

extract_front_matter_slots

extract_front_matter_slots(
    front_matter: Mapping[str, Any],
) -> tuple[dict[str, str], dict[str, SlotOptions]]

Return front-matter slot selectors alongside their per-slot options.

Source code in src/texsmith/core/conversion/inputs.py
109
110
111
112
113
114
def extract_front_matter_slots(
    front_matter: Mapping[str, Any],
) -> tuple[dict[str, str], dict[str, SlotOptions]]:
    """Return front-matter slot selectors alongside their per-slot options."""
    root_slots = front_matter.get("slots") or front_matter.get("entrypoints")
    return parse_slot_mapping(root_slots)

parse_slot_mapping

parse_slot_mapping(
    raw: Any,
) -> tuple[dict[str, str], dict[str, SlotOptions]]

Parse slot mappings together with their per-slot options.

Accepts the three front-matter shapes {name: selector}, [{target/slot: ..., label: ...}] and the bare "name:selector" string form. Returns a (selectors, options) tuple where options only contains entries whose values differ from the SlotOptions defaults, so callers can ignore it when they don't care.

Source code in src/texsmith/core/conversion/inputs.py
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
def parse_slot_mapping(
    raw: Any,
) -> tuple[dict[str, str], dict[str, SlotOptions]]:
    """Parse slot mappings together with their per-slot options.

    Accepts the three front-matter shapes ``{name: selector}``,
    ``[{target/slot: ..., label: ...}]`` and the bare ``"name:selector"``
    string form. Returns a ``(selectors, options)`` tuple where ``options``
    only contains entries whose values differ from the ``SlotOptions``
    defaults, so callers can ignore it when they don't care.
    """
    overrides: dict[str, str] = {}
    options: dict[str, SlotOptions] = {}
    if not raw:
        return overrides, options

    def _record(name: str, selector: str, option_payload: Any) -> None:
        key = name.strip()
        if not key or not selector:
            return
        overrides[key] = selector
        slot_options = _extract_slot_options(option_payload)
        if slot_options != SlotOptions():
            options[key] = slot_options

    if isinstance(raw, Mapping):
        for slot_name, payload in raw.items():
            if not isinstance(slot_name, str):
                continue
            selector = coerce_slot_selector(payload)
            if selector:
                _record(slot_name, selector, payload)
        return overrides, options

    if isinstance(raw, Iterable) and not isinstance(raw, str | bytes):
        for entry in raw:
            if not isinstance(entry, Mapping):
                continue
            slot_name = entry.get("target") or entry.get("slot")
            if not isinstance(slot_name, str):
                continue
            selector = entry.get("label") or entry.get("title") or entry.get("section")
            selector_value = coerce_slot_selector(selector)
            if not selector_value:
                selector_value = coerce_slot_selector(entry)
            if selector_value:
                _record(slot_name, selector_value, entry)
        return overrides, options

    if isinstance(raw, str):
        entry = raw.strip()
        if entry and ":" in entry:
            name, selector = entry.split(":", 1)
            selector = selector.strip()
            if selector:
                _record(name, selector, None)
        return overrides, options

    return overrides, options

Diagnostics emitters

texsmith.diagnostics defines the DiagnosticEmitter protocol plus a few stock implementations. Pass any emitter into ConversionService, convert_documents, or TemplateSession to see the findings as they happen.

An emitter is a presenter. It owns a DiagnosticSink, and the sink does the collecting: it deduplicates, it answers strict_failed(), and it owns the FileTable every document of the run registers in — which is why the emitter you parse with must be the emitter you render with, or a span will name the wrong file.

Emitter Description Typical usage
CliEmitter (texsmith.ui.cli.diagnostics) Rich-powered emitter used by the Typer CLI. Respects -v and --debug, paints warnings as panels, and streams structured events to the diagnostics sidebar. Default when running texsmith. Import it in automation scripts when you want human-friendly output.
LoggingEmitter Renders each record as the path:line:col: severity code: message line and logs it at the matching level. Daemons, notebooks, or services that rely on existing logging policy.
NullEmitter Shows nothing, and still records: the run's diagnostics stay readable on emitter.sink and on each Document. Unit tests, benchmarking, and any caller that reads the findings afterwards.

To write your own, subclass SinkEmitter and implement render(diagnostic, cause) and event(name, payload); everything else is the sink's. Emitters expose a debug_enabled flag so downstream handlers can decide whether to include stack traces or expensive state dumps.

Diagnostics shared by TeXSmith's Python passes and the tmark bindings.

See specs/migration/python-ir-and-passes.md §6 and docs/guide/diagnostics.md.

Diagnostic dataclass

Diagnostic(
    code: str,
    severity: Severity,
    span: Span,
    message: str,
    fix: Fix | None = None,
    related: tuple[tuple[Span, str], ...] = (),
    origin: str = "texsmith",
)

One finding, from a Python pass or from the tmark bindings.

key property

key: tuple[str, Span, str]

The identity the sink deduplicates on.

to_json

to_json() -> dict[str, Any]

Tmark's JSON shape plus origin; fix and related only when set.

Source code in src/texsmith/diagnostics/model.py
105
106
107
108
109
110
111
112
113
114
115
116
117
118
def to_json(self) -> dict[str, Any]:
    """Tmark's JSON shape plus ``origin``; ``fix`` and ``related`` only when set."""
    payload: dict[str, Any] = {
        "code": self.code,
        "severity": self.severity.value,
        "span": self.span.to_json(),
        "message": self.message,
    }
    if self.fix is not None:
        payload["fix"] = self.fix.to_json()
    if self.related:
        payload["related"] = [[span.to_json(), label] for span, label in self.related]
    payload["origin"] = self.origin
    return payload

DiagnosticEmitter

Bases: Protocol

What a stage may ask of the object presenting its findings.

sink is the run's collector and, through it, the run's :class:~texsmith.diagnostics.FileTable. It is part of the interface because the file a span names is decided by the table a record is recorded against: a caller that could not reach it had to number its own files from zero, and two documents then carried the same span.file.

DiagnosticSink

DiagnosticSink(
    files: FileTable | None = None,
    *,
    on_emit: OnEmit | None = None,
    containers: Iterable[str] = (),
)

Collects diagnostics for one run.

files is the table spans render against; passes and the loader share it with the sink so a record emitted in an included file prints that file's name.

Source code in src/texsmith/diagnostics/sink.py
57
58
59
60
61
62
63
64
65
66
67
68
69
def __init__(
    self,
    files: FileTable | None = None,
    *,
    on_emit: OnEmit | None = None,
    containers: Iterable[str] = (),
) -> None:
    self.files = files if files is not None else FileTable()
    self._records: list[Diagnostic] = []
    self._index: set[tuple[str, Span, str]] = set()
    self._on_emit = on_emit
    self._containers: set[str] = set()
    self.declare_containers(containers)

declared_containers property

declared_containers: frozenset[str]

The container names the active template reads (containers).

add

add(
    diagnostic: Diagnostic,
    *,
    cause: BaseException | None = None,
) -> bool

Record diagnostic; False when it is a duplicate or a declared container.

Source code in src/texsmith/diagnostics/sink.py
89
90
91
92
93
94
95
96
97
98
99
def add(self, diagnostic: Diagnostic, *, cause: BaseException | None = None) -> bool:
    """Record ``diagnostic``; ``False`` when it is a duplicate or a declared container."""
    if diagnostic.key in self._index:
        return False
    if self._containers and unknown_container(diagnostic) in self._containers:
        return False
    self._index.add(diagnostic.key)
    self._records.append(diagnostic)
    if self._on_emit is not None:
        self._on_emit(diagnostic, cause)
    return True

declare_containers

declare_containers(names: Iterable[str]) -> None

Declare container names a template's passes consume.

solution is a construct of the template, not of the language: tmark reports it as container-unknown when it parses the document, long before the pass that rewrites it runs, and a pass cannot take a record back. Declaring the name here drops that record where every stage's findings are collected, so it is absent from the terminal, from the counts and from --diagnostics-json — and every other name still warns.

Source code in src/texsmith/diagnostics/sink.py
76
77
78
79
80
81
82
83
84
85
86
87
def declare_containers(self, names: Iterable[str]) -> None:
    """Declare container names a template's passes consume.

    ``::: solution`` is a construct of the template, not of the language:
    tmark reports it as ``container-unknown`` when it parses the document,
    long before the pass that rewrites it runs, and a pass cannot take a
    record back. Declaring the name here drops that record where every
    stage's findings are collected, so it is absent from the terminal,
    from the counts and from ``--diagnostics-json`` — and every other name
    still warns.
    """
    self._containers.update(name.strip() for name in names if name and name.strip())

emit

emit(
    code: str,
    span: Span,
    message: str,
    *,
    severity: Severity | None = None,
    fix: Fix | None = None,
    related: Iterable[tuple[Span, str]] = (),
) -> Diagnostic

Build and record a TeXSmith diagnostic, severity defaulting from the code table.

Source code in src/texsmith/diagnostics/sink.py
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
def emit(
    self,
    code: str,
    span: Span,
    message: str,
    *,
    severity: Severity | None = None,
    fix: Fix | None = None,
    related: Iterable[tuple[Span, str]] = (),
) -> Diagnostic:
    """Build and record a TeXSmith diagnostic, severity defaulting from the code table."""
    diagnostic = Diagnostic(
        code=code,
        severity=severity if severity is not None else default_severity(code),
        span=span,
        message=message,
        fix=fix,
        related=tuple(related),
    )
    self.add(diagnostic)
    return diagnostic

extend_from_tmark

extend_from_tmark(
    records: Iterable[Mapping[str, Any]],
) -> int

Record the diagnostics of a tmark result; returns how many were new.

Source code in src/texsmith/diagnostics/sink.py
123
124
125
def extend_from_tmark(self, records: Iterable[Mapping[str, Any]]) -> int:
    """Record the diagnostics of a tmark result; returns how many were new."""
    return sum(1 for record in records if self.add(from_tmark(record)))

sorted

sorted() -> list[Diagnostic]

The records grouped per file and ordered by position.

Source code in src/texsmith/diagnostics/sink.py
138
139
140
def sorted(self) -> list[Diagnostic]:
    """The records grouped per file and ordered by position."""
    return sorted(self._records, key=sort_key)

strict_failed

strict_failed() -> bool

Whether any warning or error was recorded (--strict fails the run).

Source code in src/texsmith/diagnostics/sink.py
127
128
129
def strict_failed(self) -> bool:
    """Whether any warning or error was recorded (``--strict`` fails the run)."""
    return any(record.severity >= Severity.WARNING for record in self._records)

to_json

to_json() -> list[dict[str, Any]]

The records for editors and CI: tmark's shape plus the printed location.

Source code in src/texsmith/diagnostics/sink.py
142
143
144
145
146
147
148
149
150
151
152
def to_json(self) -> list[dict[str, Any]]:
    """The records for editors and CI: tmark's shape plus the printed location."""
    payload: list[dict[str, Any]] = []
    for record in self.sorted():
        entry = record.to_json()
        location = locate(record.span, self.files)
        entry["path"] = str(location[0]) if location is not None else None
        entry["line"] = location[1] if location is not None else None
        entry["col"] = location[2] if location is not None else None
        payload.append(entry)
    return payload

worst

worst() -> Severity | None

The highest severity recorded, None when the sink is empty.

Source code in src/texsmith/diagnostics/sink.py
131
132
133
def worst(self) -> Severity | None:
    """The highest severity recorded, ``None`` when the sink is empty."""
    return max((record.severity for record in self._records), default=None)

FileTable

FileTable()

The files of one build, addressed by :class:FileId.

Paths are stored as given, without symlink resolution, so TeXSmith and tmark agree on the name a diagnostic prints. A file registered with an empty text (HTML input, a path only known by name) renders without a line and column.

Source code in src/texsmith/diagnostics/files.py
133
134
def __init__(self) -> None:
    self._files: list[SourceFile] = []

add

add(path: PurePath | str, text: str) -> FileId

Register a file and return its id (the next free one).

A PurePath already given (a PurePosixPath display name, say) is kept as is; only a plain string is turned into a native Path, as before. This is what lets a caller opt into a posix-stable name without changing what every other caller gets.

Source code in src/texsmith/diagnostics/files.py
136
137
138
139
140
141
142
143
144
145
146
def add(self, path: PurePath | str, text: str) -> FileId:
    """Register a file and return its id (the next free one).

    A ``PurePath`` already given (a ``PurePosixPath`` display name, say)
    is kept as is; only a plain string is turned into a native ``Path``,
    as before. This is what lets a caller opt into a posix-stable name
    without changing what every other caller gets.
    """
    stored = path if isinstance(path, PurePath) else Path(path)
    self._files.append(SourceFile(stored, text))
    return FileId(len(self._files) - 1)

find

find(path: PurePath | str) -> FileId | None

The id of the first file registered under path, if any.

Source code in src/texsmith/diagnostics/files.py
148
149
150
151
152
153
154
def find(self, path: PurePath | str) -> FileId | None:
    """The id of the first file registered under ``path``, if any."""
    wanted = path if isinstance(path, PurePath) else Path(path)
    for index, entry in enumerate(self._files):
        if entry.path == wanted:
            return FileId(index)
    return None

line_index

line_index(file_id: int) -> LineIndex

The (lazily built, cached) index of a file.

Source code in src/texsmith/diagnostics/files.py
167
168
169
def line_index(self, file_id: int) -> LineIndex:
    """The (lazily built, cached) index of a file."""
    return self._entry(file_id).line_index

Fix dataclass

Fix(span: Span, replacement: str)

A text edit an editor can apply: replace span with replacement.

LineCol dataclass

LineCol(line: int, col: int)

A printed position: 1-based line, 1-based byte column.

LineIndex

LineIndex(text: str)

Maps byte offsets of a UTF-8 text to lines and columns and back.

Line endings are \n, \r\n and a lone \r (CommonMark). Offsets past the end clamp to the end; an offset inside a multibyte character snaps to that character's start, so a column never points between two bytes of one glyph.

Source code in src/texsmith/diagnostics/files.py
44
45
46
47
48
49
50
51
52
53
54
def __init__(self, text: str) -> None:
    data = text.encode("utf-8")
    starts = [0]
    ends: list[int] = []
    for match in _LINE_BREAK.finditer(data):
        ends.append(match.start())
        starts.append(match.end())
    ends.append(len(data))
    self._data = data
    self._starts = starts
    self._ends = ends

line_count property

line_count: int

Number of lines, at least 1.

__len__

__len__() -> int

Total length in bytes.

Source code in src/texsmith/diagnostics/files.py
56
57
58
def __len__(self) -> int:
    """Total length in bytes."""
    return len(self._data)

line_col

line_col(offset: int) -> LineCol

The printed position of a byte offset.

Source code in src/texsmith/diagnostics/files.py
86
87
88
89
90
def line_col(self, offset: int) -> LineCol:
    """The printed position of a byte offset."""
    offset = self.snap(offset)
    line = bisect_right(self._starts, offset) - 1
    return LineCol(line + 1, offset - self._starts[line] + 1)

line_end

line_end(line: int) -> int | None

Byte offset of the terminator of the 1-based line (the end of the text for the last one).

Source code in src/texsmith/diagnostics/files.py
72
73
74
75
76
77
def line_end(self, line: int) -> int | None:
    """Byte offset of the terminator of the 1-based ``line`` (the end of the text for the last one)."""
    index = line - 1
    if 0 <= index < len(self._ends):
        return self._ends[index]
    return None

line_start

line_start(line: int) -> int | None

Byte offset where the 1-based line starts, None past the text.

Source code in src/texsmith/diagnostics/files.py
65
66
67
68
69
70
def line_start(self, line: int) -> int | None:
    """Byte offset where the 1-based ``line`` starts, ``None`` past the text."""
    index = line - 1
    if 0 <= index < len(self._starts):
        return self._starts[index]
    return None

offset

offset(position: LineCol) -> int

Byte offset of a printed position.

A column past the end of the line clamps to the line's end, before its terminator; a line past the text clamps to the end of the text.

Source code in src/texsmith/diagnostics/files.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
def offset(self, position: LineCol) -> int:
    """Byte offset of a printed position.

    A column past the end of the line clamps to the line's end, before its
    terminator; a line past the text clamps to the end of the text.
    """
    start = self.line_start(position.line)
    end = self.line_end(position.line)
    if start is None or end is None:
        return len(self._data)
    return min(start + max(position.col, 1) - 1, end)

snap

snap(offset: int) -> int

Clamp offset to the text and move it back to a character boundary.

Source code in src/texsmith/diagnostics/files.py
79
80
81
82
83
84
def snap(self, offset: int) -> int:
    """Clamp ``offset`` to the text and move it back to a character boundary."""
    offset = max(0, min(offset, len(self._data)))
    while offset > 0 and offset < len(self._data) and (self._data[offset] & 0xC0) == 0x80:
        offset -= 1
    return offset

LoggingEmitter

LoggingEmitter(
    *,
    logger_obj: Logger | None = None,
    debug_enabled: bool = False,
    files: FileTable | None = None,
)

Bases: SinkEmitter

Emitter that forwards diagnostics to the standard logging module.

Source code in src/texsmith/diagnostics/emitters.py
89
90
91
92
93
94
95
96
97
def __init__(
    self,
    *,
    logger_obj: logging.Logger | None = None,
    debug_enabled: bool = False,
    files: FileTable | None = None,
) -> None:
    super().__init__(debug_enabled=debug_enabled, files=files)
    self._logger = logger_obj or logger

NullEmitter

NullEmitter(
    *,
    debug_enabled: bool = False,
    files: FileTable | None = None,
)

Bases: SinkEmitter

Emitter that shows nothing.

It still collects: silence is a rendering choice, not a reason to lose the records. Document.diagnostics is filled from this sink and the library caller reads them after the run, which is what docs/api/high-level.md describes.

Source code in src/texsmith/diagnostics/emitters.py
60
61
62
def __init__(self, *, debug_enabled: bool = False, files: FileTable | None = None) -> None:
    self.debug_enabled = debug_enabled
    self.sink = DiagnosticSink(files, on_emit=self.render)

Severity

Bases: str, Enum

tmark's four severities, ordered from the mildest to the worst.

The four comparisons are spelled out: str already defines them (alphabetically, which puts info above error), so a partial definition would be silently completed by the string ones.

rank property

rank: int

Position in the hint < info < warning < error order.

SinkEmitter

SinkEmitter(
    *,
    debug_enabled: bool = False,
    files: FileTable | None = None,
)

Base of the emitters that collect: owns the sink, renders each new record.

Subclasses implement :meth:render (and :meth:event); everything else is the sink's. Every record goes through :meth:diagnostic, so a subclass that filters there — the render command's --deprecated level — sees the whole stream.

Source code in src/texsmith/diagnostics/emitters.py
60
61
62
def __init__(self, *, debug_enabled: bool = False, files: FileTable | None = None) -> None:
    self.debug_enabled = debug_enabled
    self.sink = DiagnosticSink(files, on_emit=self.render)

debug_enabled

debug_enabled(emitter: DiagnosticEmitter | None) -> bool

Return whether debug mode is active for the given emitter.

Source code in src/texsmith/diagnostics/emitters.py
191
192
193
def debug_enabled(emitter: DiagnosticEmitter | None) -> bool:
    """Return whether debug mode is active for the given emitter."""
    return emitter is not None and emitter.debug_enabled

default_severity

default_severity(code: str) -> Severity

The severity a code carries when the emitter names none.

A code absent from the table (a tmark id not listed here) is a warning: tmark records arrive with their own severity anyway.

Source code in src/texsmith/diagnostics/codes.py
191
192
193
194
195
196
197
198
def default_severity(code: str) -> Severity:
    """The severity a code carries when the emitter names none.

    A code absent from the table (a tmark id not listed here) is a warning:
    tmark records arrive with their own severity anyway.
    """
    info = CODES.get(code)
    return info.severity if info is not None else Severity.WARNING

emit_diagnostic

emit_diagnostic(
    emitter: DiagnosticEmitter | None,
    code: str,
    message: str,
    *,
    span: Span = NO_SPAN,
    severity: Severity | None = None,
    exc: BaseException | None = None,
) -> None

Record a coded finding through emitter.

The counterpart of :meth:DiagnosticSink.emit for the stages that hold an emitter rather than a :class:~texsmith.passes.PassContext. It goes through :meth:DiagnosticEmitter.diagnostic rather than reaching the sink directly, so an emitter that filters sees these records too; the severity defaults to the one the code table gives the code.

Source code in src/texsmith/diagnostics/emitters.py
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
def emit_diagnostic(
    emitter: DiagnosticEmitter | None,
    code: str,
    message: str,
    *,
    span: Span = NO_SPAN,
    severity: Severity | None = None,
    exc: BaseException | None = None,
) -> None:
    """Record a coded finding through ``emitter``.

    The counterpart of :meth:`DiagnosticSink.emit` for the stages that hold an
    emitter rather than a :class:`~texsmith.passes.PassContext`. It goes
    through :meth:`DiagnosticEmitter.diagnostic` rather than reaching the sink
    directly, so an emitter that filters sees these records too; the severity
    defaults to the one the code table gives the code.
    """
    ensure_emitter(emitter).diagnostic(
        Diagnostic(
            code=code,
            severity=severity if severity is not None else default_severity(code),
            span=span,
            message=message,
        ),
        exc,
    )

ensure_emitter

ensure_emitter(
    emitter: DiagnosticEmitter | None,
) -> DiagnosticEmitter

Return a usable emitter, defaulting to the null implementation.

Source code in src/texsmith/diagnostics/emitters.py
186
187
188
def ensure_emitter(emitter: DiagnosticEmitter | None) -> DiagnosticEmitter:
    """Return a usable emitter, defaulting to the null implementation."""
    return emitter if emitter is not None else NullEmitter()

format_diagnostic

format_diagnostic(
    diagnostic: Diagnostic,
    files: FileTable,
    *,
    verbosity: int = 0,
) -> str

The tmark-cli line; the fix and the related locations indented at -v.

A multi-line message keeps its first line on the header and indents the rest, so the detail an emitter appends (-v exception notes) stays attached to its record.

Source code in src/texsmith/diagnostics/sink.py
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
def format_diagnostic(diagnostic: Diagnostic, files: FileTable, *, verbosity: int = 0) -> str:
    """The ``tmark-cli`` line; the fix and the related locations indented at ``-v``.

    A multi-line message keeps its first line on the header and indents the
    rest, so the detail an emitter appends (``-v`` exception notes) stays
    attached to its record.
    """
    first, _, rest = diagnostic.message.partition("\n")
    lines = [
        f"{_location_prefix(diagnostic.span, files)}"
        f"{diagnostic.severity.value} {diagnostic.code}: {first}"
    ]
    lines.extend(f"  {line}" for line in rest.splitlines())
    if verbosity >= 1:
        if diagnostic.fix is not None:
            where = _location_prefix(diagnostic.fix.span, files).rstrip(": ")
            target = f" at {where}" if where else ""
            lines.append(f"  fix{target}: replace with {diagnostic.fix.replacement!r}")
        for span, label in diagnostic.related:
            lines.append(f"  {_location_prefix(span, files)}{label}")
    return "\n".join(lines)

format_event_message

format_event_message(
    name: str, payload: Mapping[str, Any]
) -> str | None

Return a human-friendly summary for selected diagnostic events.

Source code in src/texsmith/diagnostics/emitters.py
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
def format_event_message(name: str, payload: Mapping[str, Any]) -> str | None:
    """Return a human-friendly summary for selected diagnostic events."""
    try:
        data = dict(payload)
    except Exception:  # pragma: no cover - defensive
        data = {}

    if name == "asset_fetch":
        url = data.get("url") or "<unknown>"
        convert = data.get("convert")
        suffix_hint = data.get("suffix_hint")
        details: list[str] = []
        if convert:
            details.append("convert")
        if suffix_hint:
            details.append(f"suffix={suffix_hint}")
        suffix = f" ({', '.join(details)})" if details else ""
        return f"Fetching: {url}{suffix}"

    if name == "asset_fetch_cached":
        url = data.get("url") or "<unknown>"
        reason = data.get("reason") or "cache"
        return f"Reusing cached remote image: {url} ({reason})"

    if name == "doi_fetch":
        doi_value = data.get("value") or data.get("doi") or "<unknown>"
        key = data.get("key") or "<unknown>"
        mode = data.get("mode")
        source = data.get("source") or data.get("resolved_source")
        details: list[str] = []
        if mode:
            details.append(str(mode))
        if source:
            details.append(str(source))
        suffix = f" ({', '.join(details)})" if details else ""
        return f"Resolved DOI {doi_value} for entry '{key}'{suffix}"

    return None

from_tmark

from_tmark(record: Mapping[str, Any]) -> Diagnostic

Build a :class:Diagnostic from a record returned by the tmark bindings.

The record is the serde JSON of tmark_ir::Diagnostic: fix and related are absent when empty. origin is forced to "tmark".

Source code in src/texsmith/diagnostics/model.py
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
def from_tmark(record: Mapping[str, Any]) -> Diagnostic:
    """Build a :class:`Diagnostic` from a record returned by the tmark bindings.

    The record is the ``serde`` JSON of ``tmark_ir::Diagnostic``: ``fix`` and
    ``related`` are absent when empty. ``origin`` is forced to ``"tmark"``.
    """
    fix_payload = record.get("fix")
    related_payload = record.get("related") or ()
    return Diagnostic(
        code=str(record["code"]),
        severity=Severity(str(record["severity"])),
        span=Span.from_json(record["span"]),
        message=str(record["message"]),
        fix=Fix.from_json(fix_payload) if fix_payload is not None else None,
        related=tuple((Span.from_json(span), str(label)) for span, label in related_payload),
        origin="tmark",
    )

locate

locate(
    span: Span, files: FileTable
) -> tuple[Any, int | None, int | None] | None

(path, line, col) of a span; line and col are None without a text.

NO_SPAN names the main document without a position; an unknown file yields None and the record prints without a path.

Source code in src/texsmith/diagnostics/sink.py
165
166
167
168
169
170
171
172
173
174
175
176
177
def locate(span: Span, files: FileTable) -> tuple[Any, int | None, int | None] | None:
    """``(path, line, col)`` of a span; line and col are ``None`` without a text.

    ``NO_SPAN`` names the main document without a position; an unknown file
    yields ``None`` and the record prints without a path.
    """
    entry = files.get(span.file)
    if entry is None:
        return None
    if span == NO_SPAN or not entry.text:
        return (entry.path, None, None)
    position = entry.line_index.line_col(span.start)
    return (entry.path, position.line, position.col)

record_event

record_event(
    emitter: DiagnosticEmitter | None,
    event: str,
    payload: Mapping[str, Any],
) -> None

Forward a structured diagnostic event.

Source code in src/texsmith/diagnostics/emitters.py
196
197
198
199
200
201
202
def record_event(
    emitter: DiagnosticEmitter | None,
    event: str,
    payload: Mapping[str, Any],
) -> None:
    """Forward a structured diagnostic event."""
    ensure_emitter(emitter).event(event, payload)

summary_line

summary_line(counts: Mapping[Severity, int]) -> str

N errors, M warnings — singular when it applies.

Source code in src/texsmith/diagnostics/sink.py
213
214
215
216
217
218
219
220
def summary_line(counts: Mapping[Severity, int]) -> str:
    """``N errors, M warnings`` — singular when it applies."""
    errors = counts.get(Severity.ERROR, 0)
    warnings = counts.get(Severity.WARNING, 0)
    return (
        f"{errors} error{'' if errors == 1 else 's'}, "
        f"{warnings} warning{'' if warnings == 1 else 's'}"
    )

unknown_container

unknown_container(diagnostic: Diagnostic) -> str | None

The container name a container-unknown record names, else None.

Source code in src/texsmith/diagnostics/sink.py
39
40
41
42
43
44
def unknown_container(diagnostic: Diagnostic) -> str | None:
    """The container name a ``container-unknown`` record names, else ``None``."""
    if diagnostic.code != CONTAINER_UNKNOWN:
        return None
    match = _CONTAINER_UNKNOWN_NAME.match(diagnostic.message)
    return match.group(1) if match is not None else None

Custom exception hierarchy for the LaTeX rendering pipeline.

AssetMissingError

Bases: LatexRenderingError

Raised when an expected asset cannot be located or generated.

ConversionError

Bases: Exception

Raised when a conversion fails and cannot recover.

InvalidNodeError

Bases: LatexRenderingError

Raised when a handler receives an unexpected DOM node shape.

LatexRenderingError

Bases: RuntimeError

Base exception for LaTeX rendering failures.

TransformerExecutionError

Bases: LatexRenderingError

Raised when an external converter fails to execute properly.

exception_hint

exception_hint(exc: BaseException) -> str | None

Return the most specific message available for an exception chain.

Source code in src/texsmith/core/exceptions.py
44
45
46
47
def exception_hint(exc: BaseException) -> str | None:
    """Return the most specific message available for an exception chain."""
    messages = exception_messages(exc)
    return messages[-1] if messages else None

exception_messages

exception_messages(exc: BaseException) -> list[str]

Return the collected message chain for an exception and its causes.

Source code in src/texsmith/core/exceptions.py
28
29
30
31
32
33
34
35
36
37
38
39
40
41
def exception_messages(exc: BaseException) -> list[str]:
    """Return the collected message chain for an exception and its causes."""
    messages: list[str] = []
    visited: set[int] = set()
    current: BaseException | None = exc
    while current is not None and id(current) not in visited:
        visited.add(id(current))
        text = str(current).strip()
        if text:
            first_line = text.splitlines()[0].strip()
            if first_line:
                messages.append(first_line)
        current = current.__cause__ or current.__context__
    return messages

format_rendering_error

format_rendering_error(error: LatexRenderingError) -> str

Format a human-readable rendering failure summary.

Source code in src/texsmith/core/exceptions.py
50
51
52
53
54
55
def format_rendering_error(error: LatexRenderingError) -> str:
    """Format a human-readable rendering failure summary."""
    cause = error.__cause__
    if cause is None:
        return str(error)
    return f"LaTeX rendering failed: {cause}"

format_user_friendly_render_error

format_user_friendly_render_error(
    error: LatexRenderingError,
) -> str

Return a concise rendering failure summary suitable for end users.

Source code in src/texsmith/core/exceptions.py
58
59
60
61
62
63
64
65
66
67
def format_user_friendly_render_error(error: LatexRenderingError) -> str:
    """Return a concise rendering failure summary suitable for end users."""
    summary = "LaTeX rendering failed"
    hint_source = error.__cause__ or error
    hint = exception_hint(hint_source)
    if hint:
        summary = f"{summary}: {hint}"
    if summary.endswith("."):
        summary = summary.rstrip(".")
    return f"{summary}. Re-run with --debug for technical details."

raise_conversion_error

raise_conversion_error(
    emitter: DiagnosticEmitter | None,
    message: str,
    exc: Exception,
) -> None

Record the failure that stops the run, then raise it.

Lives here rather than with the emitters: it builds a :class:ConversionError, and the diagnostics package must not depend on the conversion one.

Source code in src/texsmith/core/exceptions.py
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
def raise_conversion_error(
    emitter: DiagnosticEmitter | None,
    message: str,
    exc: Exception,
) -> None:
    """Record the failure that stops the run, then raise it.

    Lives here rather than with the emitters: it builds a
    :class:`ConversionError`, and the diagnostics package must not depend on
    the conversion one.
    """
    emit_diagnostic(emitter, "conversion-failed", message, exc=exc)
    error = ConversionError(message)
    error._texsmith_logged = True  # noqa: SLF001
    raise error from exc

LaTeX escaping — single source of truth lives in the LaTeX writer.

Escaping is a backend responsibility, so the canonical implementation moved to :mod:texsmith.writers.latex.escaper. This module re-exports it so existing importers (templates, fonts, extensions, legacy handler helpers) keep a stable import path without duplicating the logic.

escape_latex_chars

escape_latex_chars(
    text: str, *, legacy_accents: bool = False
) -> str

Escape LaTeX special characters leveraging pylatexenc.

Source code in src/texsmith/writers/latex/escaper.py
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
def escape_latex_chars(text: str, *, legacy_accents: bool = False) -> str:
    """Escape LaTeX special characters leveraging pylatexenc."""
    if not text:
        return text
    parts: list[str] = []
    buffer: list[str] = []

    def _encode_chunk(chunk: str) -> str:
        escaped = "".join(_BASIC_LATEX_ESCAPE_MAP.get(char, char) for char in chunk)
        if legacy_accents:
            encoded = unicode_to_latex(escaped, non_ascii_only=True, unknown_char_warning=False)
            return _wrap_latex_output(encoded)
        return escaped

    def _should_skip_encoding(char: str) -> bool:
        try:
            name = unicodedata.name(char)
        except ValueError:
            return False
        if "SUPERSCRIPT" in name or "SUBSCRIPT" in name:
            return True
        return "MODIFIER LETTER" in name and ("SMALL" in name or "CAPITAL" in name)

    for char in text:
        replacement = _COMMON_SYMBOL_MAP.get(char)
        if replacement is not None:
            if buffer:
                parts.append(_encode_chunk("".join(buffer)))
                buffer.clear()
            parts.append(replacement)
            continue
        if _should_skip_encoding(char):
            if buffer:
                parts.append(_encode_chunk("".join(buffer)))
                buffer.clear()
            parts.append(char)
        else:
            buffer.append(char)

    if buffer:
        parts.append(_encode_chunk("".join(buffer)))

    return "".join(parts)

Abstractions for invoking Docker containers safely.

DockerLimits dataclass

DockerLimits(
    cpus: float | int | None = None,
    memory: str | None = None,
    pids_limit: int | None = None,
)

Runtime constraints for Docker containers.

DockerRunRequest dataclass

DockerRunRequest(
    image: str,
    args: Sequence[str] = tuple(),
    mounts: Sequence[VolumeMount] = tuple(),
    environment: Mapping[str, str] = dict(),
    workdir: str | None = None,
    user: str | None = None,
    use_host_user: bool = True,
    remove: bool = True,
    limits: DockerLimits | None = None,
    network: str | None = None,
    extra_args: Sequence[str] = tuple(),
)

Full request payload for a Docker execution.

DockerRunner

DockerRunner(executable: str | None = None)

Utility class encapsulating Docker invocations.

Source code in src/texsmith/adapters/docker.py
53
54
55
def __init__(self, executable: str | None = None) -> None:
    self._explicit_executable = executable
    self._cached_executable: str | None = None

is_available

is_available() -> bool

Return True when Docker can be located.

Source code in src/texsmith/adapters/docker.py
57
58
59
60
61
62
def is_available(self) -> bool:
    """Return True when Docker can be located."""
    try:
        return self._resolve_executable(optional=True) is not None
    except TransformerExecutionError:
        return False

reset

reset() -> None

Clear cached executable lookup results.

Source code in src/texsmith/adapters/docker.py
64
65
66
def reset(self) -> None:
    """Clear cached executable lookup results."""
    self._cached_executable = None

run

run(
    request: DockerRunRequest,
    *,
    capture_output: bool = True,
    text: bool = True,
) -> subprocess.CompletedProcess[str]

Execute Docker with the supplied request.

Source code in src/texsmith/adapters/docker.py
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
def run(
    self,
    request: DockerRunRequest,
    *,
    capture_output: bool = True,
    text: bool = True,
) -> subprocess.CompletedProcess[str]:
    """Execute Docker with the supplied request."""
    command = self._build_run_command(request)
    try:
        result = subprocess.run(
            command,
            check=False,
            capture_output=capture_output,
            text=text,
        )
    except FileNotFoundError as exc:
        self._cached_executable = None
        raise TransformerExecutionError("Docker executable could not be located.") from exc
    except OSError as exc:
        raise TransformerExecutionError(f"Failed to invoke Docker: {exc}") from exc

    if result.returncode != 0:
        stderr = (result.stderr or "").strip()
        stdout = (result.stdout or "").strip()
        detail = stderr or stdout
        message = f"Docker image '{request.image}' failed with exit code {result.returncode}"
        if detail:
            message = f"{message}: {detail}"
        raise TransformerExecutionError(message)

    return result

VolumeMount dataclass

VolumeMount(
    source: Path | str, target: str, read_only: bool = False
)

Bind mount configuration.

is_docker_available

is_docker_available() -> bool

Check if Docker can be executed.

Source code in src/texsmith/adapters/docker.py
209
210
211
def is_docker_available() -> bool:
    """Check if Docker can be executed."""
    return _default_runner.is_available()

run_container

run_container(
    image: str,
    args: Sequence[str] = (),
    *,
    mounts: Sequence[VolumeMount] = (),
    environment: Mapping[str, str] | None = None,
    workdir: str | None = None,
    user: str | None = None,
    use_host_user: bool = True,
    limits: DockerLimits | None = None,
    network: str | None = None,
    remove: bool = True,
    extra_args: Sequence[str] = (),
    capture_output: bool = True,
    text: bool = True,
) -> subprocess.CompletedProcess[str]

Execute Docker using the shared runner.

Source code in src/texsmith/adapters/docker.py
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
def run_container(
    image: str,
    args: Sequence[str] = (),
    *,
    mounts: Sequence[VolumeMount] = (),
    environment: Mapping[str, str] | None = None,
    workdir: str | None = None,
    user: str | None = None,
    use_host_user: bool = True,
    limits: DockerLimits | None = None,
    network: str | None = None,
    remove: bool = True,
    extra_args: Sequence[str] = (),
    capture_output: bool = True,
    text: bool = True,
) -> subprocess.CompletedProcess[str]:
    """Execute Docker using the shared runner."""
    request = DockerRunRequest(
        image=image,
        args=tuple(args),
        mounts=tuple(mounts),
        environment=environment or {},
        workdir=workdir,
        user=user,
        use_host_user=use_host_user,
        remove=remove,
        limits=limits,
        network=network,
        extra_args=tuple(extra_args),
    )
    return _default_runner.run(
        request,
        capture_output=capture_output,
        text=text,
    )