skit-cli 0.0.1__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.
skit/atomic.py ADDED
@@ -0,0 +1,101 @@
1
+ """Atomic writes: every state/metadata file goes through tmp + os.replace (C7)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import os
7
+ import shutil
8
+ import sys
9
+ import tempfile
10
+ import tomllib
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import tomli_w
16
+
17
+
18
+ def _fsync_dir(dir_path: Path) -> None:
19
+ """Fsync a directory fd so a prior os.replace()'s rename entry is durable on stable storage.
20
+
21
+ POSIX only: directories can never be opened for writing, but a read-only fd is enough --
22
+ fsync() flushes the underlying inode's dirty metadata regardless of which fd requested it.
23
+ Callers must guard with `sys.platform != "win32"` (os.open can't open a directory there) and
24
+ wrap this in contextlib.suppress(OSError): not every filesystem supports fsync on a
25
+ directory, and that failure must never undo or fail a write os.replace() already committed.
26
+ """
27
+ fd = os.open(dir_path, os.O_RDONLY)
28
+ try:
29
+ os.fsync(fd)
30
+ finally:
31
+ os.close(fd)
32
+
33
+
34
+ def atomic_write_bytes(path: Path, data: bytes) -> None:
35
+ path.parent.mkdir(parents=True, exist_ok=True)
36
+ fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
37
+ try:
38
+ with os.fdopen(fd, "wb") as f:
39
+ f.write(data)
40
+ f.flush()
41
+ os.fsync(f.fileno()) # durable on disk BEFORE the rename, not just before this returns
42
+ os.replace(tmp, path)
43
+ if sys.platform != "win32": # os.open can't open a directory on Windows
44
+ with contextlib.suppress(OSError):
45
+ _fsync_dir(path.parent) # best-effort: persist the rename's directory entry too
46
+ except BaseException:
47
+ with contextlib.suppress(OSError):
48
+ os.unlink(tmp)
49
+ raise
50
+
51
+
52
+ def atomic_write_text(path: Path, text: str) -> None:
53
+ atomic_write_bytes(path, text.encode("utf-8")) # pragma: no mutate — utf-8/UTF-8 alias
54
+
55
+
56
+ def atomic_write_toml(path: Path, doc: dict[str, Any]) -> None:
57
+ atomic_write_bytes(path, tomli_w.dumps(doc).encode("utf-8")) # pragma: no mutate — alias
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class TomlRecovery:
62
+ """Result of load_toml_recoverable().
63
+
64
+ `doc` is the parsed table when the file is absent or parses fine (and `corrupt` is False,
65
+ `backup_path` is None). When the file exists but fails to parse, `doc` is always {} and
66
+ `corrupt` is True; `backup_path` then names where the original got copied to before the
67
+ caller treats it as empty and overwrites it — or is None when even the backup attempt failed
68
+ (e.g. a read-only config dir), so the caller can still warn the user which case it hit.
69
+ """
70
+
71
+ doc: dict[str, Any]
72
+ corrupt: bool
73
+ backup_path: Path | None
74
+
75
+
76
+ def load_toml_recoverable(path: Path) -> TomlRecovery:
77
+ """Read a TOML file for a read-modify-write save, without silently discarding a corrupt one.
78
+
79
+ A plain best-effort load (catch-and-return-{}) is fine for read-only callers, but a
80
+ read-modify-write saver that started from that {} would then overwrite the file with just the
81
+ one key it just set — silently destroying every other saved setting with no trace. So here: if
82
+ the file exists but fails to parse, it is first copied to `<path>.bak` (best-effort) before
83
+ being treated as empty, so the caller can proceed *and* tell the user where to recover from.
84
+
85
+ Headless: this module has no gettext dependency, so it never prints anything itself — callers
86
+ own the warning (worded for their own context) based on `corrupt` / `backup_path`. This is what
87
+ lets both config.py and i18n.py (which config.py imports, so the reverse would be a cycle)
88
+ share the exact same backup mechanics.
89
+ """
90
+ if not path.is_file():
91
+ return TomlRecovery(doc={}, corrupt=False, backup_path=None)
92
+ try:
93
+ with open(path, "rb") as f:
94
+ return TomlRecovery(doc=tomllib.load(f), corrupt=False, backup_path=None)
95
+ except (OSError, tomllib.TOMLDecodeError):
96
+ backup = path.with_name(path.name + ".bak")
97
+ try:
98
+ shutil.copy2(path, backup)
99
+ except OSError:
100
+ return TomlRecovery(doc={}, corrupt=True, backup_path=None)
101
+ return TomlRecovery(doc={}, corrupt=True, backup_path=backup)