Skip to content

CLI

Public CLI exports for TexSmith.

debug_enabled

debug_enabled() -> bool

Return whether full tracebacks should be displayed.

Source code in src/texsmith/ui/cli/state.py
258
259
260
261
262
263
def debug_enabled() -> bool:
    """Return whether full tracebacks should be displayed."""
    try:
        return get_cli_state(create=False).show_tracebacks
    except RuntimeError:
        return False

emit_error

emit_error(
    message: str, *, exception: BaseException | None = None
) -> None

Log an error-level message to stderr respecting verbosity settings.

Source code in src/texsmith/ui/cli/state.py
251
252
253
254
255
def emit_error(message: str, *, exception: BaseException | None = None) -> None:
    """Log an error-level message to stderr respecting verbosity settings."""
    if exception is not None and getattr(exception, "_texsmith_logged", False):
        return
    render_message("error", message, exception=exception)

emit_warning

emit_warning(
    message: str, *, exception: BaseException | None = None
) -> None

Log a warning-level message to stderr respecting verbosity settings.

Source code in src/texsmith/ui/cli/state.py
246
247
248
def emit_warning(message: str, *, exception: BaseException | None = None) -> None:
    """Log a warning-level message to stderr respecting verbosity settings."""
    render_message("warning", message, exception=exception)

ensure_rich_compat

ensure_rich_compat() -> None

Patch Rich stub modules provided by tests to expose required attributes.

Source code in src/texsmith/ui/cli/state.py
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
def ensure_rich_compat() -> None:
    """Patch Rich stub modules provided by tests to expose required attributes."""
    import importlib.machinery
    import sys as _sys
    import types

    rich_mod = _sys.modules.get("rich")
    if rich_mod is None:
        return
    if getattr(rich_mod, "__spec__", None) is None:
        rich_mod.__spec__ = importlib.machinery.ModuleSpec("rich", loader=None)

    is_stub = getattr(rich_mod, "__file__", None) is None

    if is_stub:
        try:
            import typer.core as typer_core

            cast(Any, typer_core).HAS_RICH = False
        except ImportError:  # pragma: no cover - typer not available
            pass
        try:
            import typer.main as typer_main

            cast(Any, typer_main).HAS_RICH = False
        except ImportError:  # pragma: no cover - typer not available
            pass

    if not hasattr(rich_mod, "box"):
        box_module = types.ModuleType("rich.box")
        cast(Any, box_module).SQUARE = object()
        cast(Any, box_module).MINIMAL_DOUBLE_HEAD = object()
        cast(Any, box_module).SIMPLE = object()
        cast(Any, rich_mod).box = box_module
        _sys.modules.setdefault("rich.box", box_module)

get_cli_state

get_cli_state(
    ctx: Context | Context | None = None,
    *,
    create: bool = True,
) -> CLIState

Return the CLI state associated with the active Typer context.

Source code in src/texsmith/ui/cli/state.py
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
def get_cli_state(
    ctx: typer.Context | click.Context | None = None,
    *,
    create: bool = True,
) -> CLIState:
    """Return the CLI state associated with the active Typer context."""
    if ctx is None:
        try:
            candidate = click.get_current_context(silent=True)
        except RuntimeError:
            candidate = None
        if isinstance(candidate, typer.Context):
            ctx = candidate

    state: CLIState | None = None

    if isinstance(ctx, typer.Context):
        current_ctx: typer.Context | None = ctx
        while current_ctx is not None:
            obj = getattr(current_ctx, "obj", None)
            if isinstance(obj, CLIState):
                state = obj
                break
            current_ctx = getattr(current_ctx, "parent", None)
        if state is None and create:
            state = CLIState()
            current_ctx = ctx
            current_ctx.obj = state
        if state is not None:
            _STATE_VAR.set(state)

    if state is None:
        fallback = _STATE_VAR.get(None)
        if fallback is None:
            if not create:
                raise RuntimeError("CLI state is not initialised for this context.")
            fallback = CLIState()
            _STATE_VAR.set(fallback)
        state = fallback

    return state

