amethyst-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
amethyst/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Amethyst — turn Markdown into a well-typeset PDF or Word document."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["__version__"]
amethyst/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for ``python -m amethyst``."""
2
+
3
+ from amethyst.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
amethyst/cli.py ADDED
@@ -0,0 +1,644 @@
1
+ """The Typer application: argument parsing, resolution and exit codes.
2
+
3
+ Both formats convert for real here. The one thing worth knowing about this
4
+ module is that the document and the commentary can both end up on stdout:
5
+ ``-o -`` writes the document there, and a progress line printed alongside it
6
+ would land inside the file. So every human-readable line goes through
7
+ ``out_console()``, which steps aside to stderr when stdout belongs to the
8
+ document.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import sys
14
+ from dataclasses import dataclass
15
+ from enum import Enum
16
+ from pathlib import Path
17
+ from typing import Annotated, Any
18
+
19
+ import typer
20
+ from rich.console import Console
21
+ from rich.markup import escape
22
+ from rich.table import Table
23
+
24
+ from amethyst import __version__
25
+ from amethyst.config import (
26
+ CONFIG_FILENAME,
27
+ SETTINGS,
28
+ config_files,
29
+ read_config_files,
30
+ resolve_settings,
31
+ starter_config,
32
+ )
33
+ from amethyst.document import Document, load_document
34
+ from amethyst.errors import AmethystError, RenderError, UsageError
35
+ from amethyst.parse import AssetKind
36
+ from amethyst.remote import fetch_remote_images
37
+ from amethyst.render import (
38
+ DEFAULT_HIGHLIGHT_STYLE,
39
+ RenderOptions,
40
+ render_docx,
41
+ render_pdf,
42
+ resolve_highlight_style,
43
+ )
44
+ from amethyst.render.furniture import MAX_TOC_DEPTH
45
+ from amethyst.theme import (
46
+ DEFAULT_THEME,
47
+ builtin_names,
48
+ load_theme,
49
+ locate_theme,
50
+ read_theme_text,
51
+ )
52
+
53
+ #: The conventional spelling of "stdin" or "stdout" as a path argument.
54
+ DASH = Path("-")
55
+
56
+ #: The flags that state a setting *negatively*: ``--no-page-numbers`` sets
57
+ #: ``page_numbers`` to false. A flag reads better as the thing you turn off and
58
+ #: a setting reads better as the thing you turn on, so the two disagree, and
59
+ #: this is the one place that has to know it.
60
+ NEGATED_FLAGS = {"no_page_numbers": "page_numbers", "no_remote": "remote"}
61
+
62
+ #: The flags whose parameter is simply spelled differently from its setting —
63
+ #: ``fmt`` because ``format`` is a builtin. Everything not named here or above
64
+ #: carries the setting of the same name.
65
+ FLAG_SETTINGS = {"fmt": "format"}
66
+
67
+ console = Console()
68
+ err_console = Console(stderr=True)
69
+
70
+
71
+ class Format(str, Enum):
72
+ """Output formats. The value doubles as the output file extension."""
73
+
74
+ pdf = "pdf"
75
+ docx = "docx"
76
+
77
+
78
+ class PdfEngine(str, Enum):
79
+ """PDF backends. One for now; the flag exists to keep the seam visible."""
80
+
81
+ weasyprint = "weasyprint"
82
+
83
+
84
+ _FORMAT_BY_SUFFIX = {f".{fmt.value}": fmt for fmt in Format}
85
+
86
+ #: The settings a flag is allowed to override, which is every setting there is.
87
+ _SETTING_NAMES = frozenset(setting.name for setting in SETTINGS)
88
+
89
+
90
+ @dataclass
91
+ class State:
92
+ """Cross-cutting flags that outlive argument parsing."""
93
+
94
+ verbose: bool = False
95
+ quiet: bool = False
96
+ #: True once the converted document is going to stdout, which makes stdout
97
+ #: unavailable for anything a human is meant to read.
98
+ document_on_stdout: bool = False
99
+
100
+
101
+ state = State()
102
+
103
+
104
+ def _version_callback(value: bool) -> None:
105
+ """Print the version and stop, before any argument is validated."""
106
+ if value:
107
+ console.print(f"amethyst {__version__}")
108
+ raise typer.Exit()
109
+
110
+
111
+ app = typer.Typer(
112
+ name="amethyst",
113
+ help="Turn a Markdown file into a well-typeset PDF or Word document.",
114
+ no_args_is_help=True,
115
+ # Turns shell completion off entirely, not just the two --*-completion
116
+ # flags: Typer registers its completion classes from the same code path
117
+ # that builds those flags, so the _AMETHYST_COMPLETE env var stops working
118
+ # too. Little is lost today — the completions worth having (theme names on
119
+ # -t, Pygments styles on --highlight-style) are plain strings and would
120
+ # need explicit shell_complete callbacks either way. Flip this back to True
121
+ # when those exist.
122
+ add_completion=False,
123
+ )
124
+ themes_app = typer.Typer(
125
+ name="themes",
126
+ help="Inspect the builtin themes.",
127
+ no_args_is_help=True,
128
+ )
129
+ app.add_typer(themes_app)
130
+
131
+
132
+ @app.callback()
133
+ def cli(
134
+ version: Annotated[
135
+ bool,
136
+ typer.Option(
137
+ "--version",
138
+ callback=_version_callback,
139
+ is_eager=True,
140
+ help="Show the version and exit.",
141
+ ),
142
+ ] = False,
143
+ ) -> None:
144
+ """Turn a Markdown file into a well-typeset PDF or Word document."""
145
+
146
+
147
+ def resolve_format(
148
+ output: Path | None, fmt: Format | None, configured: Format | None = None
149
+ ) -> Format:
150
+ """Decide the output format, from the flag, the output name or a config file.
151
+
152
+ In that order, which is the order of how specific each one is to this
153
+ invocation. An explicit ``-f`` always wins; a mismatch with the output
154
+ extension is worth a warning but not an error, since the user said what
155
+ they meant. A configured format is the most general statement there is, so
156
+ an output path that names an extension outranks it.
157
+ """
158
+ if fmt is not None:
159
+ if output is not None and output != DASH:
160
+ inferred = _FORMAT_BY_SUFFIX.get(output.suffix.lower())
161
+ if inferred is not None and inferred is not fmt:
162
+ warn(
163
+ f"--format {fmt.value} overrides the {output.suffix} "
164
+ f"extension of {output}."
165
+ )
166
+ return fmt
167
+
168
+ if output is not None and output != DASH:
169
+ suffix = output.suffix.lower()
170
+ inferred = _FORMAT_BY_SUFFIX.get(suffix)
171
+ if inferred is not None:
172
+ return inferred
173
+ if configured is not None:
174
+ return configured
175
+ described = (
176
+ f"the extension {suffix!r}" if suffix else "a name with no extension"
177
+ )
178
+ raise UsageError(
179
+ f"Cannot infer an output format from {described}.",
180
+ hint="Use a .pdf or .docx output path, or pass -f explicitly.",
181
+ )
182
+
183
+ if configured is not None:
184
+ return configured
185
+ if output == DASH:
186
+ raise UsageError(
187
+ "Writing to stdout needs an explicit format.",
188
+ hint="Pass -f pdf or -f docx.",
189
+ )
190
+ raise UsageError(
191
+ "No output format given.",
192
+ hint="Pass -f pdf or -f docx, or name the output with -o out.pdf.",
193
+ )
194
+
195
+
196
+ def resolve_output(source: Path, output: Path | None, fmt: Format) -> Path | None:
197
+ """Return where the document should be written, or ``None`` for stdout."""
198
+ if output == DASH:
199
+ return None
200
+ if output is not None:
201
+ return output
202
+ if source == DASH:
203
+ raise UsageError(
204
+ "Reading from stdin needs an explicit output path.",
205
+ hint="Pass -o out.pdf, or -o - to write the document to stdout.",
206
+ )
207
+ return source.with_suffix(f".{fmt.value}")
208
+
209
+
210
+ def resolve_theme(theme: str) -> str:
211
+ """Validate a theme name, or accept a path to a theme file.
212
+
213
+ Only existence is checked here, so the failures are ``UsageError``. Reading
214
+ and validating the theme happens later and raises ``ThemeError`` — a theme
215
+ that is present but broken is a different problem from one that was never
216
+ named correctly, and the two exit differently.
217
+ """
218
+ return locate_theme(theme)
219
+
220
+
221
+ def apply_overrides(
222
+ document: Document, *, title: str | None, author: str | None
223
+ ) -> None:
224
+ """Let --title and --author win over the frontmatter that declared them."""
225
+ if title is not None:
226
+ document.metadata["title"] = title
227
+ if author is not None:
228
+ document.metadata["author"] = author
229
+
230
+
231
+ def warn_about_missing_assets(document: Document) -> None:
232
+ """Warn once per reference that points at a file which is not there.
233
+
234
+ A missing image is not fatal — the document still converts, with a gap
235
+ where the picture was — so this warns and continues rather than raising.
236
+ """
237
+ for asset in document.missing_assets:
238
+ noun = "image" if asset.kind is AssetKind.image else "linked file"
239
+ where = f" (line {asset.line})" if asset.line is not None else ""
240
+ warn(f"{noun} not found: {asset.reference}{where}")
241
+
242
+
243
+ def warn(message: str) -> None:
244
+ """Report something the user should know about but that is not fatal."""
245
+ if not state.quiet:
246
+ err_console.print(f"[yellow]warning:[/] {escape(message)}")
247
+
248
+
249
+ def out_console() -> Console:
250
+ """Where a line meant for a human goes.
251
+
252
+ Normally stdout. When the converted document is being written to stdout,
253
+ anything else printed there would end up inside the file, so it steps
254
+ aside to stderr — which is also where a shell pipeline expects commentary.
255
+ """
256
+ return err_console if state.document_on_stdout else console
257
+
258
+
259
+ def report(heading: str, rows: list[tuple[str, str]]) -> None:
260
+ """Print a labelled table of the values a command resolved."""
261
+ if state.quiet:
262
+ return
263
+ table = Table(show_header=False, box=None, padding=(0, 2, 0, 0))
264
+ table.add_column(style="cyan", justify="right", no_wrap=True)
265
+ table.add_column(overflow="fold")
266
+ for label, value in rows:
267
+ table.add_row(label, escape(value))
268
+ destination = out_console()
269
+ destination.print(f"[bold]{heading}[/]")
270
+ destination.print(table)
271
+
272
+
273
+ def report_written(destination: Path | None, pages: int | None) -> None:
274
+ """Confirm the conversion, and say enough to show it produced a document."""
275
+ if state.quiet:
276
+ return
277
+ where = "stdout" if destination is None else str(destination)
278
+ detail = "" if pages is None else f" ({pages} page{'' if pages == 1 else 's'})"
279
+ out_console().print(f"wrote {escape(where)}{detail}")
280
+
281
+
282
+ def write_document(data: bytes, destination: Path | None) -> None:
283
+ """Write the finished document out, to a file or to stdout."""
284
+ if destination is None:
285
+ _write_stdout(data)
286
+ return
287
+ try:
288
+ destination.write_bytes(data)
289
+ except OSError as exc:
290
+ detail = exc.strerror or str(exc)
291
+ raise RenderError(f"Could not write {destination}: {detail.lower()}.") from exc
292
+
293
+
294
+ def _write_stdout(data: bytes) -> None:
295
+ """Write the document's bytes to stdout, without touching the encoding."""
296
+ buffer = getattr(sys.stdout, "buffer", None)
297
+ if buffer is None:
298
+ raise RenderError(
299
+ "stdout cannot take the raw bytes of a document.",
300
+ hint="Write to a file with -o instead.",
301
+ )
302
+ buffer.write(data)
303
+ buffer.flush()
304
+
305
+
306
+ @app.command()
307
+ def convert(
308
+ ctx: typer.Context,
309
+ source: Annotated[
310
+ Path,
311
+ typer.Argument(
312
+ metavar="INPUT",
313
+ exists=True,
314
+ dir_okay=False,
315
+ readable=True,
316
+ allow_dash=True,
317
+ help="Markdown file to convert, or - to read stdin.",
318
+ ),
319
+ ],
320
+ output: Annotated[
321
+ Path | None,
322
+ typer.Option(
323
+ "-o",
324
+ "--output",
325
+ dir_okay=False,
326
+ allow_dash=True,
327
+ help="Output file; the extension infers the format. - writes stdout.",
328
+ ),
329
+ ] = None,
330
+ fmt: Annotated[
331
+ Format | None,
332
+ typer.Option(
333
+ "-f",
334
+ "--format",
335
+ help="Output format. Required when --output is omitted or is stdout.",
336
+ ),
337
+ ] = None,
338
+ theme: Annotated[
339
+ str,
340
+ typer.Option("-t", "--theme", help="Builtin theme name, or a path to a .toml."),
341
+ ] = DEFAULT_THEME,
342
+ css: Annotated[
343
+ Path | None,
344
+ typer.Option(
345
+ "--css",
346
+ exists=True,
347
+ dir_okay=False,
348
+ readable=True,
349
+ help="Extra CSS, appended last. PDF only.",
350
+ ),
351
+ ] = None,
352
+ toc: Annotated[
353
+ bool, typer.Option("--toc", help="Insert a table of contents.")
354
+ ] = False,
355
+ toc_depth: Annotated[
356
+ int,
357
+ typer.Option(
358
+ "--toc-depth",
359
+ min=1,
360
+ max=MAX_TOC_DEPTH,
361
+ help="Heading levels in the TOC.",
362
+ ),
363
+ ] = 3,
364
+ title_page: Annotated[
365
+ bool,
366
+ typer.Option("--title-page", help="Open with a title page from frontmatter."),
367
+ ] = False,
368
+ title: Annotated[
369
+ str | None, typer.Option("--title", help="Override the frontmatter title.")
370
+ ] = None,
371
+ author: Annotated[
372
+ str | None, typer.Option("--author", help="Override the frontmatter author.")
373
+ ] = None,
374
+ page_size: Annotated[
375
+ str | None,
376
+ typer.Option(
377
+ "--page-size",
378
+ show_default="the theme's",
379
+ help="A4, Letter, or a custom size.",
380
+ ),
381
+ ] = None,
382
+ margin: Annotated[
383
+ str | None,
384
+ typer.Option(
385
+ "--margin",
386
+ show_default="the theme's",
387
+ help='CSS-style margin, e.g. "2cm" or "2cm 2.5cm".',
388
+ ),
389
+ ] = None,
390
+ no_page_numbers: Annotated[
391
+ bool, typer.Option("--no-page-numbers", help="Suppress footer page numbers.")
392
+ ] = False,
393
+ no_remote: Annotated[
394
+ bool,
395
+ typer.Option("--no-remote", help="Do not download images from the network."),
396
+ ] = False,
397
+ highlight_style: Annotated[
398
+ str,
399
+ typer.Option(
400
+ "--highlight-style", help="Pygments style name, or none for no colour."
401
+ ),
402
+ ] = DEFAULT_HIGHLIGHT_STYLE,
403
+ pdf_engine: Annotated[
404
+ PdfEngine, typer.Option("--pdf-engine", help="PDF backend.")
405
+ ] = PdfEngine.weasyprint,
406
+ quiet: Annotated[
407
+ bool, typer.Option("--quiet", "-q", help="Print nothing but errors.")
408
+ ] = False,
409
+ verbose: Annotated[
410
+ bool, typer.Option("--verbose", help="Print detail, and tracebacks on error.")
411
+ ] = False,
412
+ ) -> None:
413
+ """Convert a Markdown file to PDF or DOCX."""
414
+ set_verbosity(quiet=quiet, verbose=verbose)
415
+
416
+ # Read once, merged twice: the format has to be settled before the document
417
+ # is opened, and everything else after, once its frontmatter is in hand.
418
+ files = config_files()
419
+ declared = read_config_files(files)
420
+ overrides = passed_settings(ctx)
421
+ early = resolve_settings(declared=declared, overrides=overrides)
422
+
423
+ resolved_format = resolve_format(output, fmt, _format(early.format))
424
+ destination = resolve_output(source, output, resolved_format)
425
+ # Settled before anything is printed, because it decides where printing
426
+ # goes: a PDF on stdout leaves no room for a progress line beside it.
427
+ state.document_on_stdout = destination is None
428
+
429
+ document = load_document(None if source == DASH else source)
430
+ apply_overrides(document, title=title, author=author)
431
+ settings = resolve_settings(
432
+ declared=declared,
433
+ metadata=document.metadata,
434
+ document_dir=document.base_dir,
435
+ overrides=overrides,
436
+ )
437
+
438
+ resolved_theme = resolve_theme(settings.theme)
439
+ resolved_highlighting = resolve_highlight_style(settings.highlight_style)
440
+ extra_css = Path(settings.css) if settings.css is not None else None
441
+ if extra_css is not None and resolved_format is not Format.pdf:
442
+ # Only worth saying when this invocation asked for it. A config file
443
+ # that names a stylesheet for a directory of documents is not making a
444
+ # mistake every time one of them is converted to Word.
445
+ if "css" in overrides:
446
+ warn("--css applies to PDF output only; ignoring it.")
447
+ extra_css = None
448
+
449
+ # A flag that names page geometry overrides the theme that declares it,
450
+ # which leaves the theme as the one thing a renderer has to be handed.
451
+ loaded_theme = load_theme(resolved_theme).with_page(
452
+ size=settings.page_size, margin=settings.margin
453
+ )
454
+
455
+ rows = [
456
+ ("input", "stdin" if source == DASH else str(source)),
457
+ ("output", "stdout" if destination is None else str(destination)),
458
+ ("format", resolved_format.value),
459
+ ("theme", resolved_theme),
460
+ ]
461
+ if files:
462
+ rows.append(("config", ", ".join(str(path) for path in files)))
463
+ if extra_css is not None:
464
+ rows.append(("extra css", str(extra_css)))
465
+ rows.append(("title", document.title or "(untitled)"))
466
+ if document.author is not None:
467
+ rows.append(("author", document.author))
468
+ rows.append(("toc", f"depth {settings.toc_depth}" if settings.toc else "no"))
469
+ rows.append(("title page", "yes" if settings.title_page else "no"))
470
+ rows.append(("page size", loaded_theme.page.size))
471
+ rows.append(("margin", loaded_theme.page.margin))
472
+ rows.append(("page numbers", "yes" if settings.page_numbers else "no"))
473
+ rows.append(("highlighting", resolved_highlighting))
474
+ rows.append(("remote images", "yes" if settings.remote else "no"))
475
+ if resolved_format is Format.pdf:
476
+ rows.append(("pdf engine", pdf_engine.value))
477
+
478
+ if state.verbose:
479
+ report("Converting:", rows)
480
+
481
+ # After the plan is printed: this is the one step that can take a visible
482
+ # amount of time, and a user watching it wait deserves to already know
483
+ # what it is doing.
484
+ fetch_remote_images(document, enabled=settings.remote, warn=warn)
485
+ warn_about_missing_assets(document)
486
+
487
+ render = render_pdf if resolved_format is Format.pdf else render_docx
488
+ result = render(
489
+ document,
490
+ RenderOptions(
491
+ theme=loaded_theme,
492
+ extra_css=extra_css,
493
+ page_numbers=settings.page_numbers,
494
+ toc=settings.toc,
495
+ toc_depth=settings.toc_depth,
496
+ title_page=settings.title_page,
497
+ highlight_style=resolved_highlighting,
498
+ warn=warn,
499
+ ),
500
+ )
501
+ write_document(result.data, destination)
502
+ report_written(destination, result.pages)
503
+
504
+
505
+ def passed_settings(ctx: typer.Context) -> dict[str, Any]:
506
+ """The settings the command line actually stated, and only those.
507
+
508
+ A flag left alone must not overrule a config file with the default it was
509
+ going to have anyway, so what matters is not a parameter's value but
510
+ whether it was typed. Click records that per parameter, which is the one
511
+ reliable way to ask: comparing against the default cannot tell
512
+ ``--toc-depth 3`` from not passing it.
513
+ """
514
+ given: dict[str, Any] = {}
515
+ for name, value in ctx.params.items():
516
+ setting = NEGATED_FLAGS.get(name) or FLAG_SETTINGS.get(name, name)
517
+ if setting not in _SETTING_NAMES or not _was_typed(ctx, name):
518
+ continue
519
+ if name in NEGATED_FLAGS:
520
+ given[setting] = not value
521
+ elif isinstance(value, Enum):
522
+ given[setting] = value.value
523
+ elif isinstance(value, Path):
524
+ given[setting] = str(value)
525
+ else:
526
+ given[setting] = value
527
+ return given
528
+
529
+
530
+ def _was_typed(ctx: typer.Context, name: str) -> bool:
531
+ """Whether a parameter's value came from the command line.
532
+
533
+ Compared by name rather than against the ``ParameterSource`` enum itself:
534
+ Typer vendors its own copy of Click in recent versions, so the enum has no
535
+ stable import path, and the member's name does.
536
+ """
537
+ source = ctx.get_parameter_source(name)
538
+ return source is not None and source.name != "DEFAULT"
539
+
540
+
541
+ def _format(name: str | None) -> Format | None:
542
+ """A configured format name as the enum. Validated when it was read."""
543
+ return None if name is None else Format(name)
544
+
545
+
546
+ @themes_app.command("list")
547
+ def themes_list() -> None:
548
+ """List the builtin themes."""
549
+ table = Table(show_header=False, box=None, padding=(0, 2, 0, 0))
550
+ table.add_column(style="cyan", no_wrap=True)
551
+ table.add_column(overflow="fold")
552
+ for name in builtin_names():
553
+ table.add_row(name, escape(load_theme(name).description))
554
+ console.print(table)
555
+
556
+
557
+ @themes_app.command("show")
558
+ def themes_show(
559
+ name: Annotated[str, typer.Argument(metavar="NAME", help="Builtin theme name.")],
560
+ ) -> None:
561
+ """Print a theme's TOML, ready to copy and edit."""
562
+ # Printed raw: this is a file meant to be copied back out, so Rich must not
563
+ # touch it. Markup off, because a colour written as [#6a3fa0] is a style tag
564
+ # to Rich and a value to everyone else; soft wrapping on, because folding a
565
+ # long line at the terminal width would put a newline inside the TOML.
566
+ console.print(
567
+ read_theme_text(resolve_theme(name)),
568
+ markup=False,
569
+ highlight=False,
570
+ soft_wrap=True,
571
+ )
572
+
573
+
574
+ @app.command()
575
+ def init(
576
+ quiet: Annotated[
577
+ bool, typer.Option("--quiet", "-q", help="Print nothing but errors.")
578
+ ] = False,
579
+ verbose: Annotated[
580
+ bool, typer.Option("--verbose", help="Print detail, and tracebacks on error.")
581
+ ] = False,
582
+ ) -> None:
583
+ """Write a starter amethyst.toml into the current directory."""
584
+ set_verbosity(quiet=quiet, verbose=verbose)
585
+
586
+ destination = Path.cwd() / CONFIG_FILENAME
587
+ if destination.exists():
588
+ raise UsageError(
589
+ f"{CONFIG_FILENAME} already exists here.",
590
+ hint="Move or delete it first; Amethyst will not overwrite it.",
591
+ )
592
+ try:
593
+ destination.write_text(starter_config(), encoding="utf-8")
594
+ except OSError as exc:
595
+ detail = exc.strerror or str(exc)
596
+ raise RenderError(f"Could not write {destination}: {detail.lower()}.") from exc
597
+ report_written(destination, None)
598
+
599
+
600
+ def set_verbosity(*, quiet: bool, verbose: bool) -> None:
601
+ """Record the verbosity flags, which outlive the command that parsed them."""
602
+ if quiet and verbose:
603
+ raise UsageError("--quiet and --verbose contradict each other.")
604
+ state.quiet = quiet
605
+ state.verbose = verbose
606
+
607
+
608
+ def main() -> None:
609
+ """Console-script entry point: run the app, and report errors as one line.
610
+
611
+ Click handles its own usage errors (and already exits 2 for them). Anything
612
+ raised as an ``AmethystError`` is ours, and gets a message plus its own exit
613
+ code — with the traceback held back unless ``--verbose`` asked for it.
614
+
615
+ Anything else is a bug, and says so. A traceback is the right thing to hand
616
+ a maintainer and the wrong thing to hand someone who typed a command, so it
617
+ is held behind ``--verbose`` along with the rest of them. ``SystemExit`` and
618
+ ``KeyboardInterrupt`` are not ``Exception`` and so pass through untouched,
619
+ which is what leaves Click's own exits and a ctrl-C alone.
620
+ """
621
+ try:
622
+ app()
623
+ except AmethystError as exc:
624
+ err_console.print(f"[bold red]error:[/] {escape(exc.message)}")
625
+ if exc.hint:
626
+ err_console.print(f"[dim]hint:[/] {escape(exc.hint)}")
627
+ if state.verbose:
628
+ err_console.print_exception()
629
+ raise SystemExit(exc.exit_code) from exc
630
+ except Exception as exc: # noqa: BLE001 - the last line before a traceback
631
+ err_console.print(
632
+ f"[bold red]error:[/] {escape(type(exc).__name__)}: {escape(str(exc))}"
633
+ )
634
+ err_console.print(
635
+ "[dim]hint:[/] That is a bug in Amethyst, not something you did. "
636
+ "Run it again with --verbose for the traceback."
637
+ )
638
+ if state.verbose:
639
+ err_console.print_exception()
640
+ raise SystemExit(1) from exc
641
+
642
+
643
+ if __name__ == "__main__":
644
+ main()