tomlstack 0.1.2__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.
tomlstack/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .api import TomlStack, load
2
+
3
+ __all__ = ["TomlStack", "load"]
tomlstack/_version.py ADDED
@@ -0,0 +1,34 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
12
+
13
+ TYPE_CHECKING = False
14
+ if TYPE_CHECKING:
15
+ from typing import Tuple
16
+ from typing import Union
17
+
18
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
20
+ else:
21
+ VERSION_TUPLE = object
22
+ COMMIT_ID = object
23
+
24
+ version: str
25
+ __version__: str
26
+ __version_tuple__: VERSION_TUPLE
27
+ version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
30
+
31
+ __version__ = version = '0.1.2'
32
+ __version_tuple__ = version_tuple = (0, 1, 2)
33
+
34
+ __commit_id__ = commit_id = None
tomlstack/api.py ADDED
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from .base import PathHist, PathKey
8
+ from .interpolate import resolve_interpolations
9
+ from .loader import LoadResult, load_toml_with_includes
10
+ from .nodes import Node
11
+ from .path_expr import get_by_path
12
+
13
+
14
+ @dataclass
15
+ class TomlStack:
16
+ _raw_data: dict[str, Any]
17
+ _resolved_data: dict[str, Any] | None
18
+ _history: dict[PathKey, list[PathHist]]
19
+
20
+ # TODO: OK?
21
+ @property
22
+ def view(self) -> dict[str, Any]:
23
+ if self._resolved_data is None:
24
+ return self._raw_data
25
+ else:
26
+ return self._resolved_data
27
+
28
+ def resolve(self) -> TomlStack:
29
+ if self._resolved_data is None:
30
+ self._resolved_data = resolve_interpolations(self._raw_data)
31
+ return self
32
+
33
+ def to_dict(self, resolve: bool = True) -> dict[str, Any]:
34
+ if resolve:
35
+ if self._resolved_data is None:
36
+ self.resolve()
37
+ assert self._resolved_data is not None
38
+ return self._resolved_data
39
+ return self._raw_data
40
+
41
+ def to_toml(self) -> str:
42
+ raise NotImplementedError("to_toml is reserved for future implementation")
43
+
44
+ def __getitem__(self, key: str) -> Node:
45
+ if key not in self._raw_data:
46
+ raise KeyError(key)
47
+ return Node(self, (key,))
48
+
49
+ def _get_raw(self, path: PathKey) -> Any:
50
+ return get_by_path(self._raw_data, path)
51
+
52
+ def _get_value(self, path: PathKey) -> Any:
53
+ if self._resolved_data is None:
54
+ self.resolve()
55
+ assert self._resolved_data is not None
56
+ return get_by_path(self._resolved_data, path)
57
+
58
+ def include_tree(self, level: int = 0, absolute: bool = False): ...
59
+
60
+ # history[()]可以记录整个文件的层级, 可以导出文件层级, 形如
61
+ #
62
+ # main.toml
63
+ # ├─ ./a.toml
64
+ # │ └─ @root/base.toml
65
+ # ├─ /abs/path/c.toml
66
+ # │ └─ @root/base.toml
67
+ # └─ ./b.toml
68
+ # └─ @root/base.toml
69
+ #
70
+ # main.toml -> /abs/project/main.toml
71
+ # ├─ ./a.toml -> /abs/path/a.toml
72
+ # │ └─ @root/base.toml -> /abs/shared/base.toml
73
+ # └─ ./b.toml -> /abs/path/b.toml
74
+ # └─ @root/base.toml -> /abs/shared/base.toml
75
+
76
+
77
+ def load(path: str | Path) -> TomlStack:
78
+ result: LoadResult = load_toml_with_includes(Path(path))
79
+ return TomlStack(
80
+ _raw_data=result.data, _resolved_data=None, _history=result.history
81
+ )
tomlstack/base.py ADDED
@@ -0,0 +1,29 @@
1
+ from dataclasses import dataclass
2
+ from pathlib import Path
3
+ from typing import Any
4
+
5
+ PathKey = tuple[str | int, ...]
6
+
7
+ UNDECLARED_VERSION = 0
8
+ SUPPORTED_VERSIONS = {1}
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class PathRec:
13
+ raw: str # original path
14
+ path: Path # resolved absolute path
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class PathHist:
19
+ raw: str
20
+ path: Path
21
+ depth: int
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class RawToml:
26
+ path: Path
27
+ meta: dict[str, Any]
28
+ body: dict[str, Any]
29
+ includes: list[str]
tomlstack/errors.py ADDED
@@ -0,0 +1,46 @@
1
+ class TomlConfError(Exception):
2
+ """Base error for tomlconf."""
3
+
4
+
5
+ class ContentError(TomlConfError):
6
+ """Raised when content validation fails."""
7
+
8
+
9
+ class MetaError(TomlConfError):
10
+ """Base error for meta errors."""
11
+
12
+
13
+ class IncludeError(TomlConfError):
14
+ """Raised when include resolution fails."""
15
+
16
+
17
+ class TomlFormatError(TomlConfError):
18
+ """Raised when TOML parsing fails."""
19
+
20
+
21
+ class VersionError(MetaError):
22
+ """Raised when version requirement is not met."""
23
+
24
+
25
+ class IncludeInvalidError(IncludeError):
26
+ """Raised when include specification is invalid."""
27
+
28
+
29
+ class IncludeCycleError(IncludeError):
30
+ """Raised when include cycle is detected."""
31
+
32
+
33
+ class MergeError(TomlConfError):
34
+ """Raised when merge process fails."""
35
+
36
+
37
+ class InterpolationError(TomlConfError):
38
+ """Raised when interpolation fails."""
39
+
40
+
41
+ class InterpolationUndefinedError(InterpolationError):
42
+ """Raised when interpolation path is undefined."""
43
+
44
+
45
+ class InterpolationCycleError(InterpolationError):
46
+ """Raised when interpolation cycle is detected."""
tomlstack/include.py ADDED
@@ -0,0 +1,121 @@
1
+ """
2
+
3
+ ```toml
4
+ __meta__.include.root = "./parent"
5
+ # __meta__.include.anchors.root = "./parent
6
+ __meta__.include.anchors.project = "/abs/path/project"
7
+
8
+ include = [
9
+ "./relative.toml",
10
+ "../relative.toml",
11
+ "/abs/path/absolute.toml",
12
+ "@root/anchor.toml",
13
+ "@project/anchor.toml",
14
+ ]
15
+ ```
16
+
17
+ """
18
+
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import Self
22
+
23
+ from .base import PathRec, RawToml
24
+ from .errors import IncludeError
25
+
26
+
27
+ @dataclass
28
+ class IncludeSpec:
29
+ ref: Path # parent dir of current.toml
30
+ anchors: dict[str, PathRec] = field(default_factory=dict)
31
+
32
+ def __post_init__(self) -> None:
33
+ if not self.ref.is_absolute():
34
+ raise ValueError(f"IncludeSpec.ref must be absolute path, got: {self.ref}")
35
+
36
+ @staticmethod
37
+ def _include_relative(path: str) -> bool:
38
+ return path.startswith("./") or path.startswith("../")
39
+
40
+ @staticmethod
41
+ def _include_absolute(path: str) -> bool:
42
+ return Path(path).is_absolute()
43
+
44
+ @staticmethod
45
+ def _include_anchor(path: str) -> bool:
46
+ if not path.startswith("@"):
47
+ return False
48
+ label, sep, value = path[1:].partition("/")
49
+ return bool(label and sep and value)
50
+
51
+ @classmethod
52
+ def resolve_anchor_path(cls, ref: Path, anchor_path: str) -> Path:
53
+ if cls._include_relative(anchor_path):
54
+ return (ref / anchor_path).resolve()
55
+ if cls._include_absolute(anchor_path):
56
+ return Path(anchor_path).resolve()
57
+ raise ValueError(
58
+ f"Invalid anchor path: {anchor_path}. "
59
+ "Anchor values must be absolute or start with ./ or ../"
60
+ )
61
+
62
+ def resolve_include_path(self, include_path: str) -> Path:
63
+ if self._include_relative(include_path):
64
+ return (self.ref / include_path).resolve()
65
+
66
+ if self._include_absolute(include_path):
67
+ return Path(include_path).resolve()
68
+
69
+ if self._include_anchor(include_path):
70
+ label, _, rest = include_path[1:].partition("/")
71
+ if label not in self.anchors:
72
+ raise IncludeError(f"Undefined include anchor: {label}")
73
+ return (self.anchors[label].path / rest).resolve()
74
+
75
+ raise IncludeError(
76
+ f"Invalid include path format: {include_path}. "
77
+ "Use ./ or ../ or @label/ or absolute path."
78
+ )
79
+
80
+ @classmethod
81
+ def from_toml(cls, toml: RawToml) -> Self:
82
+ ref: Path = toml.path.parent
83
+
84
+ include = toml.meta.get("include")
85
+ if include is None:
86
+ return cls(ref=ref)
87
+ if not isinstance(include, dict):
88
+ raise IncludeError("Invalid __meta__.include table")
89
+
90
+ anchors_raw = include.get("anchors")
91
+ if anchors_raw is None:
92
+ anchors_raw = {}
93
+ if not isinstance(anchors_raw, dict):
94
+ raise IncludeError("Invalid __meta__.include.anchors table")
95
+
96
+ anchors: dict[str, PathRec] = {}
97
+ for label, raw_value in anchors_raw.items():
98
+ if not isinstance(label, str) or not isinstance(raw_value, str):
99
+ raise IncludeError("Anchor labels and values must be strings")
100
+ anchors[label] = PathRec(
101
+ raw=raw_value, path=cls.resolve_anchor_path(ref, raw_value)
102
+ )
103
+
104
+ # magic root anchor
105
+ if "root" in include:
106
+ root_value = include["root"]
107
+ if not isinstance(root_value, str):
108
+ raise IncludeError("Invalid __meta__.include.root")
109
+
110
+ if "root" not in anchors:
111
+ anchors["root"] = PathRec(
112
+ raw=root_value, path=cls.resolve_anchor_path(ref, root_value)
113
+ )
114
+ else:
115
+ if anchors["root"].raw != root_value:
116
+ raise IncludeError(
117
+ "Conflict between __meta__.include.root and "
118
+ "__meta__.include.anchors.root"
119
+ )
120
+
121
+ return cls(ref=ref, anchors=anchors)
@@ -0,0 +1,124 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from datetime import date, datetime, time
6
+ from typing import Any
7
+
8
+ from .errors import (
9
+ InterpolationCycleError,
10
+ InterpolationError,
11
+ InterpolationUndefinedError,
12
+ )
13
+ from .loader import PathKey
14
+ from .path_expr import get_by_path, parse_path_expr, path_to_str
15
+
16
+ EXPR_RE = re.compile(r"\$\{([^{}]+)\}")
17
+ ALLOWED_EMBED_TYPES = (str, int, float, date, time, datetime)
18
+
19
+
20
+ @dataclass
21
+ class _State:
22
+ raw: dict[str, Any]
23
+ cache: dict[PathKey, Any]
24
+ stack: list[PathKey]
25
+
26
+
27
+ def resolve_interpolations(raw_data: dict[str, Any]) -> dict[str, Any]:
28
+ state = _State(raw=raw_data, cache={}, stack=[])
29
+ return _resolve_value(raw_data, (), state)
30
+
31
+
32
+ def _resolve_path(path: PathKey, state: _State) -> Any:
33
+ if path in state.cache:
34
+ return state.cache[path]
35
+ if path in state.stack:
36
+ chain = " -> ".join(path_to_str(p) for p in [*state.stack, path])
37
+ raise InterpolationCycleError(f"Interpolation cycle detected: {chain}")
38
+
39
+ state.stack.append(path)
40
+ try:
41
+ raw_value = get_by_path(state.raw, path)
42
+ resolved = _resolve_value(raw_value, path, state)
43
+ state.cache[path] = resolved
44
+ return resolved
45
+ finally:
46
+ state.stack.pop()
47
+
48
+
49
+ def _resolve_value(value: Any, path: PathKey, state: _State) -> Any:
50
+ if isinstance(value, dict):
51
+ return {k: _resolve_value(v, (*path, k), state) for k, v in value.items()}
52
+ if isinstance(value, list):
53
+ return [_resolve_value(v, (*path, i), state) for i, v in enumerate(value)]
54
+ if isinstance(value, str):
55
+ return _resolve_string(value, path, state)
56
+ return value
57
+
58
+
59
+ def _resolve_string(value: str, path: PathKey, state: _State) -> Any:
60
+ matches = list(EXPR_RE.finditer(value))
61
+ if not matches:
62
+ return value
63
+
64
+ if len(matches) == 1 and matches[0].span() == (0, len(value)):
65
+ expr = matches[0].group(1)
66
+ ref_path, fmt_spec = _parse_expr(expr, path)
67
+ ref_value = _resolve_reference(ref_path, expr, path, state)
68
+ if fmt_spec is None:
69
+ return ref_value
70
+ return _format_value(ref_value, fmt_spec)
71
+
72
+ parts: list[str] = []
73
+ last = 0
74
+ for match in matches:
75
+ start, end = match.span()
76
+ parts.append(value[last:start])
77
+
78
+ expr = match.group(1)
79
+ ref_path, fmt_spec = _parse_expr(expr, path)
80
+ ref_value = _resolve_reference(ref_path, expr, path, state)
81
+ if not isinstance(ref_value, ALLOWED_EMBED_TYPES):
82
+ raise InterpolationError(
83
+ f"Embedded interpolation in {path_to_str(path)} "
84
+ "only supports str/int/float/date/time/datetime, "
85
+ f"got {type(ref_value).__name__}"
86
+ )
87
+ if fmt_spec is None:
88
+ parts.append(str(ref_value))
89
+ else:
90
+ parts.append(_format_value(ref_value, fmt_spec))
91
+ last = end
92
+
93
+ parts.append(value[last:])
94
+ return "".join(parts)
95
+
96
+
97
+ def _parse_expr(expr: str, path: PathKey) -> tuple[PathKey, str | None]:
98
+ ref, sep, fmt_spec = expr.partition(":")
99
+ ref = ref.strip()
100
+ fmt_spec_opt = fmt_spec if sep else None
101
+ try:
102
+ ref_path = parse_path_expr(ref)
103
+ except ValueError as exc:
104
+ raise InterpolationError(
105
+ f"Invalid interpolation expression '{expr}' at {path_to_str(path)}: {exc}"
106
+ ) from exc
107
+ return ref_path, fmt_spec_opt
108
+
109
+
110
+ def _resolve_reference(
111
+ ref_path: PathKey, expr: str, cur_path: PathKey, state: _State
112
+ ) -> Any:
113
+ try:
114
+ return _resolve_path(ref_path, state)
115
+ except KeyError as exc:
116
+ raise InterpolationUndefinedError(
117
+ f"Undefined interpolation '{expr}' at {path_to_str(cur_path)}"
118
+ ) from exc
119
+
120
+
121
+ def _format_value(value: Any, fmt_spec: str) -> str:
122
+ if isinstance(value, (date, time, datetime)):
123
+ return value.strftime(fmt_spec)
124
+ return format(value, fmt_spec)
tomlstack/loader.py ADDED
@@ -0,0 +1,202 @@
1
+ from __future__ import annotations
2
+
3
+ import tomllib
4
+ from contextlib import contextmanager
5
+ from copy import deepcopy
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .base import (
11
+ SUPPORTED_VERSIONS,
12
+ UNDECLARED_VERSION,
13
+ PathHist,
14
+ PathKey,
15
+ PathRec,
16
+ RawToml,
17
+ )
18
+ from .errors import ContentError, IncludeCycleError, VersionError
19
+ from .include import IncludeSpec
20
+
21
+ INTERNAL_FIELDS = {"include", "__meta__"}
22
+ ROOT_HISTORY_KEY: PathKey = ()
23
+
24
+
25
+ @dataclass
26
+ class LoadResult:
27
+ data: dict[str, Any]
28
+ history: dict[PathKey, list[PathHist]]
29
+
30
+
31
+ @dataclass
32
+ class _LoadContext:
33
+ file_stack: list[PathRec] = field(default_factory=list)
34
+ # current include stack for cycle detection
35
+ file_versions: dict[Path, int] = field(default_factory=dict)
36
+ # mapping of file paths to their declared or inferred version
37
+
38
+ @property
39
+ def depth(self) -> int:
40
+ return len(self.file_stack)
41
+
42
+ def _render_include_chain(self) -> str:
43
+ return "\n".join(f"\t{f.raw} -> {f.path}" for f in self.file_stack)
44
+
45
+ @contextmanager
46
+ def enter_file(self, entry: PathRec):
47
+ toml = parse_raw_file(entry.path)
48
+
49
+ self._validate_cycle_include(entry)
50
+ try:
51
+ version = self._get_version(toml.meta)
52
+ self._validate_version(version)
53
+ except VersionError as e:
54
+ raise VersionError(
55
+ f"Version conflict when including {entry.raw!r} "
56
+ f"resolved as {entry.path}\n"
57
+ "Current include chain:\n" + self._render_include_chain()
58
+ ) from e
59
+
60
+ self.file_stack.append(entry)
61
+ self.file_versions[entry.path] = version
62
+ try:
63
+ yield toml
64
+ finally:
65
+ self.file_stack.pop()
66
+
67
+ @classmethod
68
+ def _get_version(cls, meta: dict[str, Any]) -> int:
69
+ v = meta.get("version")
70
+ if v is None:
71
+ return UNDECLARED_VERSION
72
+ if not isinstance(v, int):
73
+ raise VersionError(f"Invalid type __meta__.version {v!r}")
74
+ if v not in SUPPORTED_VERSIONS:
75
+ raise VersionError(f"Unsupported __meta__.version {v!r}")
76
+ return v
77
+
78
+ def _validate_version(self, version: int) -> None:
79
+ if version == UNDECLARED_VERSION:
80
+ return
81
+ declared_version = set(self.file_versions.values())
82
+ declared_version.discard(UNDECLARED_VERSION)
83
+ declared_version.add(version)
84
+
85
+ if len(declared_version) > 1:
86
+ raise VersionError(
87
+ f"Conflicting __meta__.version value {version} "
88
+ f"with declared {declared_version!r}"
89
+ )
90
+
91
+ def _validate_cycle_include(self, entry: PathRec) -> None:
92
+
93
+ def render_cycle_include() -> str:
94
+ root = self.file_stack[0]
95
+ msg = f"Include cycle detected when load {root.raw!r}\n"
96
+ for f in self.file_stack + [entry]:
97
+ if f.path == entry.path:
98
+ msg += f"\t=> {f.raw} -> {f.path}\n"
99
+ else:
100
+ msg += f"\t {f.raw} -> {f.path}\n"
101
+ return msg
102
+
103
+ for file in self.file_stack:
104
+ if file.path == entry.path:
105
+ raise IncludeCycleError(render_cycle_include())
106
+
107
+
108
+ def load_toml_with_includes(root_file: Path) -> LoadResult:
109
+ abs_path = root_file.expanduser().resolve()
110
+ result = _load_file(PathRec(raw=str(root_file), path=abs_path), _LoadContext())
111
+ return result
112
+
113
+
114
+ def _load_file(entry: PathRec, ctx: _LoadContext) -> LoadResult:
115
+
116
+ with ctx.enter_file(entry) as toml:
117
+ current_data = toml.body
118
+ current_history = record_history(
119
+ toml.body, PathHist(raw=entry.raw, path=entry.path, depth=ctx.depth)
120
+ )
121
+ include_spec = IncludeSpec.from_toml(toml)
122
+ if toml.includes:
123
+ merged_data: dict[str, Any] = {}
124
+ merged_history: dict[PathKey, list[PathHist]] = {}
125
+ for raw_path in toml.includes:
126
+ abs_path = include_spec.resolve_include_path(raw_path)
127
+ included = _load_file(PathRec(raw=raw_path, path=abs_path), ctx)
128
+ merged_data = merge_data(merged_data, included.data)
129
+ merged_history = merge_history(merged_history, included.history)
130
+ merged_data = merge_data(merged_data, current_data)
131
+ merged_history = merge_history(merged_history, current_history)
132
+ return LoadResult(data=merged_data, history=merged_history)
133
+ else:
134
+ return LoadResult(data=current_data, history=current_history)
135
+
136
+
137
+ def record_history(data: Any, hist: PathHist):
138
+ history: dict[PathKey, list[PathHist]] = {}
139
+
140
+ def walk(value: Any, path: PathKey) -> None:
141
+ history.setdefault(path, []).append(hist)
142
+ if isinstance(value, dict):
143
+ for key, child in value.items():
144
+ walk(child, (*path, key))
145
+ elif isinstance(value, list):
146
+ for idx, child in enumerate(value):
147
+ walk(child, (*path, idx))
148
+
149
+ walk(data, ROOT_HISTORY_KEY)
150
+ return history
151
+
152
+
153
+ def merge_data(low: dict[str, Any], high: dict[str, Any]) -> dict[str, Any]:
154
+ """Merge two dictionaries with high priority overriding low priority."""
155
+ result: dict[str, Any] = deepcopy(low)
156
+ for key, high_value in high.items():
157
+ if (
158
+ key in result
159
+ and isinstance(result[key], dict)
160
+ and isinstance(high_value, dict)
161
+ ):
162
+ result[key] = merge_data(result[key], high_value)
163
+ else:
164
+ result[key] = deepcopy(high_value)
165
+ return result
166
+
167
+
168
+ def merge_history(
169
+ low: dict[PathKey, list[PathHist]], high: dict[PathKey, list[PathHist]]
170
+ ) -> dict[PathKey, list[PathHist]]:
171
+ merged = {path: entries[:] for path, entries in low.items()} # !!! important?
172
+ for path, entries in high.items():
173
+ merged.setdefault(path, []).extend(entries)
174
+ return merged
175
+
176
+
177
+ def parse_raw_file(path: Path) -> RawToml:
178
+ with path.open("rb") as f:
179
+ data = tomllib.load(f)
180
+
181
+ if not isinstance(data, dict):
182
+ raise ContentError(f"Top-level TOML object must be a table: {path}")
183
+
184
+ meta = data.pop("__meta__", {})
185
+ if not isinstance(meta, dict):
186
+ raise ContentError(f"Invalid __meta__ table in {path}")
187
+
188
+ includes: list[str] = []
189
+ raw_include = data.pop("include", None)
190
+
191
+ if raw_include is None:
192
+ pass
193
+ elif isinstance(raw_include, str):
194
+ includes.append(raw_include)
195
+ elif isinstance(raw_include, list) and all(
196
+ isinstance(item, str) for item in raw_include
197
+ ):
198
+ includes.extend(raw_include)
199
+ else:
200
+ raise ContentError(f"Invalid include specification in {path}")
201
+
202
+ return RawToml(path=path, meta=meta, body=data, includes=includes)
tomlstack/nodes.py ADDED
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Protocol
5
+
6
+ from .base import PathHist
7
+ from .loader import PathKey
8
+
9
+
10
+ @dataclass
11
+ class Node:
12
+ _cfg: ConfigProtocol
13
+ key: PathKey
14
+
15
+ def __post_init__(self) -> None:
16
+ if self.key not in self._cfg._history:
17
+ raise KeyError(f"Node path does not exist: {self.key}")
18
+
19
+ @property
20
+ def raw(self) -> Any:
21
+ return self._cfg._get_raw(self.key)
22
+
23
+ @property
24
+ def value(self) -> Any:
25
+ return self._cfg._get_value(self.key)
26
+
27
+ @property
28
+ def resolved(self) -> Any:
29
+ return self.value
30
+
31
+ @property
32
+ def origin(self) -> PathHist:
33
+ return self._cfg._history[self.key][-1]
34
+
35
+ @property
36
+ def history(self) -> tuple[PathHist, ...]:
37
+ return tuple(self._cfg._history[self.key])
38
+
39
+ def preview(self) -> str:
40
+ return _render_preview(self.raw)
41
+
42
+ def __getitem__(self, key: str | int) -> Node:
43
+ value = self.raw
44
+ if isinstance(key, int):
45
+ if not isinstance(value, list):
46
+ raise TypeError(f"Cannot index non-list node with int key: {self.key}")
47
+ if key < 0 or key >= len(value):
48
+ raise IndexError(key)
49
+ return Node(self._cfg, (*self.key, key))
50
+ if isinstance(key, str):
51
+ if not isinstance(value, dict):
52
+ raise TypeError(f"Cannot index non-dict node with str key: {self.key}")
53
+ if key not in value:
54
+ raise KeyError(key)
55
+ return Node(self._cfg, (*self.key, key))
56
+ raise TypeError(f"Unsupported key type: {type(key)!r}")
57
+
58
+ def __repr__(self) -> str:
59
+ return (
60
+ f"Node(path={self.key!r}, raw={self.raw!r}, "
61
+ f"value={self.value!r}, origin={self.origin!r})"
62
+ )
63
+
64
+
65
+ class ConfigProtocol(Protocol):
66
+ _history: dict[PathKey, list[PathHist]]
67
+
68
+ def _get_raw(self, path: PathKey) -> Any: ...
69
+
70
+ def _get_value(self, path: PathKey) -> Any: ...
71
+
72
+
73
+ def _render_preview(value: Any, indent: int = 0) -> str:
74
+ pad = " " * indent
75
+ if isinstance(value, dict):
76
+ lines = ["{"]
77
+ for key, child in value.items():
78
+ child_rendered = _render_preview(child, indent + 1)
79
+ lines.append(f"{pad} {key}: {child_rendered}")
80
+ lines.append(f"{pad}" + "}")
81
+ return "\n".join(lines)
82
+ if isinstance(value, list):
83
+ lines = ["["]
84
+ for child in value:
85
+ child_rendered = _render_preview(child, indent + 1)
86
+ lines.append(f"{pad} {child_rendered}")
87
+ lines.append(f"{pad}" + "]")
88
+ return "\n".join(lines)
89
+ return repr(value)
tomlstack/path_expr.py ADDED
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .loader import PathKey
6
+
7
+
8
+ def parse_path_expr(expr: str) -> PathKey:
9
+ expr = expr.strip()
10
+ if not expr:
11
+ raise ValueError("Empty interpolation path")
12
+
13
+ tokens: list[str | int] = []
14
+ i = 0
15
+ n = len(expr)
16
+
17
+ while i < n:
18
+ if expr[i] in ".]":
19
+ raise ValueError(f"Invalid token at position {i} in path '{expr}'")
20
+
21
+ start = i
22
+ while i < n and expr[i] not in ".[":
23
+ i += 1
24
+ key = expr[start:i]
25
+ if not key:
26
+ raise ValueError(f"Invalid empty key in path '{expr}'")
27
+ tokens.append(key)
28
+
29
+ while i < n and expr[i] == "[":
30
+ i += 1
31
+ idx_start = i
32
+ while i < n and expr[i] != "]":
33
+ i += 1
34
+ if i >= n or expr[i] != "]":
35
+ raise ValueError(f"Unclosed list index in path '{expr}'")
36
+ idx_token = expr[idx_start:i]
37
+ if not idx_token.isdigit():
38
+ raise ValueError(
39
+ f"List index must be non-negative integer in path '{expr}'"
40
+ )
41
+ tokens.append(int(idx_token))
42
+ i += 1
43
+
44
+ if i < n:
45
+ if expr[i] != ".":
46
+ raise ValueError(f"Unexpected token '{expr[i]}' in path '{expr}'")
47
+ i += 1
48
+
49
+ return tuple(tokens)
50
+
51
+
52
+ def get_by_path(data: Any, path: PathKey) -> Any:
53
+ cur = data
54
+ for part in path:
55
+ if isinstance(part, str):
56
+ if not isinstance(cur, dict) or part not in cur:
57
+ raise KeyError(part)
58
+ cur = cur[part]
59
+ else:
60
+ if not isinstance(cur, list) or part < 0 or part >= len(cur):
61
+ raise KeyError(part)
62
+ cur = cur[part]
63
+ return cur
64
+
65
+
66
+ def path_to_str(path: PathKey) -> str:
67
+ if not path:
68
+ return "<root>"
69
+
70
+ out = ""
71
+ for part in path:
72
+ if isinstance(part, str):
73
+ if out:
74
+ out += "."
75
+ out += part
76
+ else:
77
+ out += f"[{part}]"
78
+ return out
@@ -0,0 +1,197 @@
1
+ Metadata-Version: 2.4
2
+ Name: tomlstack
3
+ Version: 0.1.2
4
+ Summary: TOML configuration loader with include/merge/interpolation
5
+ Project-URL: Homepage, https://github.com/wxzhao7/tomlstack
6
+ Project-URL: Repository, https://github.com/wxzhao7/tomlstack
7
+ Project-URL: Issues, https://github.com/wxzhao7/tomlstack/issues
8
+ Author: wxzhao7
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: config,configuration,include,interpolation,toml
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Requires-Python: >=3.11
21
+ Provides-Extra: dev
22
+ Requires-Dist: build>=1.2; extra == 'dev'
23
+ Requires-Dist: mypy>=1.11; extra == 'dev'
24
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
25
+ Requires-Dist: pytest>=8.3; extra == 'dev'
26
+ Requires-Dist: ruff>=0.8; extra == 'dev'
27
+ Requires-Dist: twine>=5.1; extra == 'dev'
28
+ Provides-Extra: lint
29
+ Requires-Dist: ruff>=0.8; extra == 'lint'
30
+ Provides-Extra: test
31
+ Requires-Dist: pytest-cov>=5.0; extra == 'test'
32
+ Requires-Dist: pytest>=8.3; extra == 'test'
33
+ Provides-Extra: typing
34
+ Requires-Dist: mypy>=1.11; extra == 'typing'
35
+ Description-Content-Type: text/markdown
36
+
37
+ # tomlstack
38
+
39
+ `tomlstack` is a lightweight TOML config loader for Python 3.11+ with:
40
+
41
+ - top-level `include` loading
42
+ - deterministic merge by include order
43
+ - `${path}` interpolation with cycle/undefined checks
44
+ - node-level provenance (`origin`, `explain`, `history`)
45
+
46
+ tomlstack does not try to be a configuration framework.
47
+ It address two missing pieces to TOML: file composition and safe interpolation — while keeping files self-contained and explainable.
48
+
49
+ ## Install
50
+
51
+ ```bash
52
+ pip install tomlstack
53
+ ```
54
+
55
+ ## Quick Start
56
+
57
+ `main.toml`:
58
+
59
+ ```toml
60
+ include = [
61
+ "./base.toml",
62
+ "./prod.toml",
63
+ ]
64
+
65
+ [db]
66
+ url = "postgres://${db.user}:${db.pass}@${db.host}:${db.port}"
67
+ ```
68
+
69
+ `base.toml`:
70
+
71
+ ```toml
72
+ [db]
73
+ user = "alice"
74
+ pass = "secret"
75
+ host = "localhost"
76
+ port = 5432
77
+ ```
78
+
79
+ Python:
80
+
81
+ ```python
82
+ from tomlstack import load
83
+
84
+ cfg = load("main.toml")
85
+ print(cfg["db"]["url"].raw) # raw interpolation string
86
+ cfg.resolve()
87
+ print(cfg["db"]["url"].value) # resolved value
88
+ print(cfg["db"]["url"].origin)
89
+ print(cfg["db"]["url"].history)
90
+ print(cfg.to_dict())
91
+ ```
92
+
93
+ ## Include Semantics
94
+
95
+ - top-level `include` only; nested `include` is treated as normal data
96
+ - syntax: string or list of strings
97
+ - valid include path forms:
98
+ - `./...` or `../...`
99
+ - `@label/...` (label from `__meta__.include.anchors`)
100
+ - absolute path
101
+ - any other form raises error with hint: `Use ./ or ../ or @label/`
102
+
103
+ ### Meta Include Directives
104
+
105
+ ```toml
106
+ [__meta__]
107
+ version = 1
108
+
109
+ [__meta__.include]
110
+ root = "../.."
111
+
112
+ [__meta__.include.anchors]
113
+ proj = "./shared"
114
+ ```
115
+
116
+ - `__meta__.include.root` is sugar for `anchors.root`
117
+ - if both `root` and `anchors.root` exist and resolve differently, error
118
+ - anchor/root path values must be absolute or start with `./` or `../`
119
+ - if any file explicitly sets `__meta__.version`, all files in include chain must share one supported version (`1`)
120
+
121
+ ## Merge Rules
122
+
123
+ Load order for current file:
124
+
125
+ 1. merge first include
126
+ 2. merge second include
127
+ 3. ...
128
+ 4. merge current file (highest priority)
129
+
130
+ Conflict behavior:
131
+
132
+ - dict: recursive merge, later wins on key conflict
133
+ - list: later value replaces whole list
134
+ - scalar: later value replaces earlier
135
+
136
+ ## Interpolation Semantics
137
+
138
+ - interpolation happens on `cfg.resolve()`
139
+ - path syntax supports dot and list index: `${db.apps[0]}`
140
+ - full-string interpolation (`"${db.port}"`) keeps source type
141
+ - embedded interpolation (`"postgres://${db.host}:${db.port}"`) allows only:
142
+ - `str`, `int`, `float`, `date`, `time`, `datetime`
143
+ - formatting syntax: `${path:spec}`
144
+ - for `date/time/datetime`, formatting uses `strftime`
145
+ - otherwise uses Python `format(value, spec)`
146
+ - undefined reference raises `InterpolationUndefinedError`
147
+ - interpolation cycle raises `InterpolationCycleError`
148
+
149
+ ## Public API
150
+
151
+ - `cfg = load("f.toml")`
152
+ - `cfg.resolve()`
153
+ - `cfg.to_dict()`
154
+ - `node = cfg["proj"][0]["path"]["foo"]`
155
+ - `node.raw`
156
+ - `node.value`
157
+ - `node.origin`
158
+ - `node.history`
159
+ - `node.preview()`
160
+ - `cfg.to_toml()` -> `NotImplementedError`
161
+
162
+ ## Current Limitations
163
+
164
+ - interpolation path parser supports unquoted dot keys and numeric list indices
165
+ - no nested interpolation expressions
166
+ - `to_toml()` is not implemented yet
167
+
168
+ ## TODO
169
+
170
+ - [ ] review the details of interpolation
171
+ - [ ] explain history with interpolation
172
+
173
+ ## Release To PyPI
174
+
175
+ Build package:
176
+
177
+ ```bash
178
+ uv run --with build python -m build
179
+ ```
180
+
181
+ Upload to TestPyPI first:
182
+
183
+ ```bash
184
+ uv run --with twine python -m twine upload --repository testpypi dist/*
185
+ ```
186
+
187
+ Verify install from TestPyPI:
188
+
189
+ ```bash
190
+ python -m pip install -i https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple tomlstack
191
+ ```
192
+
193
+ Upload to PyPI:
194
+
195
+ ```bash
196
+ uv run --with twine python -m twine upload dist/*
197
+ ```
@@ -0,0 +1,14 @@
1
+ tomlstack/__init__.py,sha256=Ty_LfLLtbhRtBdljPFD8iAi1-aBZwO7KjRLaf28P_L0,66
2
+ tomlstack/_version.py,sha256=Ok5oAXdWgR9aghaFXTafTeDW6sYO3uVe6d2Nket57R4,704
3
+ tomlstack/api.py,sha256=K0eORVr3oUeZmbkntmmKTNo9KjnMABqT82hYwGzLnvw,2499
4
+ tomlstack/base.py,sha256=tBpAWYBM7uWXXAzVXVqRiH7tVEwJ16a4EijzB7gDrCY,527
5
+ tomlstack/errors.py,sha256=obgckjG712JLrtHaPKLb2dooXxNwUkRDKct58LF620w,1051
6
+ tomlstack/include.py,sha256=7OOfn4jOcsB1IrZNpUW51X4PiU6bEz4V4sedZXwODt4,3988
7
+ tomlstack/interpolate.py,sha256=DFc1LQBEgshZKlRpIng1aNjKsqnO9m-FHedkdlJ-4po,3919
8
+ tomlstack/loader.py,sha256=Yc__9VWO2sSzVwzxCc2WjwnjZQBGHhGvSpf-4gcWGYs,6765
9
+ tomlstack/nodes.py,sha256=n8M7R1PWAqietCbQfnhIpQ8d4eZY4m_nvod41FClpUg,2658
10
+ tomlstack/path_expr.py,sha256=AerF4cHe4PW8_MXhtXnOuIdNSXc-dTp9r18f5s1mhfY,2062
11
+ tomlstack-0.1.2.dist-info/METADATA,sha256=kh6u5vqyNYnQv8m-NhB0ZOZxo-6I0KhY0N-7ObddGqc,5048
12
+ tomlstack-0.1.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
13
+ tomlstack-0.1.2.dist-info/licenses/LICENSE,sha256=gKh4DjA6O7KsySQQCSgIShkcS__qcT7dCoeclv0Dpfc,1064
14
+ tomlstack-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 wxzhao7
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.