main

main() -> None

Entry point compatible with console scripts.

Source code in src/texsmith/ui/cli/app.py
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
def main() -> None:
    """Entry point compatible with console scripts."""
    try:
        app()
    except typer.Exit:
        raise
    except KeyboardInterrupt as exc:
        if debug_enabled():
            raise
        emit_error("Operation cancelled by user.", exception=exc)
        raise typer.Exit(code=1) from exc
    except SystemExit:
        raise
    except Exception as exc:  # pragma: no cover - defensive catch-all
        from .state import get_cli_state

        state = get_cli_state()
        if state.show_tracebacks:
            from rich.traceback import Traceback

            tb = Traceback.from_exception(
                type(exc),
                exc,
                exc.__traceback__,
                show_locals=state.verbosity >= 2,
            )
            state.err_console.print(tb)
        else:
            emit_error(str(exc), exception=exc)
        raise typer.Exit(code=1) from exc

Typer application wiring for the TeXSmith CLI.

HelpOnEmptyCommand

HelpOnEmptyCommand(*args: object, **kwargs: object)

Bases: TyperCommand

Typer command that disables positional argument enforcement.

Source code in src/texsmith/ui/cli/app.py
17
18
19
20
21
def __init__(self, *args: object, **kwargs: object) -> None:  # type: ignore[override]
    super().__init__(*args, **kwargs)
    for param in self.params:
        if isinstance(param, click.Argument):
            param.required = False

main

main() -> None

Entry point compatible with console scripts.

Source code in src/texsmith/ui/cli/app.py
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
def main() -> None:
    """Entry point compatible with console scripts."""
    try:
        app()
    except typer.Exit:
        raise
    except KeyboardInterrupt as exc:
        if debug_enabled():
            raise
        emit_error("Operation cancelled by user.", exception=exc)
        raise typer.Exit(code=1) from exc
    except SystemExit:
        raise
    except Exception as exc:  # pragma: no cover - defensive catch-all
        from .state import get_cli_state

        state = get_cli_state()
        if state.show_tracebacks:
            from rich.traceback import Traceback

            tb = Traceback.from_exception(
                type(exc),
                exc,
                exc.__traceback__,
                show_locals=state.verbosity >= 2,
            )
            state.err_console.print(tb)
        else:
            emit_error(str(exc), exception=exc)
        raise typer.Exit(code=1) from exc

Shared CLI state management utilities.

CLIState dataclass

CLIState(verbosity: int = 0, show_tracebacks: bool = False)

Shared state controlling CLI diagnostics.

console property

console: Console

Return a lazily instantiated stdout console.

err_console property

err_console: Console

Return a lazily instantiated stderr console.

consume_events

consume_events(name: str) -> list[dict[str, Any]]

Retrieve and clear events for the given name.

Source code in src/texsmith/ui/cli/state.py
108
109
110
def consume_events(self, name: str) -> list[dict[str, Any]]:
    """Retrieve and clear events for the given name."""
    return self.events.pop(name, [])

record_event

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

Store a structured diagnostic event for later presentation.

Source code in src/texsmith/ui/cli/state.py
103
104
105
106
def record_event(self, name: str, payload: Mapping[str, Any] | None = None) -> None:
    """Store a structured diagnostic event for later presentation."""
    entry = dict(payload or {})
    self.events.setdefault(name, []).append(entry)

debug_enabled

debug_enabled() -> bool

Return whether full tracebacks should be displayed.

Source code in src/texsmith/ui/cli/state.py
258
259
260
261
262
263
def debug_enabled() -> bool:
    """Return whether full tracebacks should be displayed."""
    try:
        return get_cli_state(create=False).show_tracebacks
    except RuntimeError:
        return False

emit_error

emit_error(
    message: str, *, exception: BaseException | None = None
) -> None

