pytypehintstore 0.0.4__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.
@@ -0,0 +1,11 @@
1
+ from pytypehintstore.errors import StoreError, StoreLoadError, StoreLockedError
2
+ from pytypehintstore.store import store_of
3
+
4
+ __version__ = "0.0.4"
5
+
6
+ __all__ = [
7
+ "store_of",
8
+ "StoreError",
9
+ "StoreLockedError",
10
+ "StoreLoadError",
11
+ ]
@@ -0,0 +1,249 @@
1
+ """The transport form of a row: ISO text for a date, the member name for an
2
+ enum, an object for a nested dataclass.
3
+
4
+ Nothing here validates. `decode` converts only where the shape is the single
5
+ possible reading; anything else travels intact for the core to reject.
6
+ """
7
+
8
+ from datetime import date, time
9
+
10
+ from pytypehint import Date, EnumShape, Float, Int, List, Str, Struct, Time
11
+
12
+ # Reserved keys of the discriminated transport. A field name is an identifier,
13
+ # so it can never collide with either.
14
+ _TYPE = "$type"
15
+ _VALUE = "$value"
16
+
17
+
18
+ # The JSON type a value is written as: a date, a time and an enum member all
19
+ # arrive as text and compete with a str. This decides whether an option needs
20
+ # naming on the wire.
21
+ def _wire_type(shape):
22
+ if type(shape) is Struct:
23
+ return dict
24
+
25
+ if type(shape) in (Date, Time, EnumShape):
26
+ return str
27
+
28
+ return shape.pytype
29
+
30
+
31
+ # The type the core routes on, once decode has turned text back into values:
32
+ # this decides whether the core still wants the wrapper.
33
+ def _core_type(shape):
34
+ return dict if type(shape) is Struct else shape.pytype
35
+
36
+
37
+ def _field(fields, name):
38
+ return next((f for f in fields if f.name == name), None)
39
+
40
+
41
+ # Two lists share a Python type and differ only in what they hold, so
42
+ # list[str] and list[int] are settled by reading their items.
43
+ def _branch_of(shapes, value):
44
+ candidates = [s for s in shapes if type(value) is s.pytype]
45
+
46
+ if len(candidates) < 2:
47
+ return candidates[0] if candidates else None
48
+
49
+ return next((s for s in candidates if _accepts(s, value)), candidates[0])
50
+
51
+
52
+ def _accepts(shape, value) -> bool:
53
+ if type(shape) is not List:
54
+ return True
55
+
56
+ for item in value:
57
+ branch = _branch_of(shape.item, item)
58
+
59
+ if branch is None or not _accepts(branch, item):
60
+ return False
61
+
62
+ return True
63
+
64
+
65
+ def encode(struct, instance) -> dict:
66
+ return {f.name: _encode_options(f.shape, getattr(instance, f.name))
67
+ for f in struct.fields}
68
+
69
+
70
+ def _encode_options(shapes, value):
71
+ shape = _branch_of(shapes, value)
72
+
73
+ if shape is None:
74
+ # A field mutated by hand: no option owns its Python type. It travels
75
+ # intact for the core to judge. Where the transport is wider than the
76
+ # type — ISO text under a Date — the round trip rewrites the field
77
+ # instead of refusing it; see README, "Known limits".
78
+ return value
79
+
80
+ written = _encode_value(shape, value)
81
+
82
+ # The core names a dataclass variant inside the object itself.
83
+ if type(shape) is Struct:
84
+ if sum(1 for s in shapes if type(s) is Struct) > 1:
85
+ return {_TYPE: shape.cls.__name__, **written}
86
+
87
+ return written
88
+
89
+ # One option per JSON type needs no discriminator; when several share one,
90
+ # the option gets named.
91
+ if sum(1 for s in shapes if _wire_type(s) is _wire_type(shape)) > 1:
92
+ return {_TYPE: shape.option_id(), _VALUE: written}
93
+
94
+ return written
95
+
96
+
97
+ def _encode_value(shape, value):
98
+ if type(shape) is Struct:
99
+ return encode(shape, value)
100
+
101
+ if type(shape) is EnumShape:
102
+ # The name, not the value: the stable, readable half of a member.
103
+ return value.name
104
+
105
+ if type(shape) in (Date, Time):
106
+ return value.isoformat()
107
+
108
+ if type(shape) is List:
109
+ return [_encode_options(shape.item, item) for item in value]
110
+
111
+ return value
112
+
113
+
114
+ def decode(struct, data):
115
+ if type(data) is not dict:
116
+ return data
117
+
118
+ result = {}
119
+
120
+ for key, value in data.items():
121
+ field = _field(struct.fields, key)
122
+
123
+ # An unknown key travels intact; rejecting it is build()'s job.
124
+ result[key] = (value if field is None
125
+ else _decode_options(field.shape, value))
126
+
127
+ return result
128
+
129
+
130
+ def _decode_options(shapes, value):
131
+ # A None is a value, not a path to descend; an omitted key never gets here
132
+ # at all and takes its default.
133
+ if value is None:
134
+ return None
135
+
136
+ if type(value) is dict:
137
+ return _decode_dict(shapes, value)
138
+
139
+ if type(value) is list:
140
+ return _decode_list(shapes, value)
141
+
142
+ if type(value) is str:
143
+ return _decode_str(shapes, value)
144
+
145
+ # `type(value) is int` excludes bool, so a JSON true is never a number here
146
+ # despite bool subclassing int. Coerce only where Float is the single
147
+ # numeric reading.
148
+ if type(value) is int:
149
+ if (any(type(s) is Float for s in shapes)
150
+ and not any(type(s) is Int for s in shapes)):
151
+ return float(value)
152
+
153
+ return value
154
+
155
+
156
+ def _decode_dict(shapes, value):
157
+ if _TYPE in value and _VALUE in value:
158
+ # The wrapper is those two keys and nothing else. A key beside them, or
159
+ # a $value that is itself a wrapper, is a hand edit the core refuses —
160
+ # and reading it here would erase it from the next dump without a word.
161
+ if len(value) > 2 or (type(value[_VALUE]) is dict and _TYPE in value[_VALUE]):
162
+ return value
163
+
164
+ # A Struct never travels this way, and its option_id is a bare class
165
+ # name an enum is allowed to share: reading one here would hand a str
166
+ # to a dataclass branch and lose the enum.
167
+ shape = next((s for s in shapes if type(s) is not Struct
168
+ and s.option_id() == value[_TYPE]), None)
169
+
170
+ if shape is None:
171
+ return value
172
+
173
+ inner = _decode_options((shape,), value[_VALUE])
174
+
175
+ # The text did not read as the option the file names. The wrapper is
176
+ # what says so; consuming it would file a broken date under str.
177
+ if type(inner) is not _core_type(shape):
178
+ return {_TYPE: value[_TYPE], _VALUE: inner}
179
+
180
+ # The core wants the wrapper only where two options share a Python
181
+ # type. A collision that was the wire's alone is already resolved.
182
+ if sum(1 for s in shapes if _core_type(s) is _core_type(shape)) > 1:
183
+ return {_TYPE: value[_TYPE], _VALUE: inner}
184
+
185
+ return inner
186
+
187
+ if _TYPE in value:
188
+ struct = next((s for s in shapes if type(s) is Struct
189
+ and s.cls.__name__ == value[_TYPE]), None)
190
+
191
+ if struct is None:
192
+ return value
193
+
194
+ inner = {k: v for k, v in value.items() if k != _TYPE}
195
+ return {_TYPE: value[_TYPE], **decode(struct, inner)}
196
+
197
+ structs = [s for s in shapes if type(s) is Struct]
198
+
199
+ if len(structs) == 1:
200
+ return decode(structs[0], value)
201
+
202
+ return value
203
+
204
+
205
+ def _decode_list(shapes, value):
206
+ lists = [s for s in shapes if type(s) is List]
207
+
208
+ # A union of lists collides on the transport type and travels wrapped, so
209
+ # a bare list means a single List branch.
210
+ if len(lists) == 1:
211
+ return [_decode_options(lists[0].item, item) for item in value]
212
+
213
+ return value
214
+
215
+
216
+ def _decode_str(shapes, value):
217
+ # A Str reading keeps a string a string. Otherwise convert only where a
218
+ # single Date, Time or enum is the one reading.
219
+ if any(type(s) is Str for s in shapes):
220
+ return value
221
+
222
+ dates = [s for s in shapes if type(s) is Date]
223
+ times = [s for s in shapes if type(s) is Time]
224
+ enums = [s for s in shapes if type(s) is EnumShape]
225
+
226
+ if (bool(dates) + bool(times) + bool(enums)) != 1:
227
+ return value
228
+
229
+ if len(dates) == 1:
230
+ return _convert(date.fromisoformat, value)
231
+
232
+ if len(times) == 1:
233
+ return _convert(time.fromisoformat, value)
234
+
235
+ if len(enums) == 1:
236
+ # __members__ resolves an alias to its canonical member, and keeps a
237
+ # mixin out of the way: a StrEnum inherits str.__getitem__, which
238
+ # cls[name] would reach instead of the member lookup.
239
+ return _convert(enums[0].cls.__members__.__getitem__, value)
240
+
241
+ return value
242
+
243
+
244
+ def _convert(fn, value):
245
+ try:
246
+ return fn(value)
247
+ except (KeyError, ValueError):
248
+ # Not a reading decode can make: build() rejects it.
249
+ return value
@@ -0,0 +1,19 @@
1
+ """What a store raises on its own behalf.
2
+
3
+ A validation failure is not one of these: `add` and `put` let the core's
4
+ SchemaTypeError / SchemaValueError travel out untouched, so the caller sees the
5
+ same line pytypehint would have produced anywhere else. These three cover what
6
+ only a store can go wrong at: the file and the lock.
7
+ """
8
+
9
+
10
+ class StoreError(Exception):
11
+ """Base of every failure a store reports for itself."""
12
+
13
+
14
+ class StoreLockedError(StoreError):
15
+ """Another live process already owns this file."""
16
+
17
+
18
+ class StoreLoadError(StoreError):
19
+ """The file on disk could not be read back into rows."""
@@ -0,0 +1,80 @@
1
+ """The identity of a schema: eight hex that move when the contract moves."""
2
+
3
+ import hashlib
4
+ import json
5
+ from dataclasses import fields, is_dataclass
6
+ from datetime import date, time
7
+ from enum import Enum
8
+
9
+ from pytypehint import MISSING
10
+
11
+ _LENGTH = 8
12
+
13
+
14
+ def fingerprint(schema) -> str:
15
+ """Eight hex of the sha256 of `schema` written as deterministic text."""
16
+ text = json.dumps(_plain(schema, (), {}), sort_keys=True)
17
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()[:_LENGTH]
18
+
19
+
20
+ def _plain(value, seen, done):
21
+ """`value` as text json can render.
22
+
23
+ `seen` is the chain of dataclasses above it, so a recursive schema stops
24
+ instead of descending forever. `done` is every dataclass already written,
25
+ because the core compiles a class once and shares that object between every
26
+ field naming it: the schema is a graph, and walking it as a tree costs a
27
+ path per branch — minutes for a class the core compiles in a millisecond.
28
+ """
29
+ if value is MISSING:
30
+ return "MISSING"
31
+
32
+ if value is None or type(value) in (bool, int, float, str):
33
+ return value
34
+
35
+ if is_dataclass(value) and not isinstance(value, type):
36
+ if id(value) in seen:
37
+ return f"<{type(value).__name__}>"
38
+
39
+ if id(value) in done:
40
+ return {"$ref": done[id(value)]}
41
+
42
+ done[id(value)] = len(done)
43
+
44
+ # Only the fields the core compares. The rest are derived — a compiled
45
+ # regex, the recipe behind a default that is already materialised — and
46
+ # a compiled regex has no text to read.
47
+ return {type(value).__name__: {
48
+ f.name: _plain(getattr(value, f.name), (*seen, id(value)), done)
49
+ for f in fields(value) if f.compare}}
50
+
51
+ if isinstance(value, type):
52
+ if issubclass(value, Enum):
53
+ # The member names are contract: renaming one is another database.
54
+ return [value.__name__, list(value.__members__)]
55
+
56
+ return value.__name__
57
+
58
+ if isinstance(value, Enum):
59
+ return value.name
60
+
61
+ if isinstance(value, (date, time)):
62
+ return value.isoformat()
63
+
64
+ if isinstance(value, dict):
65
+ return {str(key): _plain(item, seen, done) for key, item in value.items()}
66
+
67
+ if isinstance(value, (set, frozenset)):
68
+ return sorted(repr(item) for item in value)
69
+
70
+ if isinstance(value, (list, tuple)):
71
+ return [_plain(item, seen, done) for item in value]
72
+
73
+ if callable(value):
74
+ return _plain(value(), seen, done)
75
+
76
+ # No repr() of courtesy: a repr carries memory addresses, and a hash built
77
+ # on one renames every database on the next run. If the core grows a type
78
+ # this does not know, the store must fail loudly at open.
79
+ raise TypeError(
80
+ f"cannot fingerprint a {type(value).__name__}: it has no stable text")
@@ -0,0 +1,199 @@
1
+ """One process owns a store.
2
+
3
+ The lockfile holds the owner's PID, and who holds it is settled by the operating
4
+ system rather than by agreement between readers: an open handle on Windows, an
5
+ advisory `flock` on POSIX. A crash leaves an orphan the next process takes over,
6
+ and two processes racing for that orphan cannot both end up owning it.
7
+
8
+ The contract is the same on both: one owner, orphans reclaimed, and the same
9
+ error naming the PID that holds it.
10
+ """
11
+
12
+ import ctypes
13
+ import os
14
+ import re
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ from pytypehintstore.errors import StoreLockedError
19
+
20
+ # str.isdigit() is not this question: it says yes to a superscript, which int()
21
+ # then refuses, and to an Arabic-Indic digit, which int() reads as a number no
22
+ # process here ever had.
23
+ _PID = re.compile(r"[0-9]{1,9}")
24
+
25
+
26
+ def lock_path_of(path: Path) -> Path:
27
+ return path.with_name(path.name + ".lock")
28
+
29
+
30
+ def acquire(path: Path, name: str):
31
+ """Take the lock for `path`, or say who owns it. Returns the held lock."""
32
+ lock = lock_path_of(path)
33
+
34
+ # Two rounds at most: claim, and if something stale was in the way, clear it
35
+ # and claim again. A third failure means someone else won the race, and that
36
+ # someone is a live owner worth reporting.
37
+ for _ in range(2):
38
+ fd = _claim(lock)
39
+
40
+ if fd is not None:
41
+ os.ftruncate(fd, 0)
42
+ os.write(fd, str(os.getpid()).encode("utf-8"))
43
+ return lock, fd
44
+
45
+ pid = _owner(lock)
46
+
47
+ if pid is not None and alive(pid):
48
+ raise StoreLockedError(
49
+ f"{name} is owned by process {pid}. A store belongs to one "
50
+ f"process; you are probably running several workers. Run a "
51
+ f"single worker, or reach for a database server — this is not one.")
52
+
53
+ _clear(lock)
54
+
55
+ raise StoreLockedError(
56
+ f"{name} is locked by another process that keeps retaking "
57
+ f"{lock.name}. Stop it, or delete {lock.name} by hand.")
58
+
59
+
60
+ def release(held) -> None:
61
+ lock, fd = held
62
+
63
+ try:
64
+ _let_go(lock, fd)
65
+ except OSError:
66
+ # Removed by hand, or the disk is gone: neither is worth failing a
67
+ # close over.
68
+ pass
69
+
70
+
71
+ def _owner(lock: Path):
72
+ try:
73
+ text = lock.read_bytes().decode("utf-8", "replace").strip()
74
+ except OSError:
75
+ return None
76
+
77
+ return int(text) if _PID.fullmatch(text) else None
78
+
79
+
80
+ def alive(pid: int) -> bool:
81
+ """Whether a process with this id is running.
82
+
83
+ A id of zero or less is nobody: on POSIX it would reach a process group
84
+ instead of a process.
85
+ """
86
+ return pid > 0 and _running(pid)
87
+
88
+
89
+ # The two halves below hold one contract each way. They are declared under
90
+ # `sys.platform` rather than `os.name` because that is the form a type checker
91
+ # reads as "this is for that platform only", so neither half is looked up on
92
+ # the system that does not have it.
93
+
94
+ if sys.platform == "win32":
95
+ # os.kill is not a probe on Windows: signal 0 reaches TerminateProcess.
96
+ # Ask the kernel instead — and tell ctypes what it is calling, because a
97
+ # handle is a pointer and the default return type would cut a large one in
98
+ # half, silently, since the halves of a handle look like a handle.
99
+ _PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
100
+ _SYNCHRONIZE = 0x00100000
101
+ _ERROR_ACCESS_DENIED = 5
102
+ _WAIT_OBJECT_0 = 0
103
+
104
+ _kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
105
+ _kernel32.OpenProcess.argtypes = (ctypes.c_ulong, ctypes.c_int, ctypes.c_ulong)
106
+ _kernel32.OpenProcess.restype = ctypes.c_void_p
107
+ _kernel32.WaitForSingleObject.argtypes = (ctypes.c_void_p, ctypes.c_ulong)
108
+ _kernel32.WaitForSingleObject.restype = ctypes.c_ulong
109
+ _kernel32.CloseHandle.argtypes = (ctypes.c_void_p,)
110
+ _kernel32.CloseHandle.restype = ctypes.c_int
111
+
112
+ def _running(pid: int) -> bool:
113
+ handle = _kernel32.OpenProcess(
114
+ _PROCESS_QUERY_LIMITED_INFORMATION | _SYNCHRONIZE, False, pid)
115
+
116
+ if not handle:
117
+ # Denied means the process is there and belongs to someone else;
118
+ # anything else (no such PID) means it is gone.
119
+ return ctypes.get_last_error() == _ERROR_ACCESS_DENIED
120
+
121
+ try:
122
+ # A process that has ended is signalled, and waiting zero
123
+ # milliseconds asks that without waiting. Its exit code cannot
124
+ # answer: 259 is both "exited with 259" and "still running", and
125
+ # that lock would never be recovered.
126
+ return _kernel32.WaitForSingleObject(handle, 0) != _WAIT_OBJECT_0
127
+ finally:
128
+ _kernel32.CloseHandle(handle)
129
+
130
+ def _claim(lock: Path):
131
+ """O_EXCL decides, and the handle stays open for as long as the lock is
132
+ held: Windows refuses to unlink an open file, so the loser of a race
133
+ cannot delete the winner's brand new lockfile."""
134
+ try:
135
+ return os.open(lock, os.O_CREAT | os.O_EXCL | os.O_RDWR)
136
+ except FileExistsError:
137
+ return None
138
+
139
+ def _clear(lock: Path) -> None:
140
+ try:
141
+ os.unlink(lock)
142
+ except OSError:
143
+ # Gone already, or held open by the process that took it while we
144
+ # were deciding it was abandoned. O_EXCL settles the next round.
145
+ pass
146
+
147
+ def _let_go(lock: Path, fd: int) -> None:
148
+ # The handle first: the file cannot be unlinked while it is open, and a
149
+ # lock nobody can remove outlives its owner.
150
+ os.close(fd)
151
+ os.unlink(lock)
152
+
153
+ else:
154
+ import fcntl
155
+
156
+ def _running(pid: int) -> bool:
157
+ try:
158
+ os.kill(pid, 0)
159
+ except ProcessLookupError:
160
+ return False
161
+ except PermissionError:
162
+ # Someone else's process, and it exists.
163
+ return True
164
+
165
+ return True
166
+
167
+ def _claim(lock: Path):
168
+ """`flock` decides, and the kernel drops it when the process dies.
169
+
170
+ An orphan therefore needs no stealing: it is simply a file nobody
171
+ holds, and the next claim takes it. The inode is checked afterwards
172
+ because another process may have unlinked the file between the open and
173
+ the lock, and a lock on a file with no name guards nothing.
174
+ """
175
+ fd = os.open(lock, os.O_CREAT | os.O_RDWR)
176
+
177
+ try:
178
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
179
+
180
+ if os.fstat(fd).st_ino != os.stat(lock).st_ino:
181
+ raise OSError("the lockfile was replaced while it was claimed")
182
+ except OSError:
183
+ os.close(fd)
184
+ return None
185
+
186
+ return fd
187
+
188
+ def _clear(lock: Path) -> None:
189
+ # Nothing to clear: what makes an orphan an orphan is that its flock
190
+ # died with its owner, and unlinking here would only race with whoever
191
+ # is about to claim it.
192
+ pass
193
+
194
+ def _let_go(lock: Path, fd: int) -> None:
195
+ # The name first, while the lock is still ours: between the unlink and
196
+ # the close, a claim can only create a file of its own and win it
197
+ # outright, which is exactly what should happen.
198
+ os.unlink(lock)
199
+ os.close(fd)
File without changes