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 +3 -0
- tomlstack/_version.py +34 -0
- tomlstack/api.py +81 -0
- tomlstack/base.py +29 -0
- tomlstack/errors.py +46 -0
- tomlstack/include.py +121 -0
- tomlstack/interpolate.py +124 -0
- tomlstack/loader.py +202 -0
- tomlstack/nodes.py +89 -0
- tomlstack/path_expr.py +78 -0
- tomlstack-0.1.2.dist-info/METADATA +197 -0
- tomlstack-0.1.2.dist-info/RECORD +14 -0
- tomlstack-0.1.2.dist-info/WHEEL +4 -0
- tomlstack-0.1.2.dist-info/licenses/LICENSE +21 -0
tomlstack/__init__.py
ADDED
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)
|
tomlstack/interpolate.py
ADDED
|
@@ -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,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.
|