Log an error-level message to stderr respecting verbosity settings.

Source code in src/texsmith/ui/cli/state.py
251
252
253
254
255
def emit_error(message: str, *, exception: BaseException | None = None) -> None:
    """Log an error-level message to stderr respecting verbosity settings."""
    if exception is not None and getattr(exception, "_texsmith_logged", False):
        return
    render_message("error", message, exception=exception)

emit_warning

emit_warning(
    message: str, *, exception: BaseException | None = None
) -> None

Log a warning-level message to stderr respecting verbosity settings.

Source code in src/texsmith/ui/cli/state.py
246
247
248
def emit_warning(message: str, *, exception: BaseException | None = None) -> None:
    """Log a warning-level message to stderr respecting verbosity settings."""
    render_message("warning", message, exception=exception)

ensure_rich_compat

ensure_rich_compat() -> None

Patch Rich stub modules provided by tests to expose required attributes.

Source code in src/texsmith/ui/cli/state.py
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
def ensure_rich_compat() -> None:
    """Patch Rich stub modules provided by tests to expose required attributes."""
    import importlib.machinery
    import sys as _sys
    import types

    rich_mod = _sys.modules.get("rich")
    if rich_mod is None:
        return
    if getattr(rich_mod, "__spec__", None) is None:
        rich_mod.__spec__ = importlib.machinery.ModuleSpec("rich", loader=None)

    is_stub = getattr(rich_mod, "__file__", None) is None

    if is_stub:
        try:
            import typer.core as typer_core

            cast(Any, typer_core).HAS_RICH = False
        except ImportError:  # pragma: no cover - typer not available
            pass
        try:
            import typer.main as typer_main

            cast(Any, typer_main).HAS_RICH = False
        except ImportError:  # pragma: no cover - typer not available
            pass

    if not hasattr(rich_mod, "box"):
        box_module = types.ModuleType("rich.box")
        cast(Any, box_module).SQUARE = object()
        cast(Any, box_module).MINIMAL_DOUBLE_HEAD = object()
        cast(Any, box_module).SIMPLE = object()
        cast(Any, rich_mod).box = box_module
        _sys.modules.setdefault("rich.box", box_module)

get_cli_state

get_cli_state(
    ctx: Context | Context | None = None,
    *,
    create: bool = True,
) -> CLIState

Return the CLI state associated with the active Typer context.

Source code in src/texsmith/ui/cli/state.py
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
def get_cli_state(
    ctx: typer.Context | click.Context | None = None,
    *,
    create: bool = True,
) -> CLIState:
    """Return the CLI state associated with the active Typer context."""
    if ctx is None:
        try:
            candidate = click.get_current_context(silent=True)
        except RuntimeError:
            candidate = None
        if isinstance(candidate, typer.Context):
            ctx = candidate

    state: CLIState | None = None

    if isinstance(ctx, typer.Context):
        current_ctx: typer.Context | None = ctx
        while current_ctx is not None:
            obj = getattr(current_ctx, "obj", None)
            if isinstance(obj, CLIState):
                state = obj
                break
            current_ctx = getattr(current_ctx, "parent", None)
        if state is None and create:
            state = CLIState()
            current_ctx = ctx
            current_ctx.obj = state
        if state is not None:
            _STATE_VAR.set(state)

    if state is None:
        fallback = _STATE_VAR.get(None)
        if fallback is None:
            if not create:
                raise RuntimeError("CLI state is not initialised for this context.")
            fallback = CLIState()
            _STATE_VAR.set(fallback)
        state = fallback

    return state

render_message

render_message(
    level: str,
    message: str,
    *,
    exception: BaseException | None = None,
) -> None

Render a formatted message to the console, including optional diagnostics.

