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.
- pytypehintstore/__init__.py +11 -0
- pytypehintstore/codec.py +249 -0
- pytypehintstore/errors.py +19 -0
- pytypehintstore/fingerprint.py +80 -0
- pytypehintstore/lockfile.py +199 -0
- pytypehintstore/py.typed +0 -0
- pytypehintstore/store.py +421 -0
- pytypehintstore-0.0.4.dist-info/METADATA +369 -0
- pytypehintstore-0.0.4.dist-info/RECORD +11 -0
- pytypehintstore-0.0.4.dist-info/WHEEL +4 -0
- pytypehintstore-0.0.4.dist-info/licenses/LICENSE +21 -0
pytypehintstore/codec.py
ADDED
|
@@ -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)
|
pytypehintstore/py.typed
ADDED
|
File without changes
|