korml 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.
korml/__init__.py ADDED
@@ -0,0 +1,45 @@
1
+ # ------------------------------------------------------------------------------
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ # ------------------------------------------------------------------------------
4
+
5
+ import korml.core as core
6
+ import korml.error as error
7
+ import korml.schema as schema
8
+ from korml.api import dump, dumps, edit, load, loads
9
+ from korml.error import KormlError, LexError, ParseError
10
+ from korml.model import (
11
+ CST,
12
+ ConcreteToken,
13
+ GenericToken,
14
+ Mapping,
15
+ Scalar,
16
+ Sequence,
17
+ Stream,
18
+ Style,
19
+ Value,
20
+ Version,
21
+ )
22
+
23
+ __all__ = [
24
+ "core",
25
+ "error",
26
+ "schema",
27
+ "Version",
28
+ "Style",
29
+ "GenericToken",
30
+ "ConcreteToken",
31
+ "CST",
32
+ "Value",
33
+ "Scalar",
34
+ "Sequence",
35
+ "Mapping",
36
+ "Stream",
37
+ "KormlError",
38
+ "LexError",
39
+ "ParseError",
40
+ "dump",
41
+ "dumps",
42
+ "edit",
43
+ "load",
44
+ "loads",
45
+ ]
korml/_atomic.py ADDED
@@ -0,0 +1,84 @@
1
+ # ------------------------------------------------------------------------------
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ # ------------------------------------------------------------------------------
4
+
5
+ """Atomic output replacement, without following final-component symlinks."""
6
+
7
+ import os
8
+ import pathlib
9
+ import secrets
10
+ import stat
11
+ import tempfile
12
+
13
+
14
+ def write_bytes(path: str | pathlib.Path, data: bytes) -> None:
15
+ """Write a complete new file and atomically replace *path*.
16
+
17
+ On POSIX, directory-relative operations pin the parent directory during
18
+ the write and rename. A symlinked parent directory is supported, but a
19
+ final-component output symlink is replaced rather than followed. On other
20
+ platforms, atomic replace is used without directory-relative operations.
21
+ """
22
+ destination = pathlib.Path(path)
23
+ if os.name == "posix":
24
+ _write_posix(destination, data)
25
+ else:
26
+ _write_portable(destination, data)
27
+
28
+
29
+ def _write_posix(destination: pathlib.Path, data: bytes) -> None:
30
+ parent_fd = os.open(destination.parent, os.O_RDONLY | os.O_DIRECTORY)
31
+ temporary_name = f".{destination.name}.{secrets.token_hex(12)}.tmp"
32
+ try:
33
+ fd = os.open(
34
+ temporary_name, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600, dir_fd=parent_fd
35
+ )
36
+ try:
37
+ with os.fdopen(fd, "wb") as stream:
38
+ try:
39
+ previous = os.stat(
40
+ destination.name, dir_fd=parent_fd, follow_symlinks=False
41
+ )
42
+ except FileNotFoundError:
43
+ previous = None
44
+ if previous is not None and stat.S_ISREG(previous.st_mode):
45
+ os.fchmod(stream.fileno(), stat.S_IMODE(previous.st_mode))
46
+ stream.write(data)
47
+ stream.flush()
48
+ os.fsync(stream.fileno())
49
+ os.replace(
50
+ temporary_name,
51
+ destination.name,
52
+ src_dir_fd=parent_fd,
53
+ dst_dir_fd=parent_fd,
54
+ )
55
+ os.fsync(parent_fd)
56
+ finally:
57
+ try:
58
+ os.unlink(temporary_name, dir_fd=parent_fd)
59
+ except FileNotFoundError:
60
+ pass
61
+ finally:
62
+ os.close(parent_fd)
63
+
64
+
65
+ def _write_portable(destination: pathlib.Path, data: bytes) -> None:
66
+ # Non-POSIX fallback: secure temporary creation and atomic replacement.
67
+ fd, temporary = tempfile.mkstemp(
68
+ prefix=f".{destination.name}.", suffix=".tmp", dir=destination.parent
69
+ )
70
+ try:
71
+ with os.fdopen(fd, "wb") as stream:
72
+ try:
73
+ previous = destination.lstat()
74
+ except FileNotFoundError:
75
+ previous = None
76
+ if previous is not None and stat.S_ISREG(previous.st_mode) and hasattr(os, "fchmod"):
77
+ os.fchmod(stream.fileno(), stat.S_IMODE(previous.st_mode))
78
+ stream.write(data)
79
+ stream.flush()
80
+ os.fsync(stream.fileno())
81
+ os.replace(temporary, destination)
82
+ finally:
83
+ if os.path.exists(temporary):
84
+ os.unlink(temporary)
korml/_limits.py ADDED
@@ -0,0 +1,23 @@
1
+ # ------------------------------------------------------------------------------
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ # ------------------------------------------------------------------------------
4
+
5
+ """Default resource limits for processing untrusted documents."""
6
+
7
+ MAX_INPUT_BYTES = 8 * 1024 * 1024
8
+ MAX_TOKENS = 100_000
9
+ MAX_PARSE_WORK = 1_000_000
10
+
11
+
12
+ def check_input_size(text: str, *, max_input_bytes: int | None, filename: str) -> None:
13
+ import korml.error
14
+
15
+ if max_input_bytes is not None and (
16
+ len(text) > max_input_bytes
17
+ or len(text.encode("utf-8", errors="surrogatepass")) > max_input_bytes
18
+ ):
19
+ raise korml.error.CompilerError(
20
+ message=f"input exceeds {max_input_bytes} bytes",
21
+ source_name=filename,
22
+ code="INPUT_TOO_LARGE",
23
+ )
korml/api.py ADDED
@@ -0,0 +1,292 @@
1
+ # ------------------------------------------------------------------------------
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ # ------------------------------------------------------------------------------
4
+
5
+ import collections.abc
6
+ import copy
7
+ import pathlib
8
+ import typing
9
+
10
+ import korml._atomic
11
+ import korml._limits
12
+ import korml.emitter.v0
13
+ import korml.emitter.v1
14
+ import korml.error
15
+ import korml.lexer.file
16
+ import korml.lexer.v1
17
+ import korml.model
18
+ import korml.parser.v0
19
+ import korml.parser.v1
20
+
21
+ __all__ = ["dump", "dumps", "edit", "load", "loads"]
22
+
23
+
24
+ def loads(
25
+ input_text: str,
26
+ *,
27
+ filename: str = "<string>",
28
+ debug: bool = False,
29
+ max_input_bytes: int | None = korml._limits.MAX_INPUT_BYTES,
30
+ max_tokens: int | None = korml._limits.MAX_TOKENS,
31
+ max_parse_work: int | None = korml._limits.MAX_PARSE_WORK,
32
+ ) -> korml.model.Stream[korml.lexer.v1.Token]:
33
+ korml._limits.check_input_size(input_text, max_input_bytes=max_input_bytes, filename=filename)
34
+ # A byte-order mark is an artefact of the encoding, not Korml text; it is
35
+ # dropped as YAML does, so a file saved with one reads like one without.
36
+ file = korml.lexer.file.lex(
37
+ filename=filename,
38
+ input_text=input_text.removeprefix("\ufeff"),
39
+ debug=debug,
40
+ )
41
+
42
+ if file.version is korml.model.Version.v0:
43
+ return korml.parser.v0.parse(file, max_tokens=max_tokens, max_work=max_parse_work)
44
+
45
+ if file.version is korml.model.Version.v1:
46
+ return korml.parser.v1.parse(file, max_tokens=max_tokens, max_work=max_parse_work)
47
+
48
+ raise ValueError(f"unsupported korml version: {file.version.value!r}")
49
+
50
+
51
+ def load(
52
+ filename: str | pathlib.Path,
53
+ *,
54
+ debug: bool = False,
55
+ max_input_bytes: int | None = korml._limits.MAX_INPUT_BYTES,
56
+ max_tokens: int | None = korml._limits.MAX_TOKENS,
57
+ max_parse_work: int | None = korml._limits.MAX_PARSE_WORK,
58
+ ) -> korml.model.Stream[korml.lexer.v1.Token]:
59
+ path = pathlib.Path(filename)
60
+ with path.open("rb") as stream:
61
+ data = stream.read(max_input_bytes + 1 if max_input_bytes is not None else -1)
62
+ if max_input_bytes is not None and len(data) > max_input_bytes:
63
+ raise korml.error.CompilerError(
64
+ message=f"input exceeds {max_input_bytes} bytes",
65
+ source_name=str(path),
66
+ code="INPUT_TOO_LARGE",
67
+ )
68
+ return loads(
69
+ data.decode("utf-8"),
70
+ filename=str(path),
71
+ debug=debug,
72
+ max_input_bytes=max_input_bytes,
73
+ max_tokens=max_tokens,
74
+ max_parse_work=max_parse_work,
75
+ )
76
+
77
+
78
+ def dumps(
79
+ value: object,
80
+ *,
81
+ version: korml.model.Version = korml.model.Version.v1,
82
+ ) -> str:
83
+ if version is korml.model.Version.v0:
84
+ return korml.emitter.v0.emit(_make_stream(value, version=version))
85
+
86
+ if version is korml.model.Version.v1:
87
+ return korml.emitter.v1.emit(_make_stream(value, version=version))
88
+
89
+ raise ValueError(f"unsupported korml version: {version.value!r}")
90
+
91
+
92
+ def dump(
93
+ value: object,
94
+ filename: str | pathlib.Path,
95
+ *,
96
+ version: korml.model.Version = korml.model.Version.v1,
97
+ ) -> None:
98
+ korml._atomic.write_bytes(filename, dumps(value, version=version).encode("utf-8"))
99
+
100
+
101
+ def _make_stream(
102
+ value: object,
103
+ *,
104
+ version: korml.model.Version,
105
+ ) -> korml.model.Stream[korml.lexer.v1.Token]:
106
+ if isinstance(value, korml.model.Stream):
107
+ return value
108
+
109
+ if isinstance(value, korml.model.Value):
110
+ return korml.model.Stream[korml.lexer.v1.Token](
111
+ document_array=[value],
112
+ cst=korml.model.CST(version=version),
113
+ )
114
+
115
+ return korml.model.Stream[korml.lexer.v1.Token](
116
+ document_array=[_make_value(value, version=version)],
117
+ cst=korml.model.CST(version=version),
118
+ )
119
+
120
+
121
+ def _make_value(
122
+ value: object,
123
+ *,
124
+ version: korml.model.Version,
125
+ ) -> korml.model.Value[korml.lexer.v1.Token]:
126
+ if isinstance(value, str):
127
+ return korml.model.Scalar[korml.lexer.v1.Token](
128
+ value,
129
+ cst=korml.model.CST(version=version),
130
+ )
131
+
132
+ if isinstance(value, list):
133
+ return korml.model.Sequence[korml.lexer.v1.Token](
134
+ value_array=[_make_value(child, version=version) for child in value],
135
+ cst=korml.model.CST(version=version),
136
+ )
137
+
138
+ if isinstance(value, dict):
139
+ mapping = korml.model.Mapping[korml.lexer.v1.Token](
140
+ cst=korml.model.CST(version=version),
141
+ )
142
+
143
+ for key, child in value.items():
144
+ if not isinstance(key, str):
145
+ raise TypeError("mapping keys must be strings")
146
+
147
+ mapping.add_item(key, _make_value(child, version=version))
148
+
149
+ return mapping
150
+
151
+ raise TypeError(f"cannot dump {type(value).__name__!r}")
152
+
153
+
154
+ _TRIVIA_SET = {
155
+ korml.lexer.v1.TokenType.WHITESPACE,
156
+ korml.lexer.v1.TokenType.COMMENT,
157
+ korml.lexer.v1.TokenType.NEWLINE,
158
+ }
159
+
160
+ _BLOCK_SCALAR_SET = {
161
+ korml.lexer.v1.TokenType.LITERAL_BLOCK_SCALAR,
162
+ korml.lexer.v1.TokenType.FOLDED_BLOCK_SCALAR,
163
+ }
164
+
165
+
166
+ def edit(
167
+ *,
168
+ stream: korml.model.Stream[korml.lexer.v1.Token],
169
+ replacements: collections.abc.Sequence[tuple[korml.model.Value[korml.lexer.v1.Token], str]],
170
+ ) -> str:
171
+ """
172
+ Return the source of a parsed stream with some values replaced by strings.
173
+
174
+ Each replacement pairs a value of the stream, as returned by loads or
175
+ load, with the string it becomes. Only the tokens of the replaced values
176
+ change; every comment, blank line, indentation and quoting style
177
+ elsewhere is kept, so a tool can change a value without reformatting the
178
+ file. A replaced value can be a scalar or a whole collection; the string
179
+ is always written as a double-quoted scalar, and a replaced block
180
+ collection becomes a scalar on the line of its key or dash.
181
+
182
+ The result is parsed again and must equal the original with the
183
+ replacements applied; anything else is a BugError, so an edit can never
184
+ change more than it says.
185
+ """
186
+ token_array = stream.token_array
187
+ index_map = {id(token): index for index, token in enumerate(token_array)}
188
+ path_map = _value_path_map(stream)
189
+ splice_array: list[tuple[int, int, str]] = []
190
+ path_array: list[tuple[tuple[int | str, ...], str]] = []
191
+
192
+ for value, text in replacements:
193
+ if not isinstance(text, str):
194
+ raise TypeError("replacement text must be a string")
195
+
196
+ path = path_map.get(id(value))
197
+ if path is None:
198
+ raise ValueError("replacement value is not a value of this stream")
199
+
200
+ splice_array.append(_splice_for(value, text, index_map=index_map))
201
+ path_array.append((path, text))
202
+
203
+ splice_array.sort()
204
+ for (_, previous_end, _), (start, _, _) in zip(splice_array, splice_array[1:], strict=False):
205
+ if start < previous_end:
206
+ raise ValueError("replacement values overlap")
207
+
208
+ expected = copy.deepcopy(stream.to_builtin())
209
+ for path, text in path_array:
210
+ _set_path(expected, path, text)
211
+
212
+ piece_array: list[str] = []
213
+ cursor = 0
214
+ for start, end, text in splice_array:
215
+ piece_array.extend(token.value for token in token_array[cursor:start])
216
+ piece_array.append(text)
217
+ cursor = end
218
+ piece_array.extend(token.value for token in token_array[cursor:])
219
+ source = "".join(piece_array)
220
+
221
+ if loads(source, filename=stream.filename).to_builtin() != expected:
222
+ raise korml.error.BugError(
223
+ message="edited source does not read back as the edited values",
224
+ location="korml.api.edit",
225
+ source_name=stream.filename,
226
+ )
227
+
228
+ return source
229
+
230
+
231
+ def _splice_for(
232
+ value: korml.model.Value[korml.lexer.v1.Token],
233
+ text: str,
234
+ *,
235
+ index_map: dict[int, int],
236
+ ) -> tuple[int, int, str]:
237
+ """Return the token range a replaced value covers and the text it becomes."""
238
+ content_array = [token for token in value.token_array if token.type not in _TRIVIA_SET]
239
+ if not content_array:
240
+ raise ValueError("replacement value has no tokens")
241
+
242
+ start = index_map[id(content_array[0])]
243
+ end = index_map[id(content_array[-1])] + 1
244
+ scalar = korml.emitter.v1._format_double_quoted_scalar(text)
245
+
246
+ # A block scalar token includes the line break that ends its last line;
247
+ # the quoted scalar replacing it keeps that line break.
248
+ if content_array[-1].type in _BLOCK_SCALAR_SET:
249
+ scalar += _final_line_break(content_array[-1].value)
250
+
251
+ # A block collection starts on the line after its key or dash; the scalar
252
+ # replacing it goes on that line, after one space.
253
+ block_collection = (
254
+ isinstance(value, (korml.model.Mapping, korml.model.Sequence))
255
+ and value.style is korml.model.Style.block
256
+ )
257
+ if block_collection and value.token_array:
258
+ return index_map[id(value.token_array[0])], end, " " + scalar
259
+
260
+ return start, end, scalar
261
+
262
+
263
+ def _final_line_break(value: str) -> str:
264
+ for line_break in ("\r\n", "\n", "\r"):
265
+ if value.endswith(line_break):
266
+ return line_break
267
+ return ""
268
+
269
+
270
+ def _value_path_map(
271
+ stream: korml.model.Stream[korml.lexer.v1.Token],
272
+ ) -> dict[int, tuple[int | str, ...]]:
273
+ """Map the identity of every value of a stream to its path."""
274
+ path_map: dict[int, tuple[int | str, ...]] = {}
275
+ pending: list[tuple[korml.model.Value[korml.lexer.v1.Token], tuple[int | str, ...]]] = [
276
+ (document, (index,)) for index, document in enumerate(stream.document_array)
277
+ ]
278
+ while pending:
279
+ value, path = pending.pop()
280
+ path_map[id(value)] = path
281
+ if isinstance(value, korml.model.Mapping):
282
+ pending.extend((child, (*path, key)) for key, child in value.value_map.items())
283
+ elif isinstance(value, korml.model.Sequence):
284
+ pending.extend((child, (*path, index)) for index, child in enumerate(value))
285
+ return path_map
286
+
287
+
288
+ def _set_path(builtin: list[object], path: tuple[int | str, ...], text: str) -> None:
289
+ container: typing.Any = builtin
290
+ for part in path[:-1]:
291
+ container = container[part]
292
+ container[path[-1]] = text
korml/cli/__init__.py ADDED
@@ -0,0 +1,80 @@
1
+ # --------------------------------------------------------------------------------------------------
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ # Copyright (C) 2026 Jayesh Badwaik <j.badwaik@fz-juelich.de>
4
+ # --------------------------------------------------------------------------------------------------
5
+
6
+ import argparse
7
+ import pathlib
8
+ import sys
9
+
10
+ import korml.cli.bugreport
11
+ import korml.cli.lex
12
+ import korml.error
13
+
14
+
15
+ def first_level_lexer_parser(sp: argparse.ArgumentParser) -> None:
16
+ sp.add_argument(
17
+ "--input",
18
+ required=True,
19
+ type=pathlib.Path,
20
+ help="Input korml document",
21
+ )
22
+ sp.add_argument(
23
+ "--output",
24
+ required=False,
25
+ type=pathlib.Path,
26
+ default=None,
27
+ help="Output file; standard output if omitted",
28
+ )
29
+ sp.add_argument(
30
+ "--output-format",
31
+ required=False,
32
+ choices=["json", "text"],
33
+ default="json",
34
+ )
35
+ sp.set_defaults(func=korml.cli.lex.run)
36
+
37
+
38
+ def top_level_parser() -> argparse.ArgumentParser:
39
+ parser = argparse.ArgumentParser(
40
+ prog="korml",
41
+ description="korml command line tools",
42
+ epilog="Use 'korml -h' for more information",
43
+ )
44
+
45
+ subparser = parser.add_subparsers(title="subcommand", dest="subcommand", required=True)
46
+
47
+ lex_sp = subparser.add_parser("lex", help="Lex a korml document and print the tokens")
48
+ first_level_lexer_parser(lex_sp)
49
+
50
+ return parser
51
+
52
+
53
+ def main() -> int:
54
+ parser = top_level_parser()
55
+ args = parser.parse_args(None if len(sys.argv) > 1 else ["-h"])
56
+
57
+ try:
58
+ args.func(args)
59
+ return 0
60
+ except KeyboardInterrupt:
61
+ print("error: interrupted by user", file=sys.stderr)
62
+ return 130
63
+ except korml.error.BugError as exc:
64
+ return handle_uncaught_exception(exc)
65
+ except korml.error.CompilerError as exc:
66
+ print(str(exc), file=sys.stderr)
67
+ return 1
68
+ except OSError as exc:
69
+ filename = korml.error.escape_controls(str(exc.filename))
70
+ reason = korml.error.escape_controls(str(exc.strerror))
71
+ print(f"error: {filename}: {reason}", file=sys.stderr)
72
+ return 1
73
+ except Exception as exc:
74
+ return handle_uncaught_exception(exc)
75
+
76
+
77
+ def handle_uncaught_exception(exc: Exception) -> int:
78
+ report = korml.cli.bugreport.render_internal_failure_report(exc=exc, stream=sys.stderr)
79
+ print(report, file=sys.stderr)
80
+ return korml.cli.bugreport.DiagnosticExitCode.INTERNAL_ERROR
korml/cli/bugreport.py ADDED
@@ -0,0 +1,147 @@
1
+ # --------------------------------------------------------------------------------------------------
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ # Copyright (C) 2026 Jayesh Badwaik <j.badwaik@fz-juelich.de>
4
+ # --------------------------------------------------------------------------------------------------
5
+
6
+ import dataclasses
7
+ import enum
8
+ import importlib.metadata
9
+ import traceback
10
+ import typing
11
+
12
+
13
+ class AnsiCode(enum.Enum):
14
+ RESET = "\033[0m"
15
+ BOLD = "\033[1m"
16
+ RED = "\033[31m"
17
+ YELLOW = "\033[33m"
18
+ CYAN = "\033[36m"
19
+
20
+
21
+ class DiagnosticExitCode(enum.IntEnum):
22
+ # sysexits.h: an internal software error. argparse uses 2 for invalid
23
+ # usage, so an internal error needs another status.
24
+ INTERNAL_ERROR = 70
25
+
26
+
27
+ @dataclasses.dataclass(frozen=True)
28
+ class ColorPolicy:
29
+ enabled: bool
30
+
31
+ @classmethod
32
+ def from_stream(cls, stream: typing.Any) -> "ColorPolicy":
33
+ enabled = hasattr(stream, "isatty") and stream.isatty()
34
+ return cls(enabled=enabled)
35
+
36
+
37
+ @dataclasses.dataclass(frozen=True)
38
+ class TextStyler:
39
+ color_policy: ColorPolicy
40
+
41
+ def style(self, text: str, *styles: AnsiCode) -> str:
42
+ if not self.color_policy.enabled:
43
+ return text
44
+
45
+ prefix = "".join(style.value for style in styles)
46
+ return prefix + text + AnsiCode.RESET.value
47
+
48
+
49
+ # The issue tracker when korml runs without installed package metadata.
50
+ _FALLBACK_ISSUES_URL = "https://github.com/seanergys/pykorml/issues"
51
+
52
+
53
+ def issues_url() -> str:
54
+ """
55
+ Return the issue tracker named by the project URLs in pyproject.toml.
56
+
57
+ That is the Issues URL if there is one, otherwise the issues page of the
58
+ Source URL, so the bug report and the package metadata never disagree.
59
+ Without installed metadata, it is github.com/seanergys/pykorml/issues.
60
+ """
61
+ try:
62
+ metadata = importlib.metadata.metadata("korml")
63
+ except importlib.metadata.PackageNotFoundError:
64
+ return _FALLBACK_ISSUES_URL
65
+
66
+ url_map = {}
67
+ for entry in metadata.get_all("Project-URL") or []:
68
+ label, _, url = entry.partition(",")
69
+ url_map[label.strip().lower()] = url.strip().rstrip("/")
70
+
71
+ if "issues" in url_map:
72
+ return url_map["issues"]
73
+
74
+ source = url_map.get("source")
75
+ if source is None:
76
+ return _FALLBACK_ISSUES_URL
77
+
78
+ if "gitlab" in source:
79
+ return f"{source}/-/issues"
80
+
81
+ return f"{source}/issues"
82
+
83
+
84
+ @dataclasses.dataclass(frozen=True)
85
+ class BugReportConfig:
86
+ url: str = dataclasses.field(default_factory=issues_url)
87
+
88
+
89
+ @dataclasses.dataclass(frozen=True)
90
+ class InternalFailureReport:
91
+ exc: Exception
92
+ styler: TextStyler
93
+ config: BugReportConfig = dataclasses.field(default_factory=BugReportConfig)
94
+
95
+ def render(self) -> str:
96
+ return "\n".join(
97
+ [
98
+ self.styler.style(
99
+ "========================================", AnsiCode.RED, AnsiCode.BOLD
100
+ ),
101
+ self.styler.style("ERROR: KORML INTERNAL FAILURE", AnsiCode.RED, AnsiCode.BOLD),
102
+ self.styler.style(
103
+ "========================================", AnsiCode.RED, AnsiCode.BOLD
104
+ ),
105
+ "",
106
+ self.styler.style("KORML encountered an unexpected internal error.", AnsiCode.BOLD),
107
+ "This usually indicates a bug in KORML rather than an error in your input.",
108
+ "",
109
+ self.styler.style(
110
+ f"If you would like to report this, please open an issue at: {self.config.url}",
111
+ AnsiCode.YELLOW,
112
+ ),
113
+ "",
114
+ "A bug report is most helpful when it includes:",
115
+ "",
116
+ " • a short issue title, for example:",
117
+ " [BUG] <short description>",
118
+ "",
119
+ " • a minimal working example (MWE), including:",
120
+ " - the smallest input that reproduces the problem",
121
+ " - the exact command used to trigger it",
122
+ "",
123
+ " • the diagnostic report below",
124
+ "",
125
+ "You may copy and paste the following section into the issue if useful.",
126
+ "",
127
+ self.styler.style(
128
+ "----- BEGIN KORML BUG REPORT -----", AnsiCode.CYAN, AnsiCode.BOLD
129
+ ),
130
+ self.styler.style("## Error Summary", AnsiCode.CYAN, AnsiCode.BOLD),
131
+ f"- Exception type: {type(self.exc).__name__}",
132
+ f"- Exception message: {self.exc}",
133
+ "",
134
+ self.styler.style("## Traceback", AnsiCode.CYAN, AnsiCode.BOLD),
135
+ "```text",
136
+ traceback.format_exc().rstrip(),
137
+ "```",
138
+ self.styler.style("----- END KORML BUG REPORT -----", AnsiCode.CYAN, AnsiCode.BOLD),
139
+ ]
140
+ )
141
+
142
+
143
+ def render_internal_failure_report(exc: Exception, stream: typing.Any) -> str:
144
+ color_policy = ColorPolicy.from_stream(stream)
145
+ styler = TextStyler(color_policy=color_policy)
146
+ report = InternalFailureReport(exc=exc, styler=styler)
147
+ return report.render()