Source code in src/texsmith/ui/cli/state.py
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
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
231
232
233
234
235
236
237
238
239
240
241
242
243
def render_message(
    level: str,
    message: str,
    *,
    exception: BaseException | None = None,
) -> None:
    """Render a formatted message to the console, including optional diagnostics."""
    state = get_cli_state()

    if level == "info":
        # Use a neutral console log for info to align with pipeline logging.
        console = state.console
        if getattr(console, "log", None):
            console.log(message)
        return

    from rich.text import Text

    style = "red" if level == "error" else "yellow"
    label_style = f"bold {style}"
    if hasattr(Text, "assemble"):
        text = Text.assemble((f"{level}: ", label_style), (message, style))
    else:  # pragma: no cover - stub Text fallback
        text = Text()
        text.append(f"{level}: ")
        text.append(message)
    extra_lines: list[str] = []
    if exception is not None and state.verbosity >= 1:
        detail = str(exception).strip()
        if detail and detail not in message:
            extra_lines.append(detail)
        extra_lines.append(f"type: {type(exception).__name__}")
        notes = getattr(exception, "__notes__", None)
        if notes:
            extra_lines.extend(str(note) for note in notes)
        if state.verbosity >= 2:
            chain = _exception_chain(exception)
            if chain:
                extra_lines.append("caused by:")
                extra_lines.extend(f"  {entry}" for entry in chain)
        if state.verbosity >= 3:
            extra_lines.append(f"repr: {exception!r}")

    if extra_lines:
        if hasattr(text, "append"):
            text.append("\n")
            if hasattr(Text, "assemble"):
                text.append("\n".join(extra_lines), style=style)
            else:  # pragma: no cover - stub Text fallback
                text.append("\n".join(extra_lines))
        else:  # pragma: no cover - fallback if text is a plain string
            text = f"{text}\n" + "\n".join(extra_lines)

    console = state.err_console if level != "info" else state.console
    if type(console).__name__.startswith("_Stub"):  # pragma: no cover - stub Console fallback
        target = sys.stderr if level != "info" else sys.stdout
        print(text if isinstance(text, str) else str(text), file=target)
    else:
        console.print(text)

set_cli_state

set_cli_state(
    *,
    ctx: Context | None = None,
    verbosity: int | None = None,
    debug: bool | None = None,
) -> CLIState

Update the CLI state, returning the current instance.

Source code in src/texsmith/ui/cli/state.py
159
160
161
162
163
164
165
166
167
168
169
170
171
def set_cli_state(
    *,
    ctx: typer.Context | None = None,
    verbosity: int | None = None,
    debug: bool | None = None,
) -> CLIState:
    """Update the CLI state, returning the current instance."""
    state = get_cli_state(ctx)
    if verbosity is not None:
        state.verbosity = max(0, verbosity)
    if debug is not None:
        state.show_tracebacks = debug
    return state

Auxiliary helpers used by CLI commands.

determine_output_target

determine_output_target(
    template_selected: bool,
    documents: list[Path],
    output_option: Path | None,
) -> tuple[str, Path | None]

Infer where conversion output should be written based on CLI arguments.

Source code in src/texsmith/ui/cli/utils.py
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
def determine_output_target(
    template_selected: bool,
    documents: list[Path],
    output_option: Path | None,
) -> tuple[str, Path | None]:
    """Infer where conversion output should be written based on CLI arguments."""
    if template_selected:
        if output_option is None:
            base_dir = documents[0].parent if documents else Path()
            return "template", (base_dir / "build")
        suffix = output_option.suffix.lower()
        if suffix == ".pdf":
            return "template-pdf", output_option
        if output_option.exists() and output_option.is_file():
            raise typer.BadParameter("Template output must be a directory.")
        if suffix:
            raise typer.BadParameter("Template output must be a directory path.")
        return "template", output_option

    if output_option is None:
        return "stdout", None

    if output_option.exists() and output_option.is_dir():
        return "directory", output_option

    if output_option.suffix:
        return "file", output_option

    return "directory", output_option

looks_like_document_path

looks_like_document_path(candidate: str) -> bool

