tomlclass 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.
- tomlclass/__init__.py +109 -0
- tomlclass/document.py +195 -0
- tomlclass/engine_set.py +146 -0
- tomlclass/errors.py +116 -0
- tomlclass/nodes.py +313 -0
- tomlclass/parser.py +792 -0
- tomlclass/py.typed +0 -0
- tomlclass/render.py +205 -0
- tomlclass/schema.py +742 -0
- tomlclass-0.1.0.dist-info/METADATA +139 -0
- tomlclass-0.1.0.dist-info/RECORD +13 -0
- tomlclass-0.1.0.dist-info/WHEEL +4 -0
- tomlclass-0.1.0.dist-info/licenses/LICENSE +21 -0
tomlclass/__init__.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tomlclass — annotation-driven TOML configuration with comment preservation.
|
|
3
|
+
|
|
4
|
+
Declare configuration as annotated classes; tomlclass renders commented TOML
|
|
5
|
+
templates, validates and fills values, and updates existing TOML files in
|
|
6
|
+
place while keeping user comments, ordering and formatting intact.
|
|
7
|
+
|
|
8
|
+
Public API:
|
|
9
|
+
|
|
10
|
+
- :func:`parse` / :func:`loads` — parse TOML text into a :class:`Document`
|
|
11
|
+
- :func:`load` — parse a TOML file
|
|
12
|
+
- :func:`dumps` — render a :class:`Document` back to text
|
|
13
|
+
- :class:`Document` — lossless CST document with mapping access
|
|
14
|
+
and atomic :meth:`Document.save`
|
|
15
|
+
- :class:`Config` / :class:`Field` — the annotation layer
|
|
16
|
+
- :class:`TOMLError` hierarchy — TOMLParseError / TOMLTypeError / ConfigError
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from .document import Document
|
|
23
|
+
from .errors import (
|
|
24
|
+
ConfigError,
|
|
25
|
+
FieldError,
|
|
26
|
+
TOMLError,
|
|
27
|
+
TOMLParseError,
|
|
28
|
+
TOMLTypeError,
|
|
29
|
+
ValidationError,
|
|
30
|
+
)
|
|
31
|
+
from .nodes import Array, ArrayOfTables, InlineTable, Table
|
|
32
|
+
from .parser import parse_source
|
|
33
|
+
from .schema import Config, Field
|
|
34
|
+
|
|
35
|
+
__version__ = "0.1.0"
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"Array",
|
|
39
|
+
"ArrayOfTables",
|
|
40
|
+
"Config",
|
|
41
|
+
"ConfigError",
|
|
42
|
+
"Document",
|
|
43
|
+
"Field",
|
|
44
|
+
"FieldError",
|
|
45
|
+
"InlineTable",
|
|
46
|
+
"TOMLError",
|
|
47
|
+
"TOMLParseError",
|
|
48
|
+
"TOMLTypeError",
|
|
49
|
+
"Table",
|
|
50
|
+
"ValidationError",
|
|
51
|
+
"__version__",
|
|
52
|
+
"dumps",
|
|
53
|
+
"load",
|
|
54
|
+
"loads",
|
|
55
|
+
"parse",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def parse(source: str) -> Document:
|
|
60
|
+
"""
|
|
61
|
+
Parse TOML text into a :class:`Document`.
|
|
62
|
+
|
|
63
|
+
:param source: TOML text (must be a valid TOML 1.0 document)
|
|
64
|
+
:raises TOMLParseError: on any syntax or semantic error, with ``line`` /
|
|
65
|
+
``col`` / ``offset`` / ``segment`` attached
|
|
66
|
+
"""
|
|
67
|
+
root, parts = parse_source(source)
|
|
68
|
+
return Document(source, root, parts)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def loads(source: str) -> dict[str, Any]:
|
|
72
|
+
"""
|
|
73
|
+
Parse TOML text into a plain dict — stdlib :mod:`tomllib` compatible.
|
|
74
|
+
|
|
75
|
+
For lossless editing (comments, ordering, formatting) use :func:`parse`,
|
|
76
|
+
which returns a :class:`Document`.
|
|
77
|
+
|
|
78
|
+
:param source: TOML text
|
|
79
|
+
:raises TOMLParseError: on any syntax or semantic error
|
|
80
|
+
"""
|
|
81
|
+
return parse(source).to_dict()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def load(path: Any) -> Any:
|
|
85
|
+
"""
|
|
86
|
+
Parse TOML — dual-mode entry point.
|
|
87
|
+
|
|
88
|
+
- *path* (``str`` / ``os.PathLike``) → :class:`Document` — lossless,
|
|
89
|
+
editable, atomic :meth:`Document.save`
|
|
90
|
+
- binary file object (``open(path, "rb")``) → plain nested ``dict`` —
|
|
91
|
+
stdlib :mod:`tomllib` compatible
|
|
92
|
+
|
|
93
|
+
:raises TOMLParseError: on any syntax or semantic error
|
|
94
|
+
"""
|
|
95
|
+
if hasattr(path, "read"):
|
|
96
|
+
data = path.read()
|
|
97
|
+
text = data.decode("utf-8") if isinstance(data, bytes) else data
|
|
98
|
+
return parse(text).to_dict()
|
|
99
|
+
return parse(Path(path).read_text(encoding="utf-8"))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def dumps(doc: Document) -> str:
|
|
103
|
+
"""
|
|
104
|
+
Render a :class:`Document` back to text.
|
|
105
|
+
|
|
106
|
+
Unchanged documents round-trip byte-exactly; edited parts re-render with
|
|
107
|
+
canonical formatting while everything else stays untouched.
|
|
108
|
+
"""
|
|
109
|
+
return doc.dumps()
|
tomlclass/document.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The parsed document object.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import contextlib
|
|
8
|
+
import os
|
|
9
|
+
import tempfile
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .nodes import ArrayOfTables, Table
|
|
15
|
+
from .render import render_document
|
|
16
|
+
|
|
17
|
+
__all__ = ["Document", "atomic_write_text"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def atomic_write_text(path: str | os.PathLike[str], text: str) -> None:
|
|
21
|
+
"""
|
|
22
|
+
Atomically write *text* to *path* (same-dir tempfile + ``os.replace``).
|
|
23
|
+
"""
|
|
24
|
+
target = Path(path)
|
|
25
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
fd, tmp = tempfile.mkstemp(dir=target.parent, prefix=target.name + ".", suffix=".tmp")
|
|
27
|
+
try:
|
|
28
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="") as f:
|
|
29
|
+
f.write(text)
|
|
30
|
+
Path(tmp).replace(target)
|
|
31
|
+
except BaseException:
|
|
32
|
+
with contextlib.suppress(OSError):
|
|
33
|
+
Path(tmp).unlink()
|
|
34
|
+
raise
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Document:
|
|
38
|
+
"""
|
|
39
|
+
A parsed TOML document: lossless CST + mapping access + atomic save.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, source: str, root: Table, parts: list[tuple[int, int, str, Any]]) -> None:
|
|
43
|
+
self._source = source
|
|
44
|
+
self.root = root
|
|
45
|
+
self._parts = parts
|
|
46
|
+
|
|
47
|
+
# -- mapping passthrough --------------------------------------------- #
|
|
48
|
+
|
|
49
|
+
def __getitem__(self, key: str) -> Any:
|
|
50
|
+
return self.root[key]
|
|
51
|
+
|
|
52
|
+
def __setitem__(self, key: str, value: Any) -> None:
|
|
53
|
+
self.root[key] = value
|
|
54
|
+
|
|
55
|
+
def __delitem__(self, key: str) -> None:
|
|
56
|
+
del self.root[key]
|
|
57
|
+
|
|
58
|
+
def __contains__(self, key: object) -> bool:
|
|
59
|
+
return key in self.root
|
|
60
|
+
|
|
61
|
+
def __iter__(self) -> Iterator[str]:
|
|
62
|
+
return iter(self.root)
|
|
63
|
+
|
|
64
|
+
def __len__(self) -> int:
|
|
65
|
+
return len(self.root)
|
|
66
|
+
|
|
67
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
68
|
+
return self.root.get(key, default)
|
|
69
|
+
|
|
70
|
+
# -- rendering --------------------------------------------------------- #
|
|
71
|
+
|
|
72
|
+
def dumps(self) -> str:
|
|
73
|
+
"""
|
|
74
|
+
Render the document. Unchanged documents round-trip byte-exactly.
|
|
75
|
+
"""
|
|
76
|
+
return render_document(self._source, self._parts, self.root)
|
|
77
|
+
|
|
78
|
+
def save(self, path: str | os.PathLike[str]) -> None:
|
|
79
|
+
"""
|
|
80
|
+
Render and atomically write to *path* (tempfile + ``os.replace``).
|
|
81
|
+
"""
|
|
82
|
+
atomic_write_text(path, self.dumps())
|
|
83
|
+
|
|
84
|
+
# -- navigation / conversion ------------------------------------------ #
|
|
85
|
+
|
|
86
|
+
def find(self, dotted_path: str) -> Any:
|
|
87
|
+
"""
|
|
88
|
+
Return the value at *dotted_path* (``"a.b.c"``), or ``None``.
|
|
89
|
+
"""
|
|
90
|
+
node: Any = self.root
|
|
91
|
+
for part in dotted_path.split("."):
|
|
92
|
+
if isinstance(node, Table) and part in node:
|
|
93
|
+
node = node[part]
|
|
94
|
+
else:
|
|
95
|
+
return None
|
|
96
|
+
return node
|
|
97
|
+
|
|
98
|
+
def to_dict(self) -> dict[str, Any]:
|
|
99
|
+
"""
|
|
100
|
+
Convert to plain nested dicts (datetimes stay as-is).
|
|
101
|
+
"""
|
|
102
|
+
return _to_plain(self.root)
|
|
103
|
+
|
|
104
|
+
# -- comment API -------------------------------------------------------- #
|
|
105
|
+
|
|
106
|
+
def comment(self, dotted_path: str) -> str | None:
|
|
107
|
+
"""
|
|
108
|
+
Return the comment trailing *dotted_path* (without ``#``), or None.
|
|
109
|
+
|
|
110
|
+
Comments belong to the key they trail; table-level comments live on
|
|
111
|
+
the header line and are addressed by the table's path.
|
|
112
|
+
"""
|
|
113
|
+
parts = dotted_path.split(".")
|
|
114
|
+
block = self._block_for(parts[:-1])
|
|
115
|
+
if block is None:
|
|
116
|
+
return None
|
|
117
|
+
located = self._block_for(parts)
|
|
118
|
+
if located is None:
|
|
119
|
+
return None
|
|
120
|
+
block, phys = located
|
|
121
|
+
if phys in block.comments: # set_comment override wins
|
|
122
|
+
return block.comments[phys]
|
|
123
|
+
span = block.entry_spans.get(phys)
|
|
124
|
+
if span is None:
|
|
125
|
+
return None
|
|
126
|
+
_vs, _ve, cs = span
|
|
127
|
+
line_end = self._line_end(cs)
|
|
128
|
+
text = self._source[cs:line_end].strip()
|
|
129
|
+
if not text.startswith("#"):
|
|
130
|
+
return None
|
|
131
|
+
return text[1:].lstrip() or None
|
|
132
|
+
|
|
133
|
+
def set_comment(self, dotted_path: str, comment: str | None) -> None:
|
|
134
|
+
"""
|
|
135
|
+
Replace (or, with ``None``, remove) the comment trailing *dotted_path*.
|
|
136
|
+
"""
|
|
137
|
+
parts = dotted_path.split(".")
|
|
138
|
+
located = self._block_for(parts)
|
|
139
|
+
if located is None:
|
|
140
|
+
raise KeyError(dotted_path)
|
|
141
|
+
block, phys = located
|
|
142
|
+
if phys not in block.entry_spans:
|
|
143
|
+
raise KeyError(dotted_path)
|
|
144
|
+
block.comments[phys] = comment
|
|
145
|
+
|
|
146
|
+
def _block_for(self, parts: list[str]) -> tuple[Table, tuple[str, ...]] | None:
|
|
147
|
+
"""
|
|
148
|
+
Resolve *parts* to (hosting block, block-relative path).
|
|
149
|
+
"""
|
|
150
|
+
node: Any = self.root
|
|
151
|
+
consumed: list[str] = []
|
|
152
|
+
for part in parts:
|
|
153
|
+
child = node[part] if isinstance(node, Table) and part in node else None
|
|
154
|
+
if isinstance(child, Table):
|
|
155
|
+
node = child
|
|
156
|
+
consumed.append(part)
|
|
157
|
+
else:
|
|
158
|
+
# leaf value: the current table hosts the key; the rest of the path is the dotted key
|
|
159
|
+
return node, tuple(parts[len(consumed):])
|
|
160
|
+
return node, ()
|
|
161
|
+
|
|
162
|
+
def _line_end(self, offset: int) -> int:
|
|
163
|
+
nxt = self._source.find(chr(10), offset)
|
|
164
|
+
return nxt if nxt != -1 else len(self._source)
|
|
165
|
+
|
|
166
|
+
def __repr__(self) -> str:
|
|
167
|
+
return f"Document({dict.__repr__(self.root)})"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _to_plain(table: Table) -> dict[str, Any]:
|
|
171
|
+
out: dict[str, Any] = {}
|
|
172
|
+
for key in table:
|
|
173
|
+
value = table[key]
|
|
174
|
+
if isinstance(value, Table):
|
|
175
|
+
out[key] = _to_plain(value)
|
|
176
|
+
elif isinstance(value, (ArrayOfTables, list)):
|
|
177
|
+
out[key] = [_to_plain_value(v) for v in value]
|
|
178
|
+
else:
|
|
179
|
+
out[key] = value
|
|
180
|
+
return out
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _to_plain_value(value: Any) -> Any:
|
|
184
|
+
if isinstance(value, Table):
|
|
185
|
+
return _to_plain(value)
|
|
186
|
+
if isinstance(value, (ArrayOfTables, list)):
|
|
187
|
+
return [_to_plain_value(v) for v in value]
|
|
188
|
+
return value
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def plain_dict(doc: Document) -> dict[str, Any]:
|
|
192
|
+
"""
|
|
193
|
+
tomllib-style plain-dict conversion of a whole :class:`Document`.
|
|
194
|
+
"""
|
|
195
|
+
return _to_plain(doc.root)
|
tomlclass/engine_set.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Edit hooks backing :meth:`Table.__setitem__` / :meth:`Table.__delitem__`.
|
|
3
|
+
|
|
4
|
+
Assignments do not mutate rendered text directly; they register the change on
|
|
5
|
+
the *physical block* that hosts the entry, and the renderer picks it up:
|
|
6
|
+
|
|
7
|
+
- ``block.dirty[path]`` — edited entries, spliced into their line
|
|
8
|
+
- ``block.pending[path]`` — new entries, rendered after the block's last
|
|
9
|
+
parsed entry (values assigned to existing keys only go to ``dirty``)
|
|
10
|
+
- ``block.pending_tables`` — newly created ``[sub]`` tables
|
|
11
|
+
- ``block.deleted[path]`` — entries removed via ``del``
|
|
12
|
+
|
|
13
|
+
Validation happens eagerly: values that cannot be represented in TOML raise
|
|
14
|
+
:class:`TOMLTypeError` at assignment time, not at render time.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from .errors import TOMLTypeError
|
|
22
|
+
from .nodes import ArrayOfTables, Table, format_value
|
|
23
|
+
|
|
24
|
+
__all__: list[str] = []
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def engine_set(table: Table, rel: tuple[str, ...], value: Any) -> Any:
|
|
28
|
+
"""
|
|
29
|
+
Register the edit; return the object that must be stored in the tree.
|
|
30
|
+
"""
|
|
31
|
+
if table.inline:
|
|
32
|
+
path = table._phys + rel
|
|
33
|
+
raise TOMLTypeError(
|
|
34
|
+
"inline tables are closed; reassign the whole value instead",
|
|
35
|
+
path=path,
|
|
36
|
+
value=value,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# new keys on sealed (dotted) tables render as dotted lines anchored to
|
|
40
|
+
# their own last physical line — register on the sealed table itself
|
|
41
|
+
if table.sealed and table._block is not None and rel[0] not in table:
|
|
42
|
+
table.pending.append((rel, value))
|
|
43
|
+
table.deleted.discard(rel)
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
block = table._block if table._block is not None else table
|
|
47
|
+
path = table._phys + rel
|
|
48
|
+
|
|
49
|
+
if isinstance(value, Table):
|
|
50
|
+
if value._block is not None or value.kind == "root":
|
|
51
|
+
raise TOMLTypeError(
|
|
52
|
+
"cannot reassign a table that already belongs to a document",
|
|
53
|
+
path=path,
|
|
54
|
+
value=value,
|
|
55
|
+
)
|
|
56
|
+
# new [sub]table: register a pending header block
|
|
57
|
+
value._block = block
|
|
58
|
+
value._phys = path
|
|
59
|
+
value.kind = "header"
|
|
60
|
+
block.pending_tables.append((path, value))
|
|
61
|
+
block.deleted.discard(path)
|
|
62
|
+
return value
|
|
63
|
+
|
|
64
|
+
if isinstance(value, dict):
|
|
65
|
+
# plain dicts become [table] blocks (use InlineTable explicitly for inline)
|
|
66
|
+
from .nodes import Table as _Table
|
|
67
|
+
|
|
68
|
+
sub = _Table(kind="header")
|
|
69
|
+
for k, v in value.items():
|
|
70
|
+
sub[k] = v
|
|
71
|
+
block.pending_tables.append((path, sub))
|
|
72
|
+
block.deleted.discard(path)
|
|
73
|
+
return sub
|
|
74
|
+
|
|
75
|
+
existing = table._raw_get(rel[0]) if len(rel) == 1 else None
|
|
76
|
+
if isinstance(existing, ArrayOfTables) and isinstance(value, list):
|
|
77
|
+
# whole replacement: old elements stop rendering,
|
|
78
|
+
# new elements render as [[...]] blocks and are immediately readable
|
|
79
|
+
|
|
80
|
+
for element in list(existing):
|
|
81
|
+
if isinstance(element, Table):
|
|
82
|
+
mark_dead(element)
|
|
83
|
+
for item in value:
|
|
84
|
+
if not isinstance(item, dict):
|
|
85
|
+
raise TOMLTypeError(
|
|
86
|
+
"array-of-tables replacement accepts dicts only",
|
|
87
|
+
path=path,
|
|
88
|
+
value=item,
|
|
89
|
+
)
|
|
90
|
+
list.clear(existing)
|
|
91
|
+
for item in value:
|
|
92
|
+
list.append(existing, dict(item))
|
|
93
|
+
existing.pending_elements = [dict(item) for item in value]
|
|
94
|
+
block.deleted.discard(path)
|
|
95
|
+
return value
|
|
96
|
+
|
|
97
|
+
# eager serializability check — None, functions, arbitrary objects raise here
|
|
98
|
+
format_value(value)
|
|
99
|
+
block.dirty[path] = value
|
|
100
|
+
block.deleted.discard(path)
|
|
101
|
+
if path not in block.entry_spans:
|
|
102
|
+
block.pending.append((path, value))
|
|
103
|
+
return value
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def engine_del(table: Table, rel: tuple[str, ...]) -> None:
|
|
107
|
+
if table.inline:
|
|
108
|
+
path = table._phys + rel
|
|
109
|
+
raise TOMLTypeError(
|
|
110
|
+
"inline tables are closed; reassign the whole value instead",
|
|
111
|
+
path=path,
|
|
112
|
+
value=None,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# engine-created keys on sealed tables: drop from their own pending
|
|
116
|
+
if table.sealed and table._block is not None and rel[0] not in table:
|
|
117
|
+
table.pending = [(p, v) for (p, v) in table.pending if p != rel]
|
|
118
|
+
table.deleted.discard(rel)
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
block = table._block if table._block is not None else table
|
|
122
|
+
path = table._phys + rel
|
|
123
|
+
target = table._raw_get(rel[0])
|
|
124
|
+
|
|
125
|
+
if isinstance(target, ArrayOfTables):
|
|
126
|
+
for element in target:
|
|
127
|
+
if isinstance(element, Table):
|
|
128
|
+
mark_dead(element)
|
|
129
|
+
elif isinstance(target, Table):
|
|
130
|
+
mark_dead(target)
|
|
131
|
+
|
|
132
|
+
if path in block.entry_spans:
|
|
133
|
+
block.deleted.add(path)
|
|
134
|
+
block.dirty.pop(path, None)
|
|
135
|
+
block.pending = [(p, v) for (p, v) in block.pending if p != path]
|
|
136
|
+
block.pending_tables = [(p, t) for (p, t) in block.pending_tables if p != path]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def mark_dead(table: Table) -> None:
|
|
140
|
+
"""
|
|
141
|
+
Mark a parsed table subtree dead — its parts render as nothing.
|
|
142
|
+
"""
|
|
143
|
+
table.dead = True
|
|
144
|
+
for value in table.values():
|
|
145
|
+
if isinstance(value, Table):
|
|
146
|
+
mark_dead(value)
|
tomlclass/errors.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tomlclass exception hierarchy.
|
|
3
|
+
|
|
4
|
+
Integrates with stdlib conventions:
|
|
5
|
+
|
|
6
|
+
- :class:`TOMLParseError` subclasses :class:`ValueError` (the same contract as
|
|
7
|
+
:class:`tomllib.TOMLDecodeError`), so ``except ValueError`` keeps working.
|
|
8
|
+
- :class:`TOMLTypeError` subclasses :class:`TypeError` — raised when a value
|
|
9
|
+
cannot be represented in TOML (``None``, functions, arbitrary objects …).
|
|
10
|
+
- :class:`ConfigError` / :class:`ValidationError` power the annotation layer;
|
|
11
|
+
:class:`ValidationError` aggregates *all* offending fields instead of
|
|
12
|
+
failing on the first one.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TOMLError(Exception):
|
|
21
|
+
"""
|
|
22
|
+
Base class for every error raised by tomlclass.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TOMLParseError(TOMLError, ValueError):
|
|
27
|
+
"""
|
|
28
|
+
A TOML document failed to parse.
|
|
29
|
+
|
|
30
|
+
Also a :class:`ValueError` (stdlib convention, mirroring
|
|
31
|
+
:class:`tomllib.TOMLDecodeError`).
|
|
32
|
+
|
|
33
|
+
:param message: human-readable failure description
|
|
34
|
+
:param offset: character offset into the source (``None`` for semantic errors)
|
|
35
|
+
:param line: 1-based line number (``None`` for semantic errors)
|
|
36
|
+
:param col: 1-based column number (``None`` for semantic errors)
|
|
37
|
+
:param segment: the offending source line, trimmed
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
message: str,
|
|
43
|
+
offset: int | None = None,
|
|
44
|
+
line: int | None = None,
|
|
45
|
+
col: int | None = None,
|
|
46
|
+
segment: str = "",
|
|
47
|
+
) -> None:
|
|
48
|
+
if line is not None:
|
|
49
|
+
loc = f" (line {line}, column {col})"
|
|
50
|
+
body = f"{message}{loc}:\n {segment}"
|
|
51
|
+
else:
|
|
52
|
+
body = message
|
|
53
|
+
super().__init__(body)
|
|
54
|
+
self.message = message
|
|
55
|
+
self.offset = offset
|
|
56
|
+
self.line = line
|
|
57
|
+
self.col = col
|
|
58
|
+
self.segment = segment
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TOMLTypeError(TOMLError, TypeError):
|
|
62
|
+
"""
|
|
63
|
+
A value cannot be represented in TOML.
|
|
64
|
+
|
|
65
|
+
:param message: human-readable description
|
|
66
|
+
:param path: dotted key path the value was assigned to, if known
|
|
67
|
+
:param value: the offending value
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, message: str, *, path: tuple[str, ...] | None = None, value: Any = None) -> None:
|
|
71
|
+
loc = f" at '{'.'.join(path)}'" if path else ""
|
|
72
|
+
super().__init__(f"{message}{loc}")
|
|
73
|
+
self.message = message
|
|
74
|
+
self.path = path
|
|
75
|
+
self.value = value
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class FieldError:
|
|
79
|
+
"""
|
|
80
|
+
A single schema violation.
|
|
81
|
+
|
|
82
|
+
:param path: dotted key path of the offending field
|
|
83
|
+
:param message: human-readable description
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
__slots__ = ("message", "path")
|
|
87
|
+
|
|
88
|
+
def __init__(self, path: str, message: str) -> None:
|
|
89
|
+
self.path = path
|
|
90
|
+
self.message = message
|
|
91
|
+
|
|
92
|
+
def __str__(self) -> str:
|
|
93
|
+
return f"{self.path}: {self.message}"
|
|
94
|
+
|
|
95
|
+
def __repr__(self) -> str:
|
|
96
|
+
return f"FieldError({self.path!r}, {self.message!r})"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class ConfigError(TOMLError, ValueError):
|
|
100
|
+
"""
|
|
101
|
+
Base class for annotation-layer errors.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class ValidationError(ConfigError):
|
|
106
|
+
"""
|
|
107
|
+
Schema validation failed; aggregates every offending field.
|
|
108
|
+
|
|
109
|
+
:param errors: list of :class:`FieldError`
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def __init__(self, errors: list[FieldError]) -> None:
|
|
113
|
+
self.errors = errors
|
|
114
|
+
summary = "; ".join(str(e) for e in errors[:5])
|
|
115
|
+
more = f" (+{len(errors) - 5} more)" if len(errors) > 5 else ""
|
|
116
|
+
super().__init__(f"{len(errors)} validation error(s): {summary}{more}")
|