snapclass 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.
- snapclass/__init__.py +42 -0
- snapclass/collections.py +133 -0
- snapclass/containers.py +11 -0
- snapclass/decorators.py +5 -0
- snapclass/formatters.py +430 -0
- snapclass/fresh.py +58 -0
- snapclass/hooks.py +123 -0
- snapclass/paths.py +47 -0
- snapclass/plugins.py +65 -0
- snapclass/py.typed +1 -0
- snapclass/schemas.py +2598 -0
- snapclass/serializers.py +512 -0
- snapclass/sessions.py +4 -0
- snapclass/sidecar.py +300 -0
- snapclass/snapshots.py +17 -0
- snapclass/stash.py +256 -0
- snapclass/types.py +34 -0
- snapclass-0.1.0.dist-info/METADATA +123 -0
- snapclass-0.1.0.dist-info/RECORD +22 -0
- snapclass-0.1.0.dist-info/WHEEL +5 -0
- snapclass-0.1.0.dist-info/licenses/LICENSE +21 -0
- snapclass-0.1.0.dist-info/top_level.txt +1 -0
snapclass/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from dataclasses import field
|
|
2
|
+
|
|
3
|
+
from . import serializers, formatters, hooks, plugins, sessions, sidecar, types
|
|
4
|
+
from .schemas import (
|
|
5
|
+
Collection,
|
|
6
|
+
Config,
|
|
7
|
+
SnapclassError,
|
|
8
|
+
Missing,
|
|
9
|
+
Model,
|
|
10
|
+
Snapshot,
|
|
11
|
+
auto,
|
|
12
|
+
create_model,
|
|
13
|
+
snapclass,
|
|
14
|
+
frozen,
|
|
15
|
+
sync,
|
|
16
|
+
)
|
|
17
|
+
from .fresh import Fresh
|
|
18
|
+
from .stash import Stash
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"Fresh",
|
|
22
|
+
"Missing",
|
|
23
|
+
"SnapclassError",
|
|
24
|
+
"Collection",
|
|
25
|
+
"Config",
|
|
26
|
+
"Model",
|
|
27
|
+
"Snapshot",
|
|
28
|
+
"Stash",
|
|
29
|
+
"auto",
|
|
30
|
+
"serializers",
|
|
31
|
+
"create_model",
|
|
32
|
+
"snapclass",
|
|
33
|
+
"field",
|
|
34
|
+
"formatters",
|
|
35
|
+
"frozen",
|
|
36
|
+
"hooks",
|
|
37
|
+
"plugins",
|
|
38
|
+
"sessions",
|
|
39
|
+
"sidecar",
|
|
40
|
+
"sync",
|
|
41
|
+
"types",
|
|
42
|
+
]
|
snapclass/collections.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import os
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from . import sessions
|
|
9
|
+
from .stash import Stash
|
|
10
|
+
|
|
11
|
+
_missing_argument = object()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CollectionDescriptor:
|
|
15
|
+
def __get__(self, obj: object, cls: type) -> "Collection":
|
|
16
|
+
return Collection(cls)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Collection:
|
|
20
|
+
def __init__(self, cls: type, stash: Stash | None = None) -> None:
|
|
21
|
+
self.model = cls
|
|
22
|
+
self._stash = stash
|
|
23
|
+
|
|
24
|
+
def __call__(self, stash: Stash | str | os.PathLike[str]) -> "Collection":
|
|
25
|
+
from .schemas import _coerce_stash
|
|
26
|
+
|
|
27
|
+
return Collection(self.model, _coerce_stash(stash))
|
|
28
|
+
|
|
29
|
+
def get(self, *args: Any, **kwargs: Any) -> Any:
|
|
30
|
+
from .schemas import _attach_snapshot
|
|
31
|
+
|
|
32
|
+
__tracebackhide__ = sessions.HIDDEN_TRACEBACK
|
|
33
|
+
instance = self._empty_instance(*args, **kwargs)
|
|
34
|
+
_attach_snapshot(instance, self.model.__snapclass_config__, self._stash)
|
|
35
|
+
instance.snapshot.load(_initial=True)
|
|
36
|
+
return instance
|
|
37
|
+
|
|
38
|
+
def get_or_none(self, *args: Any, **kwargs: Any) -> Any | None:
|
|
39
|
+
__tracebackhide__ = sessions.HIDDEN_TRACEBACK
|
|
40
|
+
try:
|
|
41
|
+
return self.get(*args, **kwargs)
|
|
42
|
+
except FileNotFoundError:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
def get_or_create(self, *args: Any, **kwargs: Any) -> Any:
|
|
46
|
+
from .schemas import _attach_snapshot, _write_lock_for
|
|
47
|
+
|
|
48
|
+
__tracebackhide__ = sessions.HIDDEN_TRACEBACK
|
|
49
|
+
instance = self._empty_instance(*args, **kwargs, include_defaults=True)
|
|
50
|
+
_attach_snapshot(instance, self.model.__snapclass_config__, self._stash)
|
|
51
|
+
with _write_lock_for(instance.snapshot._require_path()):
|
|
52
|
+
if instance.snapshot.exists:
|
|
53
|
+
instance.snapshot.load(_initial=True)
|
|
54
|
+
else:
|
|
55
|
+
instance.snapshot.save()
|
|
56
|
+
return instance
|
|
57
|
+
|
|
58
|
+
def all(self, *, _exclude: str = "") -> Iterator[Any]:
|
|
59
|
+
from .schemas import _PatternMatcher, _attach_snapshot, _has_path_value
|
|
60
|
+
|
|
61
|
+
__tracebackhide__ = sessions.HIDDEN_TRACEBACK
|
|
62
|
+
if not self.model.__snapclass_config__.pattern:
|
|
63
|
+
raise RuntimeError("'pattern' must be set")
|
|
64
|
+
try:
|
|
65
|
+
matcher = _PatternMatcher(self.model, self._stash)
|
|
66
|
+
except ValueError as exc:
|
|
67
|
+
raise ValueError(
|
|
68
|
+
f"Unable to scan {self.model.__name__}: {exc}"
|
|
69
|
+
) from exc
|
|
70
|
+
for path in matcher.iter_candidates():
|
|
71
|
+
if path.is_dir():
|
|
72
|
+
continue
|
|
73
|
+
values = matcher.values_from(path)
|
|
74
|
+
if _exclude and values and str(values[0]).startswith(_exclude):
|
|
75
|
+
continue
|
|
76
|
+
if matcher.has_recursive_wildcard or _has_path_value(values):
|
|
77
|
+
instance = self._empty_instance(*values)
|
|
78
|
+
_attach_snapshot(instance, self.model.__snapclass_config__, self._stash)
|
|
79
|
+
instance.snapshot.load(path, _initial=True)
|
|
80
|
+
yield instance
|
|
81
|
+
else:
|
|
82
|
+
yield self.get(*values)
|
|
83
|
+
|
|
84
|
+
def filter(self, *, _exclude: str = "", **query: Any) -> Iterator[Any]:
|
|
85
|
+
from .schemas import _lookup
|
|
86
|
+
|
|
87
|
+
__tracebackhide__ = sessions.HIDDEN_TRACEBACK
|
|
88
|
+
for item in self.all(_exclude=_exclude):
|
|
89
|
+
if all(_lookup(item, key) == value for key, value in query.items()):
|
|
90
|
+
yield item
|
|
91
|
+
|
|
92
|
+
def _empty_instance(self, *args: Any, include_defaults: bool = False, **kwargs: Any) -> Any:
|
|
93
|
+
from .schemas import (
|
|
94
|
+
Missing,
|
|
95
|
+
_coerce,
|
|
96
|
+
_field_default,
|
|
97
|
+
_is_missing,
|
|
98
|
+
_placeholder_for,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
instance = self.model.__new__(self.model)
|
|
102
|
+
fields = [f for f in dataclasses.fields(self.model) if f.init]
|
|
103
|
+
hints = self.model.__snapclass_config__.type_hints
|
|
104
|
+
pattern = self.model.__snapclass_config__.pattern or ""
|
|
105
|
+
arg_iter = iter(args)
|
|
106
|
+
for field in fields:
|
|
107
|
+
try:
|
|
108
|
+
value = next(arg_iter)
|
|
109
|
+
except StopIteration:
|
|
110
|
+
value = kwargs.get(field.name, _missing_argument)
|
|
111
|
+
if value is _missing_argument:
|
|
112
|
+
default = _field_default(field)
|
|
113
|
+
if include_defaults:
|
|
114
|
+
value = default
|
|
115
|
+
elif not _is_missing(default):
|
|
116
|
+
value = default
|
|
117
|
+
elif _placeholder_for(field.name) in pattern:
|
|
118
|
+
value = default
|
|
119
|
+
if _is_missing(value):
|
|
120
|
+
raise TypeError(
|
|
121
|
+
"Collection.get() missing required placeholder field "
|
|
122
|
+
f"argument: '{field.name}'"
|
|
123
|
+
)
|
|
124
|
+
else:
|
|
125
|
+
value = Missing
|
|
126
|
+
elif _placeholder_for(field.name) in pattern:
|
|
127
|
+
value = _coerce(value, hints.get(field.name))
|
|
128
|
+
object.__setattr__(instance, field.name, value)
|
|
129
|
+
object.__setattr__(instance, "_snapclass_initializing", False)
|
|
130
|
+
return instance
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
__all__ = ["Collection", "CollectionDescriptor"]
|
snapclass/containers.py
ADDED
snapclass/decorators.py
ADDED
snapclass/formatters.py
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from io import StringIO
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, ClassVar, IO
|
|
9
|
+
|
|
10
|
+
from ruamel.yaml import YAML as RuamelYAML
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _empty_if_not_mapping(data: Any) -> dict[str, Any]:
|
|
14
|
+
if data is None:
|
|
15
|
+
return {}
|
|
16
|
+
if not isinstance(data, dict):
|
|
17
|
+
return {}
|
|
18
|
+
return data
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FileFormatter(ABC):
|
|
22
|
+
extensions: ClassVar[set[str]] = set()
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def loads(cls, text: str) -> dict[str, Any]:
|
|
27
|
+
raise NotImplementedError
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def dumps(cls, data: dict[str, Any]) -> str:
|
|
32
|
+
raise NotImplementedError
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Formatter(ABC):
|
|
36
|
+
"""Application-level file format adapter."""
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def extensions(cls) -> set[str]:
|
|
41
|
+
raise NotImplementedError
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
@abstractmethod
|
|
45
|
+
def deserialize(cls, file_object: IO[str]) -> dict[str, Any]:
|
|
46
|
+
raise NotImplementedError
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def serialize(cls, data: Any) -> str:
|
|
51
|
+
raise NotImplementedError
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
Format = Formatter
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class YAMLFormatter(FileFormatter):
|
|
58
|
+
extensions = {"", ".yml", ".yaml"}
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def loads(cls, text: str) -> dict[str, Any]:
|
|
62
|
+
yaml = RuamelYAML()
|
|
63
|
+
yaml.preserve_quotes = True
|
|
64
|
+
data = yaml.load(text)
|
|
65
|
+
return _empty_if_not_mapping(data)
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def dumps(cls, data: dict[str, Any]) -> str:
|
|
69
|
+
yaml = RuamelYAML()
|
|
70
|
+
yaml.indent(mapping=2, sequence=4, offset=2)
|
|
71
|
+
stream = StringIO()
|
|
72
|
+
yaml.dump(data, stream)
|
|
73
|
+
text = normalize_yaml_text(stream.getvalue())
|
|
74
|
+
if text == "{}\n":
|
|
75
|
+
return ""
|
|
76
|
+
return text
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TextFormatter(FileFormatter):
|
|
80
|
+
extensions = {".txt"}
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def loads(cls, text: str) -> dict[str, Any]:
|
|
84
|
+
return {"content": text}
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def dumps(cls, data: dict[str, Any]) -> str:
|
|
88
|
+
values = list(data.values())
|
|
89
|
+
if len(values) != 1:
|
|
90
|
+
raise ValueError("TextFormatter expects exactly one serialized field")
|
|
91
|
+
value = values[0]
|
|
92
|
+
return "" if value is None else str(value)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class TypedTextFormatter(FileFormatter):
|
|
96
|
+
extensions = {".txt"}
|
|
97
|
+
divider = "#-=-=-=-=-DO-NOT-EDIT-THIS-LINE-PLEASE-=-=-=-=-#"
|
|
98
|
+
|
|
99
|
+
_types = {
|
|
100
|
+
"bool": bool,
|
|
101
|
+
"float": float,
|
|
102
|
+
"int": int,
|
|
103
|
+
"NoneType": type(None),
|
|
104
|
+
"str": str,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def loads(cls, text: str) -> dict[str, Any]:
|
|
109
|
+
data: dict[str, Any] = {}
|
|
110
|
+
current_key: str | None = None
|
|
111
|
+
current_type: str | None = None
|
|
112
|
+
current_value: list[str] = []
|
|
113
|
+
|
|
114
|
+
for line in text.splitlines():
|
|
115
|
+
if line == cls.divider:
|
|
116
|
+
if current_key is None or current_type is None:
|
|
117
|
+
raise ValueError("Misformatted typed text: divider without field header")
|
|
118
|
+
data[current_key] = cls._coerce("\n".join(current_value), current_type)
|
|
119
|
+
current_key = None
|
|
120
|
+
current_type = None
|
|
121
|
+
current_value = []
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
if current_key is None:
|
|
125
|
+
try:
|
|
126
|
+
key, type_name = line.split("|", 1)
|
|
127
|
+
except ValueError as exc:
|
|
128
|
+
raise ValueError(f"Misformatted typed text field header: {line!r}") from exc
|
|
129
|
+
key = key.strip()
|
|
130
|
+
type_name = type_name.strip()
|
|
131
|
+
if not key:
|
|
132
|
+
raise ValueError("Misformatted typed text: empty field name")
|
|
133
|
+
if type_name not in cls._types:
|
|
134
|
+
raise ValueError(f"Unsupported typed text field type: {type_name!r}")
|
|
135
|
+
current_key = key
|
|
136
|
+
current_type = type_name
|
|
137
|
+
current_value = []
|
|
138
|
+
else:
|
|
139
|
+
current_value.append(line)
|
|
140
|
+
|
|
141
|
+
if current_key is not None:
|
|
142
|
+
raise ValueError(f"Misformatted typed text: missing divider for {current_key!r}")
|
|
143
|
+
|
|
144
|
+
return data
|
|
145
|
+
|
|
146
|
+
@classmethod
|
|
147
|
+
def dumps(cls, data: dict[str, Any]) -> str:
|
|
148
|
+
sections: list[str] = []
|
|
149
|
+
for key, value in data.items():
|
|
150
|
+
type_name = type(value).__name__
|
|
151
|
+
if type_name not in cls._types:
|
|
152
|
+
raise ValueError(f"Unsupported typed text field type: {type_name!r}")
|
|
153
|
+
rendered = "" if value is None else str(value)
|
|
154
|
+
sections.append(f"{key}|{type_name}\n{rendered}\n{cls.divider}\n")
|
|
155
|
+
return "".join(sections)
|
|
156
|
+
|
|
157
|
+
@classmethod
|
|
158
|
+
def _coerce(cls, value: str, type_name: str) -> Any:
|
|
159
|
+
if type_name == "NoneType":
|
|
160
|
+
return None
|
|
161
|
+
if type_name == "bool":
|
|
162
|
+
normalized = value.strip().lower()
|
|
163
|
+
if normalized in {"true", "1", "yes", "on"}:
|
|
164
|
+
return True
|
|
165
|
+
if normalized in {"false", "0", "no", "off", ""}:
|
|
166
|
+
return False
|
|
167
|
+
raise ValueError(f"Cannot parse bool typed text value: {value!r}")
|
|
168
|
+
return cls._types[type_name](value)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class JSONFormatter(FileFormatter):
|
|
172
|
+
extensions = {".json"}
|
|
173
|
+
|
|
174
|
+
@classmethod
|
|
175
|
+
def loads(cls, text: str) -> dict[str, Any]:
|
|
176
|
+
data = json.loads(text)
|
|
177
|
+
return _empty_if_not_mapping(data)
|
|
178
|
+
|
|
179
|
+
@classmethod
|
|
180
|
+
def dumps(cls, data: dict[str, Any]) -> str:
|
|
181
|
+
return json.dumps(data, indent=2) + "\n"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class JSON5Formatter(FileFormatter):
|
|
185
|
+
extensions = {".json5"}
|
|
186
|
+
|
|
187
|
+
@classmethod
|
|
188
|
+
def loads(cls, text: str) -> dict[str, Any]:
|
|
189
|
+
import json5
|
|
190
|
+
|
|
191
|
+
data = json5.loads(text)
|
|
192
|
+
return _empty_if_not_mapping(data)
|
|
193
|
+
|
|
194
|
+
@classmethod
|
|
195
|
+
def dumps(cls, data: dict[str, Any]) -> str:
|
|
196
|
+
import json5
|
|
197
|
+
|
|
198
|
+
return json5.dumps(data, indent=2) + "\n"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class TOMLFormatter(FileFormatter):
|
|
202
|
+
extensions = {".toml"}
|
|
203
|
+
|
|
204
|
+
@classmethod
|
|
205
|
+
def loads(cls, text: str) -> dict[str, Any]:
|
|
206
|
+
import tomlkit
|
|
207
|
+
|
|
208
|
+
data = tomlkit.loads(text)
|
|
209
|
+
return _plain_mapping(data)
|
|
210
|
+
|
|
211
|
+
@classmethod
|
|
212
|
+
def dumps(cls, data: dict[str, Any]) -> str:
|
|
213
|
+
import tomlkit
|
|
214
|
+
|
|
215
|
+
return tomlkit.dumps(data)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
_DEFAULT_FILE_FORMATTERS: dict[str, type[FileFormatter]] = {
|
|
219
|
+
"": YAMLFormatter,
|
|
220
|
+
".yml": YAMLFormatter,
|
|
221
|
+
".yaml": YAMLFormatter,
|
|
222
|
+
".json": JSONFormatter,
|
|
223
|
+
".json5": JSON5Formatter,
|
|
224
|
+
".toml": TOMLFormatter,
|
|
225
|
+
".txt": TextFormatter,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def from_formatter(formatter: type) -> type[FileFormatter]:
|
|
230
|
+
class FormatterAdapter(FileFormatter):
|
|
231
|
+
extensions = set(formatter.extensions())
|
|
232
|
+
|
|
233
|
+
@classmethod
|
|
234
|
+
def loads(cls, text: str) -> dict[str, Any]:
|
|
235
|
+
data = formatter.deserialize(StringIO(text))
|
|
236
|
+
if data is None:
|
|
237
|
+
return {}
|
|
238
|
+
if not isinstance(data, dict):
|
|
239
|
+
return {}
|
|
240
|
+
return data
|
|
241
|
+
|
|
242
|
+
@classmethod
|
|
243
|
+
def loads_path(cls, path: Path, text: str) -> dict[str, Any]:
|
|
244
|
+
with path.open("r", encoding="utf-8") as file_object:
|
|
245
|
+
data = formatter.deserialize(file_object)
|
|
246
|
+
if data is None:
|
|
247
|
+
return {}
|
|
248
|
+
if not isinstance(data, dict):
|
|
249
|
+
return {}
|
|
250
|
+
return data
|
|
251
|
+
|
|
252
|
+
@classmethod
|
|
253
|
+
def dumps(cls, data: dict[str, Any]) -> str:
|
|
254
|
+
return formatter.serialize(data)
|
|
255
|
+
|
|
256
|
+
FormatterAdapter.__name__ = f"{formatter.__name__}FileFormatter"
|
|
257
|
+
return FormatterAdapter
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def normalize_formatters(
|
|
261
|
+
formatters: Mapping[str, type[FileFormatter] | type[Formatter]] | None,
|
|
262
|
+
) -> dict[str, type[FileFormatter]]:
|
|
263
|
+
if formatters is None:
|
|
264
|
+
return {}
|
|
265
|
+
normalized: dict[str, type[FileFormatter]] = {}
|
|
266
|
+
for extension, formatter in formatters.items():
|
|
267
|
+
if not isinstance(extension, str):
|
|
268
|
+
raise TypeError("formatter extensions must be strings")
|
|
269
|
+
normalized[extension] = as_file_formatter(formatter)
|
|
270
|
+
return normalized
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def as_file_formatter(
|
|
274
|
+
formatter: type[FileFormatter] | type[Formatter],
|
|
275
|
+
) -> type[FileFormatter]:
|
|
276
|
+
if not isinstance(formatter, type):
|
|
277
|
+
raise TypeError("formatter must be a formatter class")
|
|
278
|
+
if issubclass(formatter, FileFormatter):
|
|
279
|
+
return formatter
|
|
280
|
+
if issubclass(formatter, Formatter):
|
|
281
|
+
return from_formatter(formatter)
|
|
282
|
+
raise TypeError("formatter must be a Formatter subclass")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def formatter_for(
|
|
286
|
+
path: Path,
|
|
287
|
+
explicit: type[FileFormatter] | None = None,
|
|
288
|
+
*,
|
|
289
|
+
formatters: Mapping[str, type[FileFormatter]] | None = None,
|
|
290
|
+
) -> type[FileFormatter]:
|
|
291
|
+
if explicit is not None:
|
|
292
|
+
return explicit
|
|
293
|
+
suffix = path.suffix
|
|
294
|
+
if formatters is not None and suffix in formatters:
|
|
295
|
+
return formatters[suffix]
|
|
296
|
+
if suffix in _DEFAULT_FILE_FORMATTERS:
|
|
297
|
+
return _DEFAULT_FILE_FORMATTERS[suffix]
|
|
298
|
+
raise ValueError(f"Unsupported file extension: {suffix!r}")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
class JSON(Formatter):
|
|
302
|
+
@classmethod
|
|
303
|
+
def extensions(cls) -> set[str]:
|
|
304
|
+
return {".json"}
|
|
305
|
+
|
|
306
|
+
@classmethod
|
|
307
|
+
def deserialize(cls, file_object: IO[str]) -> dict[str, Any]:
|
|
308
|
+
return _empty_if_not_mapping(json.load(file_object))
|
|
309
|
+
|
|
310
|
+
@classmethod
|
|
311
|
+
def serialize(cls, data: Any) -> str:
|
|
312
|
+
return json.dumps(data, indent=2)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class JSON5(Formatter):
|
|
316
|
+
@classmethod
|
|
317
|
+
def extensions(cls) -> set[str]:
|
|
318
|
+
return {".json5"}
|
|
319
|
+
|
|
320
|
+
@classmethod
|
|
321
|
+
def deserialize(cls, file_object: IO[str]) -> dict[str, Any]:
|
|
322
|
+
import json5
|
|
323
|
+
|
|
324
|
+
return _empty_if_not_mapping(json5.load(file_object))
|
|
325
|
+
|
|
326
|
+
@classmethod
|
|
327
|
+
def serialize(cls, data: Any) -> str:
|
|
328
|
+
import json5
|
|
329
|
+
|
|
330
|
+
return json5.dumps(data, indent=2)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
class TOML(Formatter):
|
|
334
|
+
@classmethod
|
|
335
|
+
def extensions(cls) -> set[str]:
|
|
336
|
+
return {".toml"}
|
|
337
|
+
|
|
338
|
+
@classmethod
|
|
339
|
+
def deserialize(cls, file_object: IO[str]) -> dict[str, Any]:
|
|
340
|
+
import tomlkit
|
|
341
|
+
|
|
342
|
+
return _plain_mapping(tomlkit.loads(file_object.read()))
|
|
343
|
+
|
|
344
|
+
@classmethod
|
|
345
|
+
def serialize(cls, data: Any) -> str:
|
|
346
|
+
import tomlkit
|
|
347
|
+
|
|
348
|
+
return tomlkit.dumps(data)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
class YAML(Formatter):
|
|
352
|
+
@classmethod
|
|
353
|
+
def extensions(cls) -> set[str]:
|
|
354
|
+
return {"", ".yml", ".yaml"}
|
|
355
|
+
|
|
356
|
+
@classmethod
|
|
357
|
+
def deserialize(cls, file_object: IO[str]) -> dict[str, Any]:
|
|
358
|
+
yaml = RuamelYAML()
|
|
359
|
+
yaml.preserve_quotes = True
|
|
360
|
+
return _empty_if_not_mapping(yaml.load(file_object))
|
|
361
|
+
|
|
362
|
+
@classmethod
|
|
363
|
+
def serialize(cls, data: Any) -> str:
|
|
364
|
+
yaml = RuamelYAML()
|
|
365
|
+
yaml.indent(mapping=2, sequence=4, offset=2)
|
|
366
|
+
stream = StringIO()
|
|
367
|
+
yaml.dump(data, stream)
|
|
368
|
+
text = normalize_yaml_text(stream.getvalue())
|
|
369
|
+
if text == "{}\n":
|
|
370
|
+
return ""
|
|
371
|
+
return text
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
_DEFAULT_FORMATTERS: dict[str, type[Formatter]] = {
|
|
375
|
+
"": YAML,
|
|
376
|
+
".yml": YAML,
|
|
377
|
+
".yaml": YAML,
|
|
378
|
+
".json": JSON,
|
|
379
|
+
".json5": JSON5,
|
|
380
|
+
".toml": TOML,
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def deserialize(
|
|
385
|
+
path: Path,
|
|
386
|
+
extension: str,
|
|
387
|
+
*,
|
|
388
|
+
formatter: type[Formatter] | None = None,
|
|
389
|
+
) -> dict[str, Any]:
|
|
390
|
+
formatter = formatter or _formatter_adapter_for(extension)
|
|
391
|
+
with path.open("r", encoding="utf-8") as file_object:
|
|
392
|
+
return _empty_if_not_mapping(formatter.deserialize(file_object))
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def serialize(
|
|
396
|
+
data: Any,
|
|
397
|
+
extension: str = ".yml",
|
|
398
|
+
*,
|
|
399
|
+
formatter: type[Formatter] | None = None,
|
|
400
|
+
) -> str:
|
|
401
|
+
formatter = formatter or _formatter_adapter_for(extension)
|
|
402
|
+
return formatter.serialize(data)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _formatter_adapter_for(extension: str) -> type[Formatter]:
|
|
406
|
+
if extension in _DEFAULT_FORMATTERS:
|
|
407
|
+
return _DEFAULT_FORMATTERS[extension]
|
|
408
|
+
raise ValueError(f"Unsupported file extension: {extension!r}")
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _plain_mapping(value: Any) -> Any:
|
|
412
|
+
if isinstance(value, dict):
|
|
413
|
+
return {key: _plain_mapping(item) for key, item in value.items()}
|
|
414
|
+
if isinstance(value, list):
|
|
415
|
+
return [_plain_mapping(item) for item in value]
|
|
416
|
+
return value
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def normalize_yaml_text(text: str) -> str:
|
|
420
|
+
text = text.replace("- \n", "-\n")
|
|
421
|
+
if text.startswith(" -"):
|
|
422
|
+
lines = (
|
|
423
|
+
line[2:] if line.startswith(" ") else line
|
|
424
|
+
for line in text.splitlines()
|
|
425
|
+
)
|
|
426
|
+
return "\n".join(lines) + "\n"
|
|
427
|
+
return text
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
_normalize_yaml_text = normalize_yaml_text
|
snapclass/fresh.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy as _copy
|
|
4
|
+
from collections import Counter, defaultdict, deque
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
_MISSING = object()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _Fresh:
|
|
13
|
+
def __call__(self, factory: Callable[[], Any]):
|
|
14
|
+
return field(default_factory=factory)
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def List(self):
|
|
18
|
+
return field(default_factory=list)
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def Dict(self):
|
|
22
|
+
return field(default_factory=dict)
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def Set(self):
|
|
26
|
+
return field(default_factory=set)
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def Deque(self):
|
|
30
|
+
return field(default_factory=deque)
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def Counter(self):
|
|
34
|
+
return field(default_factory=Counter)
|
|
35
|
+
|
|
36
|
+
def DefaultDict(
|
|
37
|
+
self,
|
|
38
|
+
factory: Callable[[], Any] | object = _MISSING,
|
|
39
|
+
*,
|
|
40
|
+
value: Callable[[], Any] | object = _MISSING,
|
|
41
|
+
):
|
|
42
|
+
if factory is not _MISSING and value is not _MISSING:
|
|
43
|
+
raise TypeError(
|
|
44
|
+
"Fresh.DefaultDict accepts either a positional factory or value=, "
|
|
45
|
+
"not both"
|
|
46
|
+
)
|
|
47
|
+
default_factory = value if value is not _MISSING else factory
|
|
48
|
+
if default_factory is _MISSING:
|
|
49
|
+
raise TypeError("Fresh.DefaultDict requires a missing-value factory")
|
|
50
|
+
return field(default_factory=lambda: defaultdict(default_factory))
|
|
51
|
+
|
|
52
|
+
def copy(self, template: Any):
|
|
53
|
+
return field(default_factory=lambda: _copy.deepcopy(template))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
Fresh = _Fresh()
|
|
57
|
+
|
|
58
|
+
__all__ = ["Fresh"]
|