Return True when the string has an extension resembling a document.

Source code in src/texsmith/ui/cli/utils.py
78
79
80
81
82
83
84
85
86
87
88
def looks_like_document_path(candidate: str) -> bool:
    """Return True when the string has an extension resembling a document."""
    suffix = Path(candidate).suffix.lower()
    return bool(suffix) and suffix in {
        ".md",
        ".markdown",
        ".mdown",
        ".mkd",
        ".html",
        ".htm",
    }

normalise_selector

normalise_selector(selector: str | None) -> str | None

Strip surrounding quotes and whitespace from user-provided selectors.

Source code in src/texsmith/ui/cli/utils.py
91
92
93
94
95
96
97
98
def normalise_selector(selector: str | None) -> str | None:
    """Strip surrounding quotes and whitespace from user-provided selectors."""
    if selector is None:
        return None
    candidate = selector.strip()
    if len(candidate) >= 2 and candidate[0] == candidate[-1] and candidate[0] in {'"', "'"}:
        candidate = candidate[1:-1].strip()
    return candidate or None

organise_slot_overrides

organise_slot_overrides(
    values: Iterable[str] | None, documents: list[Path]
) -> tuple[
    dict[Path, dict[str, str]],
    dict[Path, list[SlotAssignment]],
]

Produce slot selector overrides and assignments for downstream processing.

Source code in src/texsmith/ui/cli/utils.py
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
def organise_slot_overrides(
    values: Iterable[str] | None,
    documents: list[Path],
) -> tuple[dict[Path, dict[str, str]], dict[Path, list[SlotAssignment]]]:
    """Produce slot selector overrides and assignments for downstream processing."""
    tokens = parse_cli_slot_tokens(values)
    assignments = resolve_slot_assignments(tokens, documents)

    slot_overrides: dict[Path, dict[str, str]] = {}
    for doc, entries in assignments.items():
        if not entries:
            continue
        mapping = slot_overrides.setdefault(doc, {})
        for entry in entries:
            if entry.selector is not None:
                mapping[entry.slot] = entry.selector

    return slot_overrides, assignments

parse_cli_slot_tokens

parse_cli_slot_tokens(
    values: Iterable[str] | None,
) -> list[tuple[str, str | None, str | None, str]]

Tokenise slot overrides into (slot, path, selector, raw) tuples.

Source code in src/texsmith/ui/cli/utils.py
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
def parse_cli_slot_tokens(
    values: Iterable[str] | None,
) -> list[tuple[str, str | None, str | None, str]]:
    """Tokenise slot overrides into (slot, path, selector, raw) tuples."""
    tokens: list[tuple[str, str | None, str | None, str]] = []
    if not values:
        return tokens

    for raw in values:
        if not isinstance(raw, str):
            continue
        entry = raw.strip()
        if not entry:
            continue
        if ":" not in entry:
            raise typer.BadParameter(
                f"Invalid slot override '{raw}', expected format "
                f"'slot:selector' or 'slot:file[:selector]'."
            )
        slot_name, remainder = entry.split(":", 1)
        slot_name = slot_name.strip()
        remainder = remainder.strip()
        if not slot_name or not remainder:
            raise typer.BadParameter(
                f"Invalid slot override '{raw}', expected format "
                f"'slot:selector' or 'slot:file[:selector]'."
            )

        path_hint: str | None
        selector_value: str | None
        if ":" in remainder:
            path_part, selector_part = remainder.split(":", 1)
            path_part = path_part.strip()
            selector_value = normalise_selector(selector_part)
            path_hint = path_part or None
        else:
            if looks_like_document_path(remainder):
                path_hint = remainder
                selector_value = None
            else:
                path_hint = None
                selector_value = normalise_selector(remainder)

        tokens.append((slot_name, path_hint, selector_value, raw))

    return tokens

parse_slot_option

parse_slot_option(
    values: Iterable[str] | None,
) -> dict[str, str]

