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/config.py ADDED
@@ -0,0 +1,387 @@
1
+ """Settings that come from somewhere other than the command line.
2
+
3
+ A conversion is described in four places, and they are read in this order,
4
+ each one overriding the last:
5
+
6
+ 1. the defaults declared below,
7
+ 2. ``~/.config/amethyst/config.toml`` — how this person's documents look,
8
+ 3. ``./amethyst.toml`` — how *these* documents look,
9
+ 4. the document's own frontmatter,
10
+ 5. the flags on the command line.
11
+
12
+ That order is the useful one: the further a statement is from the document, the
13
+ more general it is, and the thing said closest to the moment of conversion is
14
+ the thing that wins.
15
+
16
+ The tuple of :class:`Setting` below is the whole schema. It is what validates a
17
+ file, what rejects a misspelled key, and what ``amethyst init`` writes out with
18
+ its defaults — so adding a setting is adding one entry there and reading it in
19
+ :func:`_build`, and there is nowhere for a second, disagreeing list to hide.
20
+ That is deliberately the same shape the theme layer uses, where
21
+ ``default.toml`` is both the defaults and the schema.
22
+
23
+ Two things do not come from here. The input path and the output path are
24
+ arguments rather than settings: a file that named them would convert the same
25
+ document however it was invoked. And the *format* can be set by a config file
26
+ but not by frontmatter — a document may reasonably say how it should look, but
27
+ where its output goes is the invocation's business, and reading it from the
28
+ document would mean parsing the document before the CLI could tell the user
29
+ they forgot ``-o``.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import os
35
+ import sys
36
+ from collections.abc import Callable, Mapping, Sequence
37
+ from dataclasses import dataclass
38
+ from pathlib import Path
39
+ from typing import Any
40
+
41
+ from amethyst.errors import ConfigError
42
+ from amethyst.render.furniture import MAX_TOC_DEPTH
43
+ from amethyst.theme import is_theme_path
44
+
45
+ if sys.version_info >= (3, 11):
46
+ import tomllib
47
+ else: # pragma: no cover - 3.10 only
48
+ import tomli as tomllib
49
+
50
+ #: The config file a directory of documents carries, and the one ``init``
51
+ #: writes.
52
+ CONFIG_FILENAME = "amethyst.toml"
53
+
54
+ #: The per-user config file, under the base directory named by the XDG
55
+ #: convention — which is what uv, pip and most of this tool's neighbours
56
+ #: already follow on macOS as well as on Linux.
57
+ USER_CONFIG_PARTS = ("amethyst", "config.toml")
58
+ CONFIG_HOME = "XDG_CONFIG_HOME"
59
+ DEFAULT_CONFIG_HOME = Path.home() / ".config"
60
+
61
+ #: What a document's frontmatter may say about its own conversion. Everything
62
+ #: else in the frontmatter is metadata — a title, an author, a date — and is
63
+ #: left alone, which is why frontmatter cannot be checked for unknown keys the
64
+ #: way a config file can.
65
+ FRONTMATTER = "the document's frontmatter"
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class Setting:
70
+ """One thing that can be said in a config file or in frontmatter."""
71
+
72
+ name: str
73
+ #: ``str``, ``bool`` or ``int``. A value of another type is refused rather
74
+ #: than coerced: a page size written as a number is a mistake, not a size.
75
+ kind: type
76
+ default: Any
77
+ #: The one-line explanation ``amethyst init`` writes above the key.
78
+ help: str
79
+ #: Values a string setting may take, when there are only a few of them.
80
+ choices: tuple[str, ...] | None = None
81
+ minimum: int | None = None
82
+ maximum: int | None = None
83
+ #: When set, whether a value names a file — and so should be resolved
84
+ #: against the directory of whatever declared it rather than the working
85
+ #: directory. A predicate rather than a flag because the two settings that
86
+ #: take a path do not agree on what one looks like: a stylesheet is always
87
+ #: a file, and a bare word naming a theme is a builtin.
88
+ locate: Callable[[str], bool] | None = None
89
+ #: Whether a document may set this about itself.
90
+ frontmatter: bool = True
91
+ #: What ``init`` shows for a setting whose default is "say nothing".
92
+ example: str | None = None
93
+
94
+
95
+ SETTINGS: tuple[Setting, ...] = (
96
+ Setting(
97
+ "format",
98
+ str,
99
+ None,
100
+ "Output format when the output path does not imply one.",
101
+ choices=("pdf", "docx"),
102
+ frontmatter=False,
103
+ example='"pdf"',
104
+ ),
105
+ Setting(
106
+ "theme",
107
+ str,
108
+ "default",
109
+ "Builtin theme name, or a path to a theme .toml.",
110
+ locate=is_theme_path,
111
+ ),
112
+ Setting(
113
+ "css",
114
+ str,
115
+ None,
116
+ "Extra stylesheet, appended after everything else. PDF only.",
117
+ locate=lambda _value: True,
118
+ example='"house-style.css"',
119
+ ),
120
+ Setting("toc", bool, False, "Open the document with a table of contents."),
121
+ Setting(
122
+ "toc_depth",
123
+ int,
124
+ 3,
125
+ "Heading levels the contents lists.",
126
+ minimum=1,
127
+ maximum=MAX_TOC_DEPTH,
128
+ ),
129
+ Setting(
130
+ "title_page", bool, False, "Open with a title page built from frontmatter."
131
+ ),
132
+ Setting(
133
+ "page_size",
134
+ str,
135
+ None,
136
+ "Sheet size, overriding the theme's. A4, Letter, or a CSS size.",
137
+ example='"A4"',
138
+ ),
139
+ Setting(
140
+ "margin",
141
+ str,
142
+ None,
143
+ "Page margin, overriding the theme's. CSS style: 2cm, or 2cm 2.5cm.",
144
+ example='"2cm"',
145
+ ),
146
+ Setting("page_numbers", bool, True, "Number the pages in the footer."),
147
+ Setting(
148
+ "highlight_style",
149
+ str,
150
+ "default",
151
+ "Pygments style for code, or none to leave code uncoloured.",
152
+ ),
153
+ Setting("remote", bool, True, "Download images the document links to."),
154
+ )
155
+
156
+ _BY_NAME = {setting.name: setting for setting in SETTINGS}
157
+
158
+
159
+ @dataclass(frozen=True)
160
+ class Settings:
161
+ """Everything the conversion was told, from wherever it was told it."""
162
+
163
+ format: str | None
164
+ theme: str
165
+ css: str | None
166
+ toc: bool
167
+ toc_depth: int
168
+ title_page: bool
169
+ page_size: str | None
170
+ margin: str | None
171
+ page_numbers: bool
172
+ highlight_style: str
173
+ remote: bool
174
+
175
+
176
+ def config_files(*, project_dir: Path | None = None) -> list[Path]:
177
+ """The config files that exist, in the order they are read."""
178
+ directory = Path.cwd() if project_dir is None else project_dir
179
+ candidates = [user_config_path(), directory / CONFIG_FILENAME]
180
+ return [path for path in candidates if path.is_file()]
181
+
182
+
183
+ def user_config_path() -> Path:
184
+ """Where this user's own config file lives, whether or not it is there."""
185
+ base = _env(CONFIG_HOME)
186
+ root = Path(base) if base else DEFAULT_CONFIG_HOME
187
+ return root.joinpath(*USER_CONFIG_PARTS)
188
+
189
+
190
+ def read_config_files(files: Sequence[Path] | None = None) -> dict[str, Any]:
191
+ """Everything the config files say, merged, later file winning.
192
+
193
+ Kept apart from :func:`resolve_settings` because the CLI has to settle the
194
+ output format before it has read the document — and then settle everything
195
+ else after, once the frontmatter is in hand. Reading the files once and
196
+ merging twice is the difference between that and parsing them twice.
197
+ """
198
+ values: dict[str, Any] = {}
199
+ for path in files if files is not None else config_files():
200
+ values.update(read_config(path))
201
+ return values
202
+
203
+
204
+ def resolve_settings(
205
+ *,
206
+ declared: Mapping[str, Any] | None = None,
207
+ metadata: Mapping[str, Any] | None = None,
208
+ document_dir: Path | None = None,
209
+ overrides: Mapping[str, Any] | None = None,
210
+ ) -> Settings:
211
+ """Merge every source of settings into one answer.
212
+
213
+ ``declared`` is what the config files said, from :func:`read_config_files`;
214
+ passing nothing reads them. ``overrides`` is what the command line actually
215
+ said — only the flags that were passed, so that a flag left alone does not
216
+ silently overrule a config file with its own default.
217
+ """
218
+ values: dict[str, Any] = {setting.name: setting.default for setting in SETTINGS}
219
+ values.update(declared if declared is not None else read_config_files())
220
+ if metadata is not None:
221
+ values.update(from_frontmatter(metadata, document_dir))
222
+ if overrides is not None:
223
+ values.update(overrides)
224
+ return _build(values)
225
+
226
+
227
+ def read_config(path: Path) -> dict[str, Any]:
228
+ """Read one config file, refusing anything it should not contain."""
229
+ try:
230
+ text = path.read_text(encoding="utf-8")
231
+ except (OSError, UnicodeDecodeError) as exc:
232
+ detail = getattr(exc, "strerror", None) or "it is not valid UTF-8 text"
233
+ raise ConfigError(f"Could not read {path}: {detail.lower()}.") from exc
234
+ try:
235
+ data = tomllib.loads(text)
236
+ except tomllib.TOMLDecodeError as exc:
237
+ raise ConfigError(f"{path} is not valid TOML: {exc}.") from exc
238
+
239
+ values: dict[str, Any] = {}
240
+ for key, value in data.items():
241
+ setting = _BY_NAME.get(key)
242
+ if setting is None:
243
+ # Silently ignoring a misspelled key looks exactly like a setting
244
+ # that does not work, which is a much worse afternoon than being
245
+ # told which line is wrong. The theme loader refuses for the same
246
+ # reason.
247
+ raise ConfigError(
248
+ f"{path}: unknown setting {key!r}.",
249
+ hint=f"A config file takes: {', '.join(_BY_NAME)}.",
250
+ )
251
+ values[key] = _checked(setting, value, str(path), path.parent)
252
+ return values
253
+
254
+
255
+ def from_frontmatter(
256
+ metadata: Mapping[str, Any], document_dir: Path | None = None
257
+ ) -> dict[str, Any]:
258
+ """The conversion settings a document declares about itself.
259
+
260
+ Only the keys that name a setting are read. Everything else in the
261
+ frontmatter is the document's metadata, and an unknown key there is a
262
+ title or a keyword list rather than a mistake.
263
+ """
264
+ values: dict[str, Any] = {}
265
+ for key, value in metadata.items():
266
+ setting = _BY_NAME.get(str(key).lower())
267
+ if setting is not None and setting.frontmatter:
268
+ values[setting.name] = _checked(setting, value, FRONTMATTER, document_dir)
269
+ return values
270
+
271
+
272
+ def starter_config() -> str:
273
+ """The commented file ``amethyst init`` writes.
274
+
275
+ Generated from the settings rather than shipped as data, so that it cannot
276
+ fall behind them: every key is here, with the default it actually has.
277
+ """
278
+ lines = [
279
+ "# Amethyst settings for the documents in this directory.",
280
+ "#",
281
+ "# Every setting is listed with its default and commented out.",
282
+ "# Uncomment the ones you want to change.",
283
+ "#",
284
+ "# A flag on the command line still wins over anything here, and so",
285
+ "# does a value in a document's own frontmatter.",
286
+ ]
287
+ for setting in SETTINGS:
288
+ value = setting.example if setting.default is None else _toml(setting.default)
289
+ lines += ["", f"# {setting.help}", f"# {setting.name} = {value}"]
290
+ return "\n".join([*lines, ""])
291
+
292
+
293
+ def _build(values: Mapping[str, Any]) -> Settings:
294
+ """Turn merged values into the settings object the CLI reads."""
295
+ return Settings(
296
+ format=values["format"],
297
+ theme=values["theme"],
298
+ css=values["css"],
299
+ toc=values["toc"],
300
+ toc_depth=values["toc_depth"],
301
+ title_page=values["title_page"],
302
+ page_size=values["page_size"],
303
+ margin=values["margin"],
304
+ page_numbers=values["page_numbers"],
305
+ highlight_style=values["highlight_style"],
306
+ remote=values["remote"],
307
+ )
308
+
309
+
310
+ def _checked(setting: Setting, value: Any, source: str, directory: Path | None) -> Any:
311
+ """Validate one declared value, and locate it if it names a file."""
312
+ if setting.kind is bool:
313
+ if not isinstance(value, bool):
314
+ raise _invalid(setting, value, source, "true or false")
315
+ elif setting.kind is int:
316
+ # A bool is an int as far as Python is concerned, and `toc_depth =
317
+ # true` is not a depth.
318
+ if isinstance(value, bool) or not isinstance(value, int):
319
+ raise _invalid(setting, value, source, "a whole number")
320
+ if setting.minimum is not None and value < setting.minimum:
321
+ raise _invalid(setting, value, source, f"at least {setting.minimum}")
322
+ if setting.maximum is not None and value > setting.maximum:
323
+ raise _invalid(setting, value, source, f"at most {setting.maximum}")
324
+ else:
325
+ if not isinstance(value, str) or not value.strip():
326
+ raise _invalid(setting, value, source, "text")
327
+ value = value.strip()
328
+ if setting.choices is not None and value not in setting.choices:
329
+ raise _invalid(setting, value, source, f"one of {_or(setting.choices)}")
330
+ names_a_file = setting.locate is not None and setting.locate(value)
331
+ if names_a_file and directory is not None:
332
+ value = _relative_to(value, directory)
333
+ return value
334
+
335
+
336
+ def _relative_to(value: str, directory: Path) -> str:
337
+ """Resolve a declared path against the file that declared it.
338
+
339
+ A stylesheet named in ``~/.config/amethyst/config.toml`` means the one
340
+ beside that file, not one that happens to share its name with something in
341
+ whatever directory the command was run from.
342
+ """
343
+ candidate = Path(value)
344
+ if candidate.is_absolute():
345
+ return value
346
+ return str((directory / candidate).resolve())
347
+
348
+
349
+ def _invalid(setting: Setting, value: Any, source: str, must: str) -> ConfigError:
350
+ """The one shape a bad setting is reported in, wherever it was written."""
351
+ return ConfigError(f"{source}: {setting.name} must be {must}, not {value!r}.")
352
+
353
+
354
+ def _or(choices: Sequence[str]) -> str:
355
+ """Join choices the way a sentence would."""
356
+ return " or ".join(
357
+ [", ".join(choices[:-1]), choices[-1]] if len(choices) > 1 else choices
358
+ )
359
+
360
+
361
+ def _toml(value: Any) -> str:
362
+ """Write a default the way it would be written in the file."""
363
+ if isinstance(value, bool):
364
+ return "true" if value else "false"
365
+ if isinstance(value, str):
366
+ return f'"{value}"'
367
+ return str(value)
368
+
369
+
370
+ def _env(name: str) -> str | None:
371
+ """One environment variable, treating an empty value as unset."""
372
+ return os.environ.get(name) or None
373
+
374
+
375
+ __all__ = [
376
+ "CONFIG_FILENAME",
377
+ "SETTINGS",
378
+ "Setting",
379
+ "Settings",
380
+ "config_files",
381
+ "from_frontmatter",
382
+ "read_config",
383
+ "read_config_files",
384
+ "resolve_settings",
385
+ "starter_config",
386
+ "user_config_path",
387
+ ]
amethyst/document.py ADDED
@@ -0,0 +1,228 @@
1
+ """The parsed document: its metadata, its tokens, and where it came from.
2
+
3
+ This is the only object the renderers are given. It carries the source
4
+ directory rather than the source file because that is what a relative image
5
+ path resolves against, and stdin has the second without having the first.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import datetime
11
+ import sys
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from markdown_it.token import Token
17
+
18
+ from amethyst.errors import InputError
19
+ from amethyst.parse.assets import Asset, resolve_assets
20
+ from amethyst.parse.frontmatter import split_frontmatter
21
+ from amethyst.parse.markdown import build_parser
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Heading:
26
+ """One heading, as the contents and the running head see it."""
27
+
28
+ #: 1 for ``h1``, 6 for ``h6``.
29
+ level: int
30
+ #: The heading's visible text, with the markup flattened out of it.
31
+ text: str
32
+ #: The HTML id the anchors plugin gave it, which is what a contents entry
33
+ #: links to and what a Word bookmark is named after. ``None`` only if the
34
+ #: parser ever stops assigning one, in which case the entry is still
35
+ #: listed — unlinked, and without a page number.
36
+ anchor: str | None = None
37
+
38
+
39
+ @dataclass
40
+ class Document:
41
+ """A Markdown file, parsed and ready to render."""
42
+
43
+ #: The token stream, with the frontmatter token removed and local image
44
+ #: sources rewritten to absolute paths.
45
+ tokens: list[Token]
46
+ #: Frontmatter fields, keys lowercased, values as YAML produced them.
47
+ metadata: dict[str, Any] = field(default_factory=dict)
48
+ #: Every reference out of the document — see :func:`resolve_assets`.
49
+ assets: list[Asset] = field(default_factory=list)
50
+ #: The file this came from, or ``None`` when it was read from stdin.
51
+ source: Path | None = None
52
+ #: What relative paths resolve against: the source's directory, or the
53
+ #: working directory for stdin.
54
+ base_dir: Path = field(default_factory=Path.cwd)
55
+ #: The original Markdown, frontmatter included.
56
+ text: str = ""
57
+
58
+ @classmethod
59
+ def from_markdown(
60
+ cls,
61
+ text: str,
62
+ *,
63
+ source: Path | None = None,
64
+ base_dir: Path | None = None,
65
+ ) -> Document:
66
+ """Parse Markdown text into a document.
67
+
68
+ ``base_dir`` defaults to the source file's directory, and to the
69
+ working directory when there is no source file.
70
+ """
71
+ if base_dir is None:
72
+ base_dir = source.resolve().parent if source is not None else Path.cwd()
73
+
74
+ metadata, tokens = split_frontmatter(build_parser().parse(text))
75
+ assets = resolve_assets(tokens, base_dir)
76
+ return cls(
77
+ tokens=tokens,
78
+ metadata=metadata,
79
+ assets=assets,
80
+ source=source,
81
+ base_dir=base_dir,
82
+ text=text,
83
+ )
84
+
85
+ @property
86
+ def title(self) -> str | None:
87
+ """The frontmatter title, or failing that the first level-1 heading."""
88
+ declared = _as_text(self.metadata.get("title"))
89
+ if declared is not None:
90
+ return declared
91
+ first = next((item for item in self.headings if item.level == 1), None)
92
+ return (first.text or None) if first is not None else None
93
+
94
+ @property
95
+ def subtitle(self) -> str | None:
96
+ """The declared subtitle. It reaches the title page and the metadata."""
97
+ return _as_text(self.metadata.get("subtitle"))
98
+
99
+ @property
100
+ def author(self) -> str | None:
101
+ """The declared author. A YAML list becomes a comma-separated string."""
102
+ return _as_text(self.metadata.get("author"))
103
+
104
+ @property
105
+ def date(self) -> str | None:
106
+ """The declared date, as text. YAML dates arrive parsed; ISO them back."""
107
+ return _as_text(self.metadata.get("date"))
108
+
109
+ @property
110
+ def keywords(self) -> str | None:
111
+ """The declared keywords, comma-separated — which both formats want."""
112
+ return _as_text(self.metadata.get("keywords"))
113
+
114
+ @property
115
+ def created(self) -> datetime.date | None:
116
+ """The declared date as a real date, or ``None`` if it is not one.
117
+
118
+ A document may be dated "Spring 2026", which belongs on a title page
119
+ and nowhere near a file's creation timestamp. Only a date that parses
120
+ reaches the PDF's and Word's metadata; the rest stays text.
121
+ """
122
+ text = self.date
123
+ if text is None:
124
+ return None
125
+ try:
126
+ return datetime.date.fromisoformat(text)
127
+ except ValueError:
128
+ return None
129
+
130
+ @property
131
+ def headings(self) -> list[Heading]:
132
+ """Every heading in the document, in the order they are written.
133
+
134
+ Derived from the tokens rather than collected during the parse: the
135
+ contents, the running head and Word's bookmarks all want the same
136
+ list, and there is nothing here that the token stream does not already
137
+ say.
138
+ """
139
+ found: list[Heading] = []
140
+ for index, token in enumerate(self.tokens):
141
+ if token.type != "heading_open":
142
+ continue
143
+ inline = self.tokens[index + 1] if index + 1 < len(self.tokens) else None
144
+ if inline is None or inline.type != "inline":
145
+ continue
146
+ anchor = token.attrGet("id")
147
+ found.append(
148
+ Heading(
149
+ level=int(token.tag[1:]),
150
+ text=_inline_text(inline),
151
+ anchor=anchor if isinstance(anchor, str) and anchor else None,
152
+ )
153
+ )
154
+ return found
155
+
156
+ @property
157
+ def missing_assets(self) -> list[Asset]:
158
+ """Local references with no file at the other end."""
159
+ return [asset for asset in self.assets if asset.is_missing]
160
+
161
+
162
+ def load_document(source: Path | None) -> Document:
163
+ """Read Markdown from a file, or from stdin when ``source`` is ``None``."""
164
+ if source is None:
165
+ return Document.from_markdown(_read_stdin(), base_dir=Path.cwd())
166
+ return Document.from_markdown(_read_file(source), source=source)
167
+
168
+
169
+ def _read_file(source: Path) -> str:
170
+ """Read a Markdown file as UTF-8, reporting failures as one clear line."""
171
+ try:
172
+ # utf-8-sig so a byte-order mark from a Windows editor does not end up
173
+ # as an invisible first character of the first heading.
174
+ return source.read_text(encoding="utf-8-sig")
175
+ except UnicodeDecodeError as exc:
176
+ raise InputError(
177
+ f"{source} is not valid UTF-8 text.",
178
+ hint="Amethyst reads UTF-8 Markdown; convert the file first.",
179
+ ) from exc
180
+ except FileNotFoundError as exc:
181
+ raise InputError(f"No such file: {source}.") from exc
182
+ except OSError as exc:
183
+ detail = exc.strerror or str(exc)
184
+ raise InputError(f"Could not read {source}: {detail.lower()}.") from exc
185
+
186
+
187
+ def _read_stdin() -> str:
188
+ """Read the whole of stdin as the document."""
189
+ try:
190
+ return sys.stdin.read()
191
+ except UnicodeDecodeError as exc:
192
+ raise InputError(
193
+ "The Markdown on stdin is not valid UTF-8 text.",
194
+ hint="Amethyst reads UTF-8 Markdown; convert the input first.",
195
+ ) from exc
196
+
197
+
198
+ def _inline_text(token: Token) -> str:
199
+ """Flatten an inline token to its visible text, dropping the markup."""
200
+ if not token.children:
201
+ return token.content.strip()
202
+ parts = [
203
+ child.content
204
+ for child in token.children
205
+ if child.type in {"text", "code_inline"}
206
+ ]
207
+ return "".join(parts).strip()
208
+
209
+
210
+ def _as_text(value: Any) -> str | None:
211
+ """Render a metadata value as a display string, or ``None`` if it is empty.
212
+
213
+ Frontmatter is YAML, so a value can arrive as a date, a number, a boolean
214
+ or a list. Everything that reaches a title page or a Word core property has
215
+ to be a string, and this is the single place that conversion happens.
216
+ """
217
+ if value is None:
218
+ return None
219
+ if isinstance(value, str):
220
+ return value.strip() or None
221
+ if isinstance(value, bool):
222
+ return "true" if value else "false"
223
+ if isinstance(value, datetime.date): # covers datetime, which subclasses it
224
+ return value.isoformat()
225
+ if isinstance(value, list | tuple):
226
+ joined = ", ".join(text for text in map(_as_text, value) if text)
227
+ return joined or None
228
+ return str(value)
amethyst/errors.py ADDED
@@ -0,0 +1,66 @@
1
+ """The error hierarchy Amethyst reports with, rather than crashing.
2
+
3
+ Every error carries the process exit code it should produce, so the CLI can
4
+ turn it into one readable line instead of a traceback. The codes are the ones
5
+ the CLI contract promises: 0 ok, 1 conversion failure, 2 bad usage, 3 missing
6
+ system dependency.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+
12
+ class AmethystError(Exception):
13
+ """Base for anything the user should see as a message, not a traceback.
14
+
15
+ ``hint`` is the actionable second line — the command to run, or the flag to
16
+ pass. Leave it out when there is nothing concrete to suggest.
17
+ """
18
+
19
+ exit_code = 1
20
+
21
+ def __init__(self, message: str, *, hint: str | None = None) -> None:
22
+ super().__init__(message)
23
+ self.message = message
24
+ self.hint = hint
25
+
26
+
27
+ class UsageError(AmethystError):
28
+ """The invocation itself was wrong: conflicting flags, unusable paths."""
29
+
30
+ exit_code = 2
31
+
32
+
33
+ class InputError(AmethystError):
34
+ """The source document could not be read or parsed."""
35
+
36
+
37
+ class ConfigError(AmethystError):
38
+ """A config file, or a document's frontmatter, said something unusable.
39
+
40
+ A conversion failure rather than bad usage, for the same reason a broken
41
+ theme is: the invocation was fine, and a file it read was not.
42
+ """
43
+
44
+
45
+ class ThemeError(AmethystError):
46
+ """A theme was located but could not be read or validated.
47
+
48
+ A theme that simply is not there — an unknown builtin name, or a path with
49
+ no file at it — is a ``UsageError`` instead. That is a mistyped invocation,
50
+ not a broken document, and the two deserve different exit codes.
51
+ """
52
+
53
+
54
+ class RenderError(AmethystError):
55
+ """Conversion began but could not produce output."""
56
+
57
+
58
+ class MissingDependencyError(AmethystError):
59
+ """A system library is absent, or present but not on the loader's path.
60
+
61
+ The distinction matters enough to be worth spelling out in the message:
62
+ telling someone to ``brew install pango`` when pango is already installed
63
+ and merely unreachable sends them round in circles.
64
+ """
65
+
66
+ exit_code = 3