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 +45 -0
- korml/_atomic.py +84 -0
- korml/_limits.py +23 -0
- korml/api.py +292 -0
- korml/cli/__init__.py +80 -0
- korml/cli/bugreport.py +147 -0
- korml/cli/lex.py +70 -0
- korml/core/__init__.py +199 -0
- korml/emitter/__init__.py +8 -0
- korml/emitter/v0/__init__.py +34 -0
- korml/emitter/v1/__init__.py +243 -0
- korml/error/__init__.py +429 -0
- korml/lexer/__init__.py +8 -0
- korml/lexer/file.py +97 -0
- korml/lexer/v0/__init__.py +21 -0
- korml/lexer/v0/file/__init__.py +169 -0
- korml/lexer/v1/__init__.py +117 -0
- korml/lexer/v1/file/__init__.py +1674 -0
- korml/metadata.py +7 -0
- korml/model.py +351 -0
- korml/parser/__init__.py +8 -0
- korml/parser/v0/__init__.py +54 -0
- korml/parser/v1/__init__.py +1217 -0
- korml/schema/__init__.py +71 -0
- korml/schema/v1/__init__.py +757 -0
- korml-0.1.0.data/data/share/korml/grammars/1.0/lexer.ebnf +160 -0
- korml-0.1.0.data/data/share/korml/grammars/1.0/parser.ebnf +146 -0
- korml-0.1.0.data/data/share/korml/grammars/1.0/tokenstream.schema +299 -0
- korml-0.1.0.data/data/share/korml/schema/v1/schema.korml +94 -0
- korml-0.1.0.dist-info/METADATA +36 -0
- korml-0.1.0.dist-info/RECORD +33 -0
- korml-0.1.0.dist-info/WHEEL +4 -0
- korml-0.1.0.dist-info/entry_points.txt +2 -0
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()
|