Parse CLI slot overrides declared as 'slot:Section' pairs.

Source code in src/texsmith/ui/cli/utils.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
def parse_slot_option(values: Iterable[str] | None) -> dict[str, str]:
    """Parse CLI slot overrides declared as 'slot:Section' pairs."""
    overrides: dict[str, str] = {}
    if not values:
        return overrides

    for raw in values:
        if not isinstance(raw, str):
            continue
        entry = raw.strip()
        if not entry:
            continue
        if ":" not in entry:
            raise ValueError(f"Invalid slot override '{raw}', expected format 'slot:Section'.")
        slot_name, selector = entry.split(":", 1)
        slot_name = slot_name.strip()
        selector = selector.strip()
        if not slot_name or not selector:
            raise ValueError(f"Invalid slot override '{raw}', expected format 'slot:Section'.")
        overrides[slot_name] = selector

    return overrides

resolve_slot_assignments

resolve_slot_assignments(
    tokens: list[tuple[str, str | None, str | None, str]],
    documents: list[Path],
) -> dict[Path, list[SlotAssignment]]

Resolve parsed slot tokens against provided documents.

Source code in src/texsmith/ui/cli/utils.py
149
150
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
204
205
206
207
208
209
210
211
212
213
214
215
def resolve_slot_assignments(
    tokens: list[tuple[str, str | None, str | None, str]],
    documents: list[Path],
) -> dict[Path, list[SlotAssignment]]:
    """Resolve parsed slot tokens against provided documents."""
    assignments: dict[Path, list[SlotAssignment]] = {doc: [] for doc in documents}
    if not tokens:
        return assignments

    resolved_index = {doc.resolve(): doc for doc in documents}
    name_index: dict[str, list[Path]] = {}
    for doc in documents:
        name_index.setdefault(doc.name, []).append(doc)

    for slot_name, path_hint, selector_value, raw in tokens:
        target_doc: Path | None = None
        if path_hint is None:
            if len(documents) == 1:
                target_doc = documents[0]
            else:
                raise typer.BadParameter(
                    f"slot override '{raw}' requires a document "
                    "path when multiple inputs are provided."
                )
        else:
            candidate_path = Path(path_hint)
            resolved_candidate: Path | None = None
            try:
                base = (
                    candidate_path if candidate_path.is_absolute() else Path.cwd() / candidate_path
                )
                resolved_candidate = base.resolve()
            except OSError:
                resolved_candidate = None

            if resolved_candidate is not None and resolved_candidate in resolved_index:
                target_doc = resolved_index[resolved_candidate]
            else:
                matches = name_index.get(candidate_path.name, [])
                if len(matches) == 1:
                    target_doc = matches[0]
                elif len(matches) > 1:
                    raise typer.BadParameter(
                        f"slot override '{raw}' is ambiguous; multiple "
                        f"documents match '{candidate_path.name}'."
                    )

        if target_doc is None:
            raise typer.BadParameter(f"slot override '{raw}' does not match any provided document.")

        selector_clean = selector_value
        include_document = False
        if selector_clean is None:
            include_document = True
        else:
            token_lower = selector_clean.strip().lower()
            if token_lower in {"*", DOCUMENT_SELECTOR_SENTINEL.lower()}:
                include_document = True
                selector_clean = None

        assignments[target_doc].append(
            SlotAssignment(
                slot=slot_name, selector=selector_clean, include_document=include_document
            )
        )

    return assignments

write_output_file

write_output_file(target: Path, content: str) -> None

Persist content to disk, creating parent directories as needed.

Source code in src/texsmith/ui/cli/utils.py
69
70
71
72
73
74
75
def write_output_file(target: Path, content: str) -> None:
    """Persist LaTeX content to disk, creating parent directories as needed."""
    try:
        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_text(content, encoding="utf-8")
    except OSError as exc:  # pragma: no cover - filesystem errors
        raise OSError(f"Failed to write LaTeX output to '{target}': {exc}") from exc

