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 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
+ ]
@@ -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"]
@@ -0,0 +1,11 @@
1
+ from .schemas import TrackedDict, TrackedList, _track_value, _wrap_mutables
2
+ from .types import Dict, List
3
+
4
+ __all__ = [
5
+ "Dict",
6
+ "List",
7
+ "TrackedDict",
8
+ "TrackedList",
9
+ "_track_value",
10
+ "_wrap_mutables",
11
+ ]
@@ -0,0 +1,5 @@
1
+ """Decorator API re-exports."""
2
+
3
+ from .schemas import auto, snapclass, sync
4
+
5
+ __all__ = ["auto", "snapclass", "sync"]
@@ -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"]