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 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