Bibliography-related CLI helpers.

build_reference_panel

build_reference_panel(
    reference: Mapping[str, object],
) -> Panel

Create a Rich panel that visualises a single bibliography entry.

Source code in src/texsmith/ui/cli/bibliography.py
 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
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
def build_reference_panel(reference: Mapping[str, object]) -> Panel:
    """Create a Rich panel that visualises a single bibliography entry."""
    from rich import box
    from rich.panel import Panel
    from rich.table import Table

    raw_fields = reference.get("fields")
    fields: dict[str, Any]
    if isinstance(raw_fields, Mapping):
        fields = {str(key): value for key, value in raw_fields.items()}
    else:
        fields = {}
    grid = Table.grid(padding=(0, 1))
    grid.add_column(style="bold green", no_wrap=True)
    grid.add_column()

    def _pop_field(*keys: str) -> str | None:
        for key in keys:
            value = fields.pop(key, None)
            if isinstance(value, bytes):
                text = value.decode("utf-8", errors="ignore").strip()
                if text:
                    return text
            elif isinstance(value, str):
                text = value.strip()
                if text:
                    return text
            elif value is not None:
                return str(value)
        return None

    def _add_field(label: str, value: object) -> None:
        if value is None:
            return
        if isinstance(value, str) and not value.strip():
            return
        grid.add_row(label, str(value))

    title = _pop_field("title")
    _add_field("Title", title)

    year = _pop_field("year")
    _add_field("Year", year)

    journal = _pop_field("journal", "booktitle")
    _add_field("Journal", journal)

    persons_block = reference.get("persons")
    if isinstance(persons_block, Mapping):
        authors = persons_block.get("author")
        if isinstance(authors, Iterable):
            cast_authors = cast(Sequence[Mapping[str, object]], tuple(authors))
            _add_field("Authors", format_person_list(cast_authors))

    sources = [
        str(Path(str(path))) for path in _iterable_items(reference.get("source_files")) if path
    ]
    if sources:
        formatted_sources = ", ".join(sources)
        _add_field("Sources", formatted_sources)

    for key, value in sorted(fields.items()):
        _add_field(key.title(), value)

    key = str(reference.get("key", "Reference"))
    entry_type = str(reference.get("type", "reference"))
    title = f"{key} ({entry_type})"
    return Panel(grid, title=title, box=box.SIMPLE)

format_bibliography_person

format_bibliography_person(
    person: Mapping[str, object],
) -> str

Render a bibliography person dictionary into a readable string.

Source code in src/texsmith/ui/cli/bibliography.py
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
def format_bibliography_person(person: Mapping[str, object]) -> str:
    """Render a bibliography person dictionary into a readable string."""
    parts: list[str] = []
    for field in ("first", "middle", "prelast", "last", "lineage"):
        value = person.get(field)
        if isinstance(value, str):
            text = value.strip()
            if text:
                parts.append(text)
            continue
        for segment in _iterable_items(value):
            if segment:
                parts.append(str(segment))

    text = " ".join(part for part in parts if part)
    if text:
        return text
    fallback = person.get("text")
    return str(fallback).strip() if isinstance(fallback, str) else ""

format_person_list

format_person_list(
    persons: Iterable[Mapping[str, object]],
) -> str

Join a sequence of person dictionaries into a comma-separated string.

Source code in src/texsmith/ui/cli/bibliography.py
53
54
55
56
def format_person_list(persons: Iterable[Mapping[str, object]]) -> str:
    """Join a sequence of person dictionaries into a comma-separated string."""
    names = [format_bibliography_person(person) for person in persons]
    return ", ".join(name for name in names if name)

print_bibliography_overview

print_bibliography_overview(
    collection: BibliographyCollection,
) -> None

Render a formatted summary of bibliography files, issues, and entries.

