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