cistula 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.
- cistula/__init__.py +60 -0
- cistula/digest.py +39 -0
- cistula/files.py +460 -0
- cistula/process.py +326 -0
- cistula/py.typed +0 -0
- cistula-0.1.0.dist-info/METADATA +379 -0
- cistula-0.1.0.dist-info/RECORD +10 -0
- cistula-0.1.0.dist-info/WHEEL +5 -0
- cistula-0.1.0.dist-info/licenses/LICENSE +190 -0
- cistula-0.1.0.dist-info/top_level.txt +1 -0
cistula/__init__.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Lusoris <lusoris@proton.me>
|
|
2
|
+
# SPDX-License-Identifier: EUPL-1.2
|
|
3
|
+
"""Cistula: shared Python helpers of the Plurano framework family.
|
|
4
|
+
|
|
5
|
+
Three modules, standard library only, none of which contacts a network:
|
|
6
|
+
|
|
7
|
+
- `process`: run a child process under a required time limit.
|
|
8
|
+
- `files`: strict JSON reading and atomic file writing, in one call or in a block.
|
|
9
|
+
- `digest`: SHA-256 digests.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from cistula.digest import sha256_bytes, sha256_file, sha256_text
|
|
15
|
+
from cistula.files import (
|
|
16
|
+
DuplicateKeyError,
|
|
17
|
+
JsonValue,
|
|
18
|
+
loads_json,
|
|
19
|
+
open_bytes_atomic,
|
|
20
|
+
open_text_atomic,
|
|
21
|
+
read_json,
|
|
22
|
+
staged_path,
|
|
23
|
+
write_bytes_atomic,
|
|
24
|
+
write_json,
|
|
25
|
+
write_text_atomic,
|
|
26
|
+
)
|
|
27
|
+
from cistula.process import (
|
|
28
|
+
CommandFailedError,
|
|
29
|
+
CommandNotFoundError,
|
|
30
|
+
CommandNotStartedError,
|
|
31
|
+
CommandTimeoutError,
|
|
32
|
+
ProcessError,
|
|
33
|
+
Result,
|
|
34
|
+
child_env,
|
|
35
|
+
run,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"CommandFailedError",
|
|
40
|
+
"CommandNotFoundError",
|
|
41
|
+
"CommandNotStartedError",
|
|
42
|
+
"CommandTimeoutError",
|
|
43
|
+
"DuplicateKeyError",
|
|
44
|
+
"JsonValue",
|
|
45
|
+
"ProcessError",
|
|
46
|
+
"Result",
|
|
47
|
+
"child_env",
|
|
48
|
+
"loads_json",
|
|
49
|
+
"open_bytes_atomic",
|
|
50
|
+
"open_text_atomic",
|
|
51
|
+
"read_json",
|
|
52
|
+
"run",
|
|
53
|
+
"sha256_bytes",
|
|
54
|
+
"sha256_file",
|
|
55
|
+
"sha256_text",
|
|
56
|
+
"staged_path",
|
|
57
|
+
"write_bytes_atomic",
|
|
58
|
+
"write_json",
|
|
59
|
+
"write_text_atomic",
|
|
60
|
+
]
|
cistula/digest.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Lusoris <lusoris@proton.me>
|
|
2
|
+
# SPDX-License-Identifier: EUPL-1.2
|
|
3
|
+
"""SHA-256 digests as lower-case hexadecimal text.
|
|
4
|
+
|
|
5
|
+
`sha256_file` reads the file in pieces, so its memory use does not grow with
|
|
6
|
+
the size of the file.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
import os
|
|
16
|
+
|
|
17
|
+
__all__ = ["sha256_bytes", "sha256_file", "sha256_text"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def sha256_file(path: str | os.PathLike[str]) -> str:
|
|
21
|
+
"""Return the SHA-256 digest of the file's content. A missing file raises `OSError`."""
|
|
22
|
+
with open(path, "rb") as handle:
|
|
23
|
+
return hashlib.file_digest(handle, "sha256").hexdigest()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def sha256_bytes(data: bytes | bytearray | memoryview) -> str:
|
|
27
|
+
"""Return the SHA-256 digest of `data`; text is refused, use `sha256_text`."""
|
|
28
|
+
if not isinstance(data, (bytes, bytearray, memoryview)):
|
|
29
|
+
message = f"sha256_bytes needs bytes, not {type(data).__name__}"
|
|
30
|
+
raise TypeError(message)
|
|
31
|
+
return hashlib.sha256(data).hexdigest()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def sha256_text(text: str) -> str:
|
|
35
|
+
"""Return the SHA-256 digest of `text` encoded as UTF-8."""
|
|
36
|
+
if not isinstance(text, str):
|
|
37
|
+
message = f"sha256_text needs text, not {type(text).__name__}"
|
|
38
|
+
raise TypeError(message)
|
|
39
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
cistula/files.py
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Lusoris <lusoris@proton.me>
|
|
2
|
+
# SPDX-License-Identifier: EUPL-1.2
|
|
3
|
+
"""Read JSON strictly and write files so that a reader never sees half of one.
|
|
4
|
+
|
|
5
|
+
The readers decode UTF-8 and refuse two things the standard library accepts
|
|
6
|
+
silently: an object that names the same key twice (it keeps the last value)
|
|
7
|
+
and a number JSON does not have (NaN, an infinity, a number too large for a
|
|
8
|
+
float).
|
|
9
|
+
|
|
10
|
+
The writers put the new content into a temporary file beside the target, flush
|
|
11
|
+
it to disk and rename it over the target in one step: after a failure or a
|
|
12
|
+
crash the target holds either the old content or the new, never a mixture. A
|
|
13
|
+
writer called with `durable=False` leaves out the flush and with it the promise
|
|
14
|
+
about a crash of the machine. A writer called with `parents=True` first creates
|
|
15
|
+
the directory of the target where it is missing.
|
|
16
|
+
|
|
17
|
+
Three of the writers are blocks for content that is not one value in memory.
|
|
18
|
+
`open_bytes_atomic` and `open_text_atomic` lend an open file to write into
|
|
19
|
+
step by step; `staged_path` lends the name of the temporary file to a producer
|
|
20
|
+
that opens the file itself. All writers share one implementation of the
|
|
21
|
+
temporary file, the flush, the rename and the clean-up.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import contextlib
|
|
27
|
+
import errno
|
|
28
|
+
import io
|
|
29
|
+
import json
|
|
30
|
+
import math
|
|
31
|
+
import os
|
|
32
|
+
import secrets
|
|
33
|
+
import stat
|
|
34
|
+
from typing import TYPE_CHECKING, NamedTuple, TypeAlias, cast
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from collections.abc import Iterator
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"DuplicateKeyError",
|
|
41
|
+
"JsonValue",
|
|
42
|
+
"loads_json",
|
|
43
|
+
"open_bytes_atomic",
|
|
44
|
+
"open_text_atomic",
|
|
45
|
+
"read_json",
|
|
46
|
+
"staged_path",
|
|
47
|
+
"write_bytes_atomic",
|
|
48
|
+
"write_json",
|
|
49
|
+
"write_text_atomic",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
# Not `type` statements: Python 3.11 does not have them.
|
|
53
|
+
JsonValue: TypeAlias = bool | int | float | str | list["JsonValue"] | dict[str, "JsonValue"] | None
|
|
54
|
+
|
|
55
|
+
_Path: TypeAlias = str | os.PathLike[str]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class DuplicateKeyError(ValueError):
|
|
59
|
+
"""A JSON object names the same key more than once."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, key: str) -> None:
|
|
62
|
+
"""Record the repeated `key`."""
|
|
63
|
+
super().__init__(f"duplicate key in JSON object: {key!r}")
|
|
64
|
+
self.key = key
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _unique(pairs: list[tuple[str, JsonValue]]) -> dict[str, JsonValue]:
|
|
68
|
+
found: dict[str, JsonValue] = {}
|
|
69
|
+
for key, value in pairs:
|
|
70
|
+
if key in found:
|
|
71
|
+
raise DuplicateKeyError(key)
|
|
72
|
+
found[key] = value
|
|
73
|
+
return found
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _refuse_constant(name: str) -> float:
|
|
77
|
+
message = f"not a JSON number: {name}"
|
|
78
|
+
raise ValueError(message)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _finite(text: str) -> float:
|
|
82
|
+
value = float(text)
|
|
83
|
+
if math.isinf(value):
|
|
84
|
+
message = f"number too large for a float: {text[:40]}"
|
|
85
|
+
raise ValueError(message)
|
|
86
|
+
return value
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def loads_json(text: str, *, unique_keys: bool = True, finite_numbers: bool = True) -> JsonValue:
|
|
90
|
+
"""Parse a JSON document strictly.
|
|
91
|
+
|
|
92
|
+
An object with a repeated key raises `DuplicateKeyError`;
|
|
93
|
+
`unique_keys=False` restores the standard library's behaviour of keeping
|
|
94
|
+
the last value.
|
|
95
|
+
|
|
96
|
+
`NaN`, `Infinity` and `-Infinity`, which JSON does not have, and a number
|
|
97
|
+
too large for a float raise `ValueError`. `finite_numbers=False` restores
|
|
98
|
+
the standard library's behaviour: the three names are read as these
|
|
99
|
+
numbers and a number too large is read as an infinity. `write_json`
|
|
100
|
+
refuses to write any of them, so only a document read with the default can
|
|
101
|
+
always be written back.
|
|
102
|
+
|
|
103
|
+
Each keyword lifts its one check and nothing else. Everything else is
|
|
104
|
+
`json.loads`: malformed text raises `json.JSONDecodeError`. All of these
|
|
105
|
+
errors are `ValueError`.
|
|
106
|
+
"""
|
|
107
|
+
if not isinstance(text, str):
|
|
108
|
+
message = f"loads_json needs text, not {type(text).__name__}; read_json reads a file"
|
|
109
|
+
raise TypeError(message)
|
|
110
|
+
return cast(
|
|
111
|
+
"JsonValue",
|
|
112
|
+
json.loads(
|
|
113
|
+
text,
|
|
114
|
+
object_pairs_hook=_unique if unique_keys else None,
|
|
115
|
+
parse_constant=_refuse_constant if finite_numbers else None,
|
|
116
|
+
parse_float=_finite if finite_numbers else None,
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def read_json(path: _Path, *, unique_keys: bool = True, finite_numbers: bool = True) -> JsonValue:
|
|
122
|
+
"""Read a UTF-8 JSON file strictly; see `loads_json` for the two keywords.
|
|
123
|
+
|
|
124
|
+
An object with a repeated key raises `DuplicateKeyError`; NaN, an
|
|
125
|
+
infinity and a number too large for a float raise `ValueError`. Bytes that
|
|
126
|
+
are not UTF-8 raise `UnicodeDecodeError`, malformed JSON (including a
|
|
127
|
+
byte-order mark) raises `json.JSONDecodeError`. All of these are
|
|
128
|
+
`ValueError`. A missing or unreadable file raises `OSError`.
|
|
129
|
+
"""
|
|
130
|
+
with open(path, "rb") as handle:
|
|
131
|
+
text = handle.read().decode("utf-8")
|
|
132
|
+
return loads_json(text, unique_keys=unique_keys, finite_numbers=finite_numbers)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _sync_directory(directory: str) -> None:
|
|
136
|
+
"""Flush the entries of `directory` to disk: a renamed file, a new directory."""
|
|
137
|
+
descriptor = os.open(directory, os.O_RDONLY | os.O_DIRECTORY)
|
|
138
|
+
try:
|
|
139
|
+
os.fsync(descriptor)
|
|
140
|
+
finally:
|
|
141
|
+
os.close(descriptor)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _mode_to_keep(target: str) -> int | None:
|
|
145
|
+
"""Return the permissions the new file takes over, or `None` for the default.
|
|
146
|
+
|
|
147
|
+
An existing regular file keeps its permissions. A new file gets what any
|
|
148
|
+
newly created file would get (the process umask applies), and so does the
|
|
149
|
+
regular file that replaces a symbolic link: the link's own mode says nothing.
|
|
150
|
+
"""
|
|
151
|
+
try:
|
|
152
|
+
found = os.lstat(target).st_mode
|
|
153
|
+
except FileNotFoundError:
|
|
154
|
+
return None
|
|
155
|
+
return stat.S_IMODE(found) if stat.S_ISREG(found) else None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _switch(name: str, value: object) -> None:
|
|
159
|
+
"""Refuse a value other than `True` or `False` for the keyword `name`."""
|
|
160
|
+
if not isinstance(value, bool):
|
|
161
|
+
message = f"{name} must be True or False, not {type(value).__name__}"
|
|
162
|
+
raise TypeError(message)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _create_directories(directory: str, *, durable: bool) -> None:
|
|
166
|
+
"""Create `directory` and every missing directory above it, the highest first.
|
|
167
|
+
|
|
168
|
+
A new directory gets the permissions of any newly created directory. With
|
|
169
|
+
`durable`, the directory above each new one is flushed to disk; `directory`
|
|
170
|
+
itself is flushed by the caller, after the rename.
|
|
171
|
+
"""
|
|
172
|
+
missing: list[str] = []
|
|
173
|
+
current = directory
|
|
174
|
+
# Every step up removes at least one separator, so this bound is never short.
|
|
175
|
+
for _ in range(directory.count(os.sep) + 1):
|
|
176
|
+
if os.path.lexists(current):
|
|
177
|
+
break
|
|
178
|
+
missing.append(current)
|
|
179
|
+
current = os.path.dirname(current) or "."
|
|
180
|
+
for new in reversed(missing):
|
|
181
|
+
try:
|
|
182
|
+
os.mkdir(new)
|
|
183
|
+
except FileExistsError:
|
|
184
|
+
# Another writer was faster, or the path names a directory a second way (`a/..`).
|
|
185
|
+
if not os.path.isdir(new):
|
|
186
|
+
raise
|
|
187
|
+
if durable:
|
|
188
|
+
_sync_directory(os.path.dirname(new) or ".")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class _Stage(NamedTuple):
|
|
192
|
+
"""One write: the target, its directory and the temporary file beside it."""
|
|
193
|
+
|
|
194
|
+
target: str
|
|
195
|
+
directory: str
|
|
196
|
+
temporary: str
|
|
197
|
+
mode: int | None
|
|
198
|
+
durable: bool
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _stage(path: _Path, *, durable: bool, parents: bool, block: bool = False, suffix: bool = False) -> _Stage:
|
|
202
|
+
"""Check the arguments, create the directory if asked to and name the temporary file.
|
|
203
|
+
|
|
204
|
+
`block` is for a write that runs code of the caller before it is done: a
|
|
205
|
+
relative path is tied to the current directory now, so that this code may
|
|
206
|
+
change the directory. `suffix` repeats the suffix of the target at the end
|
|
207
|
+
of the temporary name, for a producer that reads the format from the name.
|
|
208
|
+
"""
|
|
209
|
+
_switch("durable", durable)
|
|
210
|
+
_switch("parents", parents)
|
|
211
|
+
target = os.fspath(path)
|
|
212
|
+
if not isinstance(target, str):
|
|
213
|
+
message = f"path must be text or a text path, not {type(target).__name__}"
|
|
214
|
+
raise TypeError(message)
|
|
215
|
+
directory, name = os.path.split(target)
|
|
216
|
+
if not name:
|
|
217
|
+
message = f"not a file path: {target!r}"
|
|
218
|
+
raise ValueError(message)
|
|
219
|
+
directory = directory or "."
|
|
220
|
+
if block and not os.path.isabs(directory):
|
|
221
|
+
# Joined, not normalised: `a/..` keeps the meaning the file system gives it.
|
|
222
|
+
directory = os.path.join(os.getcwd(), directory)
|
|
223
|
+
target = os.path.join(directory, name)
|
|
224
|
+
if parents:
|
|
225
|
+
_create_directories(directory, durable=durable)
|
|
226
|
+
mode = _mode_to_keep(target)
|
|
227
|
+
ending = os.path.splitext(name)[1] if suffix else ""
|
|
228
|
+
temporary = os.path.join(directory, f".{name}.{secrets.token_hex(8)}.tmp{ending}")
|
|
229
|
+
return _Stage(target, directory, temporary, mode, durable)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _remove(temporary: str) -> None:
|
|
233
|
+
"""Remove the temporary file after a failure; a directory a producer left there stays."""
|
|
234
|
+
try:
|
|
235
|
+
os.unlink(temporary)
|
|
236
|
+
except FileNotFoundError:
|
|
237
|
+
pass
|
|
238
|
+
except OSError:
|
|
239
|
+
# Which error a directory gives depends on the system; the caller gets the first error.
|
|
240
|
+
if not os.path.isdir(temporary):
|
|
241
|
+
raise
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@contextlib.contextmanager
|
|
245
|
+
def _published(stage: _Stage) -> Iterator[None]:
|
|
246
|
+
"""Rename the temporary file over the target after the block; remove it when anything fails."""
|
|
247
|
+
try:
|
|
248
|
+
yield
|
|
249
|
+
os.replace(stage.temporary, stage.target)
|
|
250
|
+
except BaseException:
|
|
251
|
+
_remove(stage.temporary)
|
|
252
|
+
raise
|
|
253
|
+
if stage.durable:
|
|
254
|
+
_sync_directory(stage.directory)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@contextlib.contextmanager
|
|
258
|
+
def _temporary_file(stage: _Stage) -> Iterator[int]:
|
|
259
|
+
"""Create the temporary file and lend its descriptor; then flush, close and publish it."""
|
|
260
|
+
descriptor = os.open(stage.temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC, 0o666)
|
|
261
|
+
with _published(stage):
|
|
262
|
+
try:
|
|
263
|
+
if stage.mode is not None:
|
|
264
|
+
os.fchmod(descriptor, stage.mode)
|
|
265
|
+
yield descriptor
|
|
266
|
+
if stage.durable:
|
|
267
|
+
os.fsync(descriptor)
|
|
268
|
+
finally:
|
|
269
|
+
os.close(descriptor)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _seal(stage: _Stage, *, allow_empty: bool) -> None:
|
|
273
|
+
"""Check the file a producer left at the temporary path and make it ready for the rename."""
|
|
274
|
+
# Not followed and not waited for: a link or a pipe left there is refused below, not opened.
|
|
275
|
+
flags = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC
|
|
276
|
+
try:
|
|
277
|
+
descriptor = os.open(stage.temporary, flags)
|
|
278
|
+
except FileNotFoundError as error:
|
|
279
|
+
message = "nothing was written to the staged path"
|
|
280
|
+
raise FileNotFoundError(errno.ENOENT, message, stage.temporary) from error
|
|
281
|
+
try:
|
|
282
|
+
found = os.fstat(descriptor)
|
|
283
|
+
if not stat.S_ISREG(found.st_mode):
|
|
284
|
+
message = f"not a regular file at the staged path: {stage.temporary!r}"
|
|
285
|
+
raise ValueError(message)
|
|
286
|
+
if found.st_size == 0 and not allow_empty:
|
|
287
|
+
message = f"an empty file at the staged path: {stage.temporary!r}; allow_empty=True publishes it"
|
|
288
|
+
raise ValueError(message)
|
|
289
|
+
if stage.mode is not None:
|
|
290
|
+
os.fchmod(descriptor, stage.mode)
|
|
291
|
+
if stage.durable:
|
|
292
|
+
os.fsync(descriptor)
|
|
293
|
+
finally:
|
|
294
|
+
os.close(descriptor)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def write_bytes_atomic(
|
|
298
|
+
path: _Path, data: bytes | bytearray | memoryview, *, durable: bool = True, parents: bool = False
|
|
299
|
+
) -> None:
|
|
300
|
+
"""Replace the file at `path` with `data` in one step.
|
|
301
|
+
|
|
302
|
+
When writing fails, `path` is unchanged and no temporary file stays
|
|
303
|
+
behind. Only a crash of the whole process can leave a temporary file,
|
|
304
|
+
named `.<name>.<random>.tmp`, beside the unchanged target.
|
|
305
|
+
|
|
306
|
+
The directory of `path` must exist: a missing one raises
|
|
307
|
+
`FileNotFoundError`. `parents=True` creates it first, together with every
|
|
308
|
+
missing directory above it, as `os.makedirs` does. Directories created
|
|
309
|
+
stay when the write then fails; the one step is the file, not its
|
|
310
|
+
directories. Nothing is created for a call that is refused for its
|
|
311
|
+
arguments.
|
|
312
|
+
|
|
313
|
+
Permissions: an existing file keeps its permissions. A new file gets the
|
|
314
|
+
permissions of any newly created file, so the process umask decides who
|
|
315
|
+
can read it (mode 644 under umask 022); no file is made private for the
|
|
316
|
+
caller. A symbolic link at `path` is replaced by a regular file with the
|
|
317
|
+
permissions of a new file, its target is left alone. A directory created
|
|
318
|
+
for `parents=True` follows the same rule: it gets the permissions of any
|
|
319
|
+
newly created directory (mode 755 under umask 022), and a directory that
|
|
320
|
+
exists is left as it is.
|
|
321
|
+
|
|
322
|
+
`durable=True` flushes the file to disk before the rename and the
|
|
323
|
+
directory after it, and the directory above each directory it created, so
|
|
324
|
+
the new content survives a power failure or a crash of the operating
|
|
325
|
+
system once the call has returned. `durable=False` leaves out every flush
|
|
326
|
+
and is much faster on a disk. It is not
|
|
327
|
+
crash-safe: the rename is still one step for every reader and after a
|
|
328
|
+
failed write, but after a power failure or a crash of the operating system
|
|
329
|
+
the target can hold the old content, the new content, or an empty or
|
|
330
|
+
incomplete file. Use it only for files that can be made again.
|
|
331
|
+
"""
|
|
332
|
+
if not isinstance(data, (bytes, bytearray, memoryview)):
|
|
333
|
+
message = f"write_bytes_atomic needs bytes, not {type(data).__name__}"
|
|
334
|
+
raise TypeError(message)
|
|
335
|
+
view = memoryview(data).cast("B")
|
|
336
|
+
with _temporary_file(_stage(path, durable=durable, parents=parents)) as descriptor:
|
|
337
|
+
written = 0
|
|
338
|
+
while written < len(view):
|
|
339
|
+
written += os.write(descriptor, view[written:])
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def write_text_atomic(path: _Path, text: str, *, durable: bool = True, parents: bool = False) -> None:
|
|
343
|
+
"""Replace the file at `path` with `text` as UTF-8 in one step.
|
|
344
|
+
|
|
345
|
+
Line endings are written as given. See `write_bytes_atomic` for what
|
|
346
|
+
"in one step" guarantees, for the permissions of the file and for
|
|
347
|
+
`durable` and `parents`.
|
|
348
|
+
"""
|
|
349
|
+
if not isinstance(text, str):
|
|
350
|
+
message = f"write_text_atomic needs text, not {type(text).__name__}"
|
|
351
|
+
raise TypeError(message)
|
|
352
|
+
write_bytes_atomic(path, text.encode("utf-8"), durable=durable, parents=parents)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def write_json( # noqa: PLR0913 - one keyword per independent setting of the written file
|
|
356
|
+
path: _Path,
|
|
357
|
+
value: JsonValue,
|
|
358
|
+
*,
|
|
359
|
+
indent: int | None = 2,
|
|
360
|
+
sort_keys: bool = True,
|
|
361
|
+
ensure_ascii: bool = False,
|
|
362
|
+
durable: bool = True,
|
|
363
|
+
parents: bool = False,
|
|
364
|
+
) -> None:
|
|
365
|
+
"""Replace the file at `path` with `value` as UTF-8 JSON in one step.
|
|
366
|
+
|
|
367
|
+
The document ends with a newline. Non-ASCII text is written as itself;
|
|
368
|
+
`ensure_ascii=True` writes each non-ASCII character as a Unicode escape,
|
|
369
|
+
as the standard serialiser does. A value JSON cannot represent is refused
|
|
370
|
+
before anything is written: NaN and the two infinities raise `ValueError`,
|
|
371
|
+
a value of a type JSON does not have raises `TypeError`. See
|
|
372
|
+
`write_bytes_atomic` for what "in one step" guarantees, for the
|
|
373
|
+
permissions of the file and for `durable` and `parents`.
|
|
374
|
+
"""
|
|
375
|
+
text = json.dumps(value, indent=indent, sort_keys=sort_keys, ensure_ascii=ensure_ascii, allow_nan=False)
|
|
376
|
+
write_text_atomic(path, text + "\n", durable=durable, parents=parents)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@contextlib.contextmanager
|
|
380
|
+
def open_bytes_atomic(path: _Path, *, durable: bool = True, parents: bool = False) -> Iterator[io.BufferedWriter]:
|
|
381
|
+
"""Lend a binary file to write into; replace the file at `path` with it in one step.
|
|
382
|
+
|
|
383
|
+
For content that is written in steps or is too large for one value:
|
|
384
|
+
|
|
385
|
+
with open_bytes_atomic("table.bin") as handle:
|
|
386
|
+
for row in rows:
|
|
387
|
+
handle.write(row)
|
|
388
|
+
|
|
389
|
+
The block writes into a temporary file beside `path`. When the block ends,
|
|
390
|
+
everything written is in the file before the rename: the block need not
|
|
391
|
+
flush or close, and may close. When the block raises, `path` is unchanged
|
|
392
|
+
and no temporary file stays behind. Nothing happens before the `with`
|
|
393
|
+
statement is entered. A relative `path` means the directory that was
|
|
394
|
+
current then; the block may change it.
|
|
395
|
+
|
|
396
|
+
See `write_bytes_atomic` for what "in one step" guarantees, for the
|
|
397
|
+
permissions of the file and for `durable` and `parents`. The temporary
|
|
398
|
+
file exists for as long as the block runs, so a process killed inside the
|
|
399
|
+
block leaves it behind.
|
|
400
|
+
"""
|
|
401
|
+
with _temporary_file(_stage(path, durable=durable, parents=parents, block=True)) as descriptor:
|
|
402
|
+
handle = os.fdopen(descriptor, "wb", closefd=False)
|
|
403
|
+
try:
|
|
404
|
+
yield handle
|
|
405
|
+
handle.close()
|
|
406
|
+
finally:
|
|
407
|
+
# After a failure: drops what is still buffered. The descriptor is closed next, and
|
|
408
|
+
# bytes written later would reach whatever file gets its number.
|
|
409
|
+
handle.raw.close()
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
@contextlib.contextmanager
|
|
413
|
+
def open_text_atomic(path: _Path, *, durable: bool = True, parents: bool = False) -> Iterator[io.TextIOWrapper]:
|
|
414
|
+
"""Lend a text file to write into; replace the file at `path` with it in one step.
|
|
415
|
+
|
|
416
|
+
The text is written as UTF-8 and line endings are written as given, as
|
|
417
|
+
`write_text_atomic` does. Text that cannot be encoded raises
|
|
418
|
+
`UnicodeEncodeError` where it is written, and `path` is unchanged. Text
|
|
419
|
+
still buffered when the block ends is written before the rename. See
|
|
420
|
+
`open_bytes_atomic` for everything else.
|
|
421
|
+
"""
|
|
422
|
+
with open_bytes_atomic(path, durable=durable, parents=parents) as handle:
|
|
423
|
+
text = io.TextIOWrapper(handle, encoding="utf-8", newline="")
|
|
424
|
+
yield text
|
|
425
|
+
text.close()
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
@contextlib.contextmanager
|
|
429
|
+
def staged_path(
|
|
430
|
+
path: _Path, *, durable: bool = True, parents: bool = False, allow_empty: bool = False
|
|
431
|
+
) -> Iterator[str]:
|
|
432
|
+
"""Lend the name of a temporary file to a producer; replace the file at `path` with it in one step.
|
|
433
|
+
|
|
434
|
+
For a producer that takes a file name and opens the file itself: a library
|
|
435
|
+
call, a child process.
|
|
436
|
+
|
|
437
|
+
with staged_path("table.parquet") as temporary:
|
|
438
|
+
frame.to_parquet(temporary)
|
|
439
|
+
|
|
440
|
+
The name is an absolute path beside `path`, of a file that does not exist
|
|
441
|
+
yet, and it ends with the suffix of `path`. When the block ends, the file
|
|
442
|
+
the producer wrote there replaces `path`. When the block raises, `path` is
|
|
443
|
+
unchanged and the temporary file is removed.
|
|
444
|
+
|
|
445
|
+
The producer must have closed the file before the block ends: this
|
|
446
|
+
function cannot see content that a producer still holds in memory. Two
|
|
447
|
+
signs of that mistake are refused, with `path` unchanged: no file raises
|
|
448
|
+
`FileNotFoundError`, an empty file raises `ValueError`. `allow_empty=True`
|
|
449
|
+
publishes an empty file. Anything but a regular file is refused too, and
|
|
450
|
+
a directory left there is not removed.
|
|
451
|
+
|
|
452
|
+
A new file keeps the permissions the producer gave it; an existing file's
|
|
453
|
+
permissions are applied when the block ends. See `write_bytes_atomic` for
|
|
454
|
+
`durable` and `parents`.
|
|
455
|
+
"""
|
|
456
|
+
_switch("allow_empty", allow_empty)
|
|
457
|
+
stage = _stage(path, durable=durable, parents=parents, block=True, suffix=True)
|
|
458
|
+
with _published(stage):
|
|
459
|
+
yield stage.temporary
|
|
460
|
+
_seal(stage, allow_empty=allow_empty)
|