Source code in src/texsmith/ui/cli/bibliography.py
129
130
131
132
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
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
204
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
231
232
233
234
235
236
237
def print_bibliography_overview(collection: BibliographyCollection) -> None:
    """Render a formatted summary of bibliography files, issues, and entries."""
    ensure_rich_compat()
    try:
        from rich import box
        from rich.table import Table
        from rich.text import Text
    except ImportError:  # pragma: no cover - fallback when Rich is unavailable
        _print_bibliography_plain(collection)
        return

    console = get_cli_state().console
    references = collection.list_references()

    def _summarise_sources() -> tuple[Counter[Path], int, int, int]:
        per_file: Counter[Path] = Counter()
        frontmatter = 0
        doi = 0
        for reference in references:
            raw_sources = reference.get("source_files") or []
            sources = {Path(str(src)) for src in raw_sources if src}
            if not sources:
                continue
            for src in sources:
                per_file[src] += 1
            names = {src.name.lower() for src in sources}
            if any("frontmatter" in name for name in names):
                frontmatter += 1
            if any("doi" in name for name in names):
                doi += 1
        total = len(references)
        return per_file, total, frontmatter, doi

    per_file_counts, total_entries, frontmatter_entries, doi_entries = _summarise_sources()

    stats = collection.file_stats
    if stats:
        stats_table = Table(
            title="Bibliography Files",
            box=box.SQUARE,
            show_edge=True,
            header_style="bold cyan",
        )
        stats_table.add_column("File", overflow="fold")
        stats_table.add_column("Entries", justify="right")
        for file_path, entry_count in stats:
            stats_table.add_row(str(file_path), str(entry_count))
        stats_table.add_row(
            Text("Total", style="bold"), Text(str(sum(count for _, count in stats)))
        )
        console.print(stats_table)

    if collection.issues:
        issue_table = Table(
            title="Warnings",
            box=box.SQUARE,
            header_style="bold cyan",
            show_edge=True,
        )
        issue_table.add_column("Key", style="yellow", no_wrap=True)
        issue_table.add_column("Message", style="yellow")
        issue_table.add_column("Sources", style="yellow")
        for issue in collection.issues:
            issue_table.add_row(
                issue.key or "—",
                issue.message,
                str(issue.source) if issue.source else "—",
            )
        console.print(issue_table)

    if not references:
        console.print("[dim]No references found.[/]")
        summary_table = Table(
            title="Bibliography Summary",
            box=box.SQUARE,
            header_style="bold cyan",
            show_edge=True,
        )
        summary_table.add_column("Category", style="bold")
        summary_table.add_column("Count", justify="right")
        summary_table.add_row("Total entries", str(total_entries))
        if per_file_counts:
            for path, count in per_file_counts.most_common():
                summary_table.add_row(f"From {path.name}", str(count))
        summary_table.add_row("From front matter", str(frontmatter_entries))
        summary_table.add_row("From DOI fetches", str(doi_entries))
        console.print(summary_table)
        return

    for reference in references:
        panel = build_reference_panel(reference)
        console.print(panel)
        console.print()

    summary_table = Table(
        title="Bibliography Summary",
        box=box.SQUARE,
        header_style="bold cyan",
        show_edge=True,
    )
    summary_table.add_column("Category", style="bold")
    summary_table.add_column("Count", justify="right")
    summary_table.add_row("Total entries", str(total_entries))
    if per_file_counts:
        for path, count in per_file_counts.most_common():
            summary_table.add_row(f"From {path.name}", str(count))
    summary_table.add_row("From front matter", str(frontmatter_entries))
    summary_table.add_row("From DOI fetches", str(doi_entries))
    console.print(summary_table)

CLI command implementations exposed via texsmith.ui.cli.

This module exists primarily to make the texsmith.ui.cli.commands package importable for documentation tools such as mkdocstrings. It re-exports the Typer command factories defined in the sibling modules so downstream code can import them using dotted paths (e.g. texsmith.ui.cli.commands.render).