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 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)
@@ -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}")