fs-schema 0.4.5__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.
- fs_schema/__init__.py +31 -0
- fs_schema/_fmt.py +162 -0
- fs_schema/_mashumaro_json.py +70 -0
- fs_schema/_ops.py +66 -0
- fs_schema/_schema.py +981 -0
- fs_schema/_std_ext.py +35 -0
- fs_schema/_types.py +52 -0
- fs_schema/py.typed +0 -0
- fs_schema-0.4.5.dist-info/METADATA +107 -0
- fs_schema-0.4.5.dist-info/RECORD +12 -0
- fs_schema-0.4.5.dist-info/WHEEL +4 -0
- fs_schema-0.4.5.dist-info/licenses/LICENSE +21 -0
fs_schema/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from importlib.metadata import version as _version
|
|
2
|
+
|
|
3
|
+
from beartype import (
|
|
4
|
+
BeartypeConf as _BeartypeConf,
|
|
5
|
+
BeartypeStrategy as _BeartypeStrategy,
|
|
6
|
+
)
|
|
7
|
+
from beartype.claw import beartype_this_package as _beartype_this_package
|
|
8
|
+
|
|
9
|
+
__version__ = _version("fs-schema")
|
|
10
|
+
|
|
11
|
+
_beartype_this_package(conf=_BeartypeConf(strategy=_BeartypeStrategy.On))
|
|
12
|
+
|
|
13
|
+
from ._fmt import dt as dt
|
|
14
|
+
from ._ops import (
|
|
15
|
+
MismatchErr as MismatchErr,
|
|
16
|
+
exists_opt as exists_opt,
|
|
17
|
+
is_mismatch as is_mismatch,
|
|
18
|
+
put as put,
|
|
19
|
+
raise_exn as raise_exn,
|
|
20
|
+
raise_mismatch as raise_mismatch,
|
|
21
|
+
)
|
|
22
|
+
from ._schema import (
|
|
23
|
+
FILES as FILES,
|
|
24
|
+
Dir as Dir,
|
|
25
|
+
File as File,
|
|
26
|
+
Layout as Layout,
|
|
27
|
+
Match as Match,
|
|
28
|
+
Schema as Schema,
|
|
29
|
+
SchemaRoot as SchemaRoot,
|
|
30
|
+
)
|
|
31
|
+
from ._types import Located as Located
|
fs_schema/_fmt.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Private compilation and parsing of fs-schema basename templates."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator, Mapping
|
|
4
|
+
from dataclasses import dataclass, replace
|
|
5
|
+
from datetime import date, datetime, time
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from string import Formatter
|
|
8
|
+
from typing import Final, TypeAlias
|
|
9
|
+
|
|
10
|
+
from beartype.typing import Protocol
|
|
11
|
+
from parse import Parser
|
|
12
|
+
from typing_extensions import override
|
|
13
|
+
|
|
14
|
+
FmtLike: TypeAlias = str
|
|
15
|
+
# A parsed template field: {n:d} -> int, {stem} -> str, dt() -> datetime.
|
|
16
|
+
FmtField: TypeAlias = str | int | datetime
|
|
17
|
+
# Regex optional groups can be absent; format fields cannot.
|
|
18
|
+
CaptureField: TypeAlias = FmtField | None
|
|
19
|
+
_FORMATTER = Formatter()
|
|
20
|
+
_DIRECTIVES = frozenset("aAwdbBmyYHIpMSfzZjUWcxX%")
|
|
21
|
+
_INTEGER_TYPES = frozenset("bcdoxXn")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CaptureMap(Mapping[str, CaptureField]):
|
|
25
|
+
__slots__: Final = ("_values",)
|
|
26
|
+
_values: dict[str, CaptureField]
|
|
27
|
+
|
|
28
|
+
def __init__(self, values: Mapping[str, CaptureField]) -> None:
|
|
29
|
+
self._values = dict(values)
|
|
30
|
+
|
|
31
|
+
@override
|
|
32
|
+
def __getitem__(self, key: str) -> CaptureField:
|
|
33
|
+
return self._values[key]
|
|
34
|
+
|
|
35
|
+
@override
|
|
36
|
+
def __iter__(self) -> Iterator[str]:
|
|
37
|
+
return iter(self._values)
|
|
38
|
+
|
|
39
|
+
@override
|
|
40
|
+
def __len__(self) -> int:
|
|
41
|
+
return len(self._values)
|
|
42
|
+
|
|
43
|
+
def __getattr__(self, name: str) -> CaptureField:
|
|
44
|
+
try:
|
|
45
|
+
return self._values[name]
|
|
46
|
+
except KeyError as error:
|
|
47
|
+
raise AttributeError(name) from error
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class FormatField:
|
|
52
|
+
name: str
|
|
53
|
+
index: int
|
|
54
|
+
pattern: str = ""
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def positional(self) -> bool:
|
|
58
|
+
return not self.name or (self.name.isascii() and self.name.isdecimal())
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _FormatFieldTemplate(Enum):
|
|
62
|
+
STRING = FormatField("", 0)
|
|
63
|
+
INTEGER = FormatField("", 0, "d")
|
|
64
|
+
DATETIME = FormatField("", 0, "%Y")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True)
|
|
68
|
+
class ParsedCaptures:
|
|
69
|
+
args: tuple[CaptureField, ...]
|
|
70
|
+
kwargs: CaptureMap
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class CompiledFormat:
|
|
74
|
+
__slots__: Final = ("_fields", "_parser", "_pattern")
|
|
75
|
+
_fields: tuple[FormatField, ...]
|
|
76
|
+
_parser: Parser
|
|
77
|
+
_pattern: str
|
|
78
|
+
|
|
79
|
+
def __init__(self, pattern: str) -> None:
|
|
80
|
+
self._fields = tuple(
|
|
81
|
+
_compile_field(name, spec or "", index)
|
|
82
|
+
for index, (_, name, spec, _) in enumerate(_FORMATTER.parse(pattern))
|
|
83
|
+
if name is not None
|
|
84
|
+
)
|
|
85
|
+
self._parser = Parser(pattern, case_sensitive=True)
|
|
86
|
+
self._pattern = pattern
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def pattern(self) -> str:
|
|
90
|
+
return self._pattern
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def fields(self) -> tuple[FormatField, ...]:
|
|
94
|
+
return self._fields
|
|
95
|
+
|
|
96
|
+
def format(self, *args: FmtField, **kwargs: FmtField) -> str:
|
|
97
|
+
return _FORMATTER.vformat(self.pattern, args, kwargs)
|
|
98
|
+
|
|
99
|
+
def parse(self, basename: str) -> ParsedCaptures | None:
|
|
100
|
+
if (parsed := _parse(self._parser, basename)) is None:
|
|
101
|
+
return None
|
|
102
|
+
return ParsedCaptures(
|
|
103
|
+
tuple(_capture_value(value) for value in parsed.fixed),
|
|
104
|
+
CaptureMap({name: _capture_value(value) for name, value in parsed.named.items()}),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# These types only exist for enforced typechecking
|
|
109
|
+
_ParserField: TypeAlias = str | int | date | time | datetime
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class _ParseResult(Protocol):
|
|
113
|
+
fixed: tuple[_ParserField, ...]
|
|
114
|
+
named: dict[str, _ParserField]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _parse(parser: Parser, basename: str) -> _ParseResult | None:
|
|
118
|
+
return parser.parse(basename)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _capture_value(value: _ParserField) -> FmtField:
|
|
122
|
+
match value:
|
|
123
|
+
case datetime():
|
|
124
|
+
return value
|
|
125
|
+
case date():
|
|
126
|
+
return datetime.combine(value, time.min)
|
|
127
|
+
case str() | int():
|
|
128
|
+
return value
|
|
129
|
+
case time():
|
|
130
|
+
raise TypeError("time captures are unsupported")
|
|
131
|
+
case _: # pyright: ignore[reportUnnecessaryComparison]
|
|
132
|
+
raise TypeError("unexpected capture type") # pyright: ignore[reportUnreachable]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _is_strftime_spec(spec: str) -> bool:
|
|
136
|
+
found = False
|
|
137
|
+
index = 0
|
|
138
|
+
while index < len(spec):
|
|
139
|
+
if spec[index] != "%":
|
|
140
|
+
index += 1
|
|
141
|
+
continue
|
|
142
|
+
if index + 1 >= len(spec) or spec[index + 1] not in _DIRECTIVES:
|
|
143
|
+
return False
|
|
144
|
+
found |= spec[index + 1] != "%"
|
|
145
|
+
index += 2
|
|
146
|
+
return found
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _field_template(spec: str) -> _FormatFieldTemplate:
|
|
150
|
+
if _is_strftime_spec(spec):
|
|
151
|
+
return _FormatFieldTemplate.DATETIME
|
|
152
|
+
if spec[-1:] in _INTEGER_TYPES:
|
|
153
|
+
return _FormatFieldTemplate.INTEGER
|
|
154
|
+
return _FormatFieldTemplate.STRING
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _compile_field(name: str, spec: str, index: int) -> FormatField:
|
|
158
|
+
return replace(_field_template(spec).value, name=name, index=index, pattern=spec)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def dt(pattern: str) -> FmtLike:
|
|
162
|
+
return f"{{:{pattern}}}"
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Mashumaro JSON conversion used by filesystem operations."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import is_dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TypeVar, cast
|
|
6
|
+
|
|
7
|
+
from typing_extensions import Protocol, runtime_checkable
|
|
8
|
+
|
|
9
|
+
from ._types import DataclassInstance
|
|
10
|
+
|
|
11
|
+
_T = TypeVar("_T")
|
|
12
|
+
_T_co = TypeVar("_T_co", covariant=True)
|
|
13
|
+
_T_contra = TypeVar("_T_contra", contravariant=True)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@runtime_checkable
|
|
17
|
+
class _Encoder(Protocol[_T_contra]):
|
|
18
|
+
def encode(self, obj: _T_contra) -> str | bytes: ...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@runtime_checkable
|
|
22
|
+
class _Decoder(Protocol[_T_co]):
|
|
23
|
+
def decode(self, data: str | bytes | bytearray) -> _T_co: ...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _json_codecs(model: type[_T]) -> tuple[_Encoder[_T], _Decoder[_T]]:
|
|
27
|
+
try:
|
|
28
|
+
from mashumaro.codecs.json import JSONDecoder, JSONEncoder
|
|
29
|
+
except ModuleNotFoundError as error:
|
|
30
|
+
if error.name == "mashumaro":
|
|
31
|
+
raise TypeError("JSON dataclass conversion requires fs-schema[mashumaro]") from error
|
|
32
|
+
raise
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
import orjson
|
|
36
|
+
except ModuleNotFoundError as error:
|
|
37
|
+
if error.name != "orjson":
|
|
38
|
+
raise
|
|
39
|
+
encoder, decoder = JSONEncoder(model), JSONDecoder(model)
|
|
40
|
+
else:
|
|
41
|
+
from mashumaro.codecs.orjson import ORJSONDecoder, ORJSONEncoder
|
|
42
|
+
|
|
43
|
+
_ = orjson
|
|
44
|
+
encoder, decoder = ORJSONEncoder(model), ORJSONDecoder(model)
|
|
45
|
+
return cast(_Encoder[_T], encoder), cast(_Decoder[_T], decoder)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _check_json(path: Path) -> None:
|
|
49
|
+
if path.suffix.casefold() != ".json":
|
|
50
|
+
raise TypeError(f"dataclass conversion requires a .json file, got {path.suffix or '<no suffix>'!r}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _check_model(model: type[object]) -> None:
|
|
54
|
+
if not is_dataclass(model):
|
|
55
|
+
raise TypeError(f"declared model {model.__name__} is not a dataclass")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def decode_json(path: Path, model: type[_T]) -> _T:
|
|
59
|
+
_check_json(path)
|
|
60
|
+
_check_model(model)
|
|
61
|
+
_, decoder = _json_codecs(model)
|
|
62
|
+
return decoder.decode(path.read_bytes())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def encode_json(path: Path, value: DataclassInstance) -> str | bytes:
|
|
66
|
+
_check_json(path)
|
|
67
|
+
model = type(value)
|
|
68
|
+
_check_model(model)
|
|
69
|
+
encoder, _ = _json_codecs(model)
|
|
70
|
+
return encoder.encode(value)
|
fs_schema/_ops.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from shutil import copyfile
|
|
3
|
+
from typing import TypeVar, cast
|
|
4
|
+
|
|
5
|
+
from typing_extensions import TypeIs, assert_never
|
|
6
|
+
|
|
7
|
+
from ._mashumaro_json import decode_json, encode_json
|
|
8
|
+
from ._types import DataclassInstance, HasSave, LoadSpec, LoadT, PathIsh, Puttable
|
|
9
|
+
|
|
10
|
+
# The passthrough value is returned unchanged when it is not a mismatch.
|
|
11
|
+
_T = TypeVar("_T")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MismatchErr(Exception):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def is_mismatch(x: object) -> TypeIs[MismatchErr]:
|
|
19
|
+
return isinstance(x, MismatchErr)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def raise_exn(x: _T | Exception) -> _T:
|
|
23
|
+
if isinstance(x, Exception):
|
|
24
|
+
raise x
|
|
25
|
+
return x
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def raise_mismatch(x: _T | MismatchErr) -> _T:
|
|
29
|
+
return raise_exn(x)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def exists_opt(path: PathIsh) -> Path | None:
|
|
33
|
+
candidate = Path(path)
|
|
34
|
+
return candidate if candidate.exists() else None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def put(path: PathIsh, data: Puttable) -> None:
|
|
38
|
+
target = Path(path)
|
|
39
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
match data:
|
|
41
|
+
case bytes():
|
|
42
|
+
_ = target.write_bytes(data)
|
|
43
|
+
case str():
|
|
44
|
+
_ = target.write_text(data)
|
|
45
|
+
case Path():
|
|
46
|
+
_ = copyfile(data, target)
|
|
47
|
+
case HasSave():
|
|
48
|
+
data.save(target)
|
|
49
|
+
case DataclassInstance():
|
|
50
|
+
encoded = encode_json(target, data)
|
|
51
|
+
if isinstance(encoded, bytes):
|
|
52
|
+
_ = target.write_bytes(encoded)
|
|
53
|
+
else:
|
|
54
|
+
_ = target.write_text(encoded)
|
|
55
|
+
case _: # pragma: no cover - closed-union defense
|
|
56
|
+
assert_never(data)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def load(path: PathIsh, decoder: LoadSpec[LoadT]) -> LoadT | Exception:
|
|
60
|
+
target = Path(path)
|
|
61
|
+
try:
|
|
62
|
+
if isinstance(decoder, type):
|
|
63
|
+
return cast(LoadT, decode_json(target, decoder))
|
|
64
|
+
return decoder(target)
|
|
65
|
+
except Exception as error:
|
|
66
|
+
return error
|