durablewrite 0.2.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.
- durablewrite/__init__.py +26 -0
- durablewrite/__main__.py +9 -0
- durablewrite/_atomic.py +141 -0
- durablewrite/_cleanup.py +63 -0
- durablewrite/_cli.py +178 -0
- durablewrite/_errors.py +40 -0
- durablewrite/_fs.py +134 -0
- durablewrite/_helpers.py +132 -0
- durablewrite/_options.py +79 -0
- durablewrite/_replace.py +94 -0
- durablewrite/py.typed +0 -0
- durablewrite-0.2.0.dist-info/METADATA +171 -0
- durablewrite-0.2.0.dist-info/RECORD +16 -0
- durablewrite-0.2.0.dist-info/WHEEL +4 -0
- durablewrite-0.2.0.dist-info/entry_points.txt +2 -0
- durablewrite-0.2.0.dist-info/licenses/LICENSE +21 -0
durablewrite/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Durable, cross-platform atomic file writes (temp-same-dir, os.replace, fsync)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._atomic import atomic_write
|
|
6
|
+
from ._cleanup import cleanup_temp_files
|
|
7
|
+
from ._errors import DirectorySyncError, DurableWriteError, SharingViolationError
|
|
8
|
+
from ._helpers import write_bytes_atomic, write_json_atomic, write_text_atomic
|
|
9
|
+
from ._options import DEFAULT_RETRY, NO_RETRY, FsyncLevel, RetryPolicy
|
|
10
|
+
|
|
11
|
+
__version__ = "0.2.0"
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"DEFAULT_RETRY",
|
|
15
|
+
"NO_RETRY",
|
|
16
|
+
"DirectorySyncError",
|
|
17
|
+
"DurableWriteError",
|
|
18
|
+
"FsyncLevel",
|
|
19
|
+
"RetryPolicy",
|
|
20
|
+
"SharingViolationError",
|
|
21
|
+
"atomic_write",
|
|
22
|
+
"cleanup_temp_files",
|
|
23
|
+
"write_bytes_atomic",
|
|
24
|
+
"write_json_atomic",
|
|
25
|
+
"write_text_atomic",
|
|
26
|
+
]
|
durablewrite/__main__.py
ADDED
durablewrite/_atomic.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""The atomic write context manager: temp file, flush, fsync, rename, fsync dir."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import errno
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from typing import IO, Any
|
|
10
|
+
|
|
11
|
+
from ._errors import DirectorySyncError
|
|
12
|
+
from ._fs import (
|
|
13
|
+
StrPath,
|
|
14
|
+
apply_metadata,
|
|
15
|
+
create_temp,
|
|
16
|
+
existing_stat,
|
|
17
|
+
fsync_directory,
|
|
18
|
+
resolve_target,
|
|
19
|
+
)
|
|
20
|
+
from ._options import DEFAULT_RETRY, FsyncLevel, RetryPolicy, resolve_fsync
|
|
21
|
+
from ._replace import publish, remove_temp
|
|
22
|
+
|
|
23
|
+
_VALID_MODES = ("w", "wb")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _check_mode(mode: str, encoding: str | None, newline: str | None) -> bool:
|
|
27
|
+
if mode not in _VALID_MODES:
|
|
28
|
+
raise ValueError(f"mode must be one of {_VALID_MODES!r}, got {mode!r}")
|
|
29
|
+
binary = mode == "wb"
|
|
30
|
+
if binary and encoding is not None:
|
|
31
|
+
raise ValueError("encoding is not allowed in binary mode")
|
|
32
|
+
if binary and newline is not None:
|
|
33
|
+
raise ValueError("newline is not allowed in binary mode")
|
|
34
|
+
return binary
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@contextmanager
|
|
38
|
+
def atomic_write(
|
|
39
|
+
path: StrPath,
|
|
40
|
+
mode: str = "w",
|
|
41
|
+
*,
|
|
42
|
+
encoding: str | None = None,
|
|
43
|
+
newline: str | None = None,
|
|
44
|
+
durable: bool = True,
|
|
45
|
+
fsync_dir: bool = True,
|
|
46
|
+
fsync: FsyncLevel | None = None,
|
|
47
|
+
overwrite: bool = True,
|
|
48
|
+
preserve_mode: bool = True,
|
|
49
|
+
permissions: int | None = None,
|
|
50
|
+
preserve_owner: bool = False,
|
|
51
|
+
follow_symlinks: bool = True,
|
|
52
|
+
retry: RetryPolicy = DEFAULT_RETRY,
|
|
53
|
+
) -> Iterator[IO[Any]]:
|
|
54
|
+
"""Write to ``path`` so readers only ever see the old file or the complete new one.
|
|
55
|
+
|
|
56
|
+
The block writes to a temp file created in the target's own directory (so
|
|
57
|
+
the final rename never crosses a filesystem). When the block exits normally
|
|
58
|
+
the data is flushed, fsynced according to ``fsync``, and moved onto ``path``
|
|
59
|
+
with ``os.replace``. If the block raises, ``path`` is untouched and the temp
|
|
60
|
+
file is removed. Closing the handle inside the block is an error: the write
|
|
61
|
+
is discarded and :class:`ValueError` is raised. While it is written, the
|
|
62
|
+
temp file is only readable by its owner whenever the final mode comes from
|
|
63
|
+
``permissions`` or from the replaced file.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
path: The file to create or replace.
|
|
67
|
+
mode: ``"w"`` for text (UTF-8 unless ``encoding`` is given) or ``"wb"``.
|
|
68
|
+
encoding: Text encoding; not allowed in binary mode.
|
|
69
|
+
newline: Passed to the text wrapper as in :func:`open`.
|
|
70
|
+
durable: Legacy flag for the file fsync; ignored when ``fsync`` is given.
|
|
71
|
+
fsync_dir: Legacy flag for the directory fsync; ignored when ``fsync`` is given.
|
|
72
|
+
fsync: ``"none"``, ``"file"`` or ``"full"`` (see :data:`FsyncLevel`).
|
|
73
|
+
Defaults to the legacy flags, which mean ``"full"``.
|
|
74
|
+
overwrite: When False, publish only if nothing exists at ``path`` and
|
|
75
|
+
raise :class:`FileExistsError` otherwise, atomically.
|
|
76
|
+
preserve_mode: Copy the permission bits of an existing target (POSIX).
|
|
77
|
+
New files get ``0o666`` minus the umask, as with :func:`open`.
|
|
78
|
+
permissions: Explicit permission bits; wins over ``preserve_mode``.
|
|
79
|
+
On Windows only the read-only flag is affected.
|
|
80
|
+
preserve_owner: Copy uid and gid of an existing target (POSIX only;
|
|
81
|
+
usually needs privileges to change the uid). Ignored on Windows.
|
|
82
|
+
follow_symlinks: When ``path`` is a symlink, replace the file it points
|
|
83
|
+
to and keep the link (default), or replace the link itself.
|
|
84
|
+
retry: How long to retry a rename blocked by a Windows lock.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
ValueError: An invalid ``mode``, ``fsync`` or argument combination, or
|
|
88
|
+
a ``path`` that is empty or ends with a path separator.
|
|
89
|
+
IsADirectoryError: ``path`` is an existing directory (or, when
|
|
90
|
+
following symlinks, a link to one).
|
|
91
|
+
FileExistsError: ``overwrite`` is False and ``path`` exists.
|
|
92
|
+
SharingViolationError: The rename stayed blocked for the whole retry budget.
|
|
93
|
+
DirectorySyncError: The file was replaced, but the directory fsync failed.
|
|
94
|
+
"""
|
|
95
|
+
binary = _check_mode(mode, encoding, newline)
|
|
96
|
+
sync_file, sync_dir = resolve_fsync(fsync, durable, fsync_dir)
|
|
97
|
+
target = resolve_target(os.fspath(path), follow_symlinks)
|
|
98
|
+
if os.path.isdir(target) and not os.path.islink(target):
|
|
99
|
+
raise IsADirectoryError(errno.EISDIR, "target is a directory", target)
|
|
100
|
+
if not overwrite and os.path.lexists(target):
|
|
101
|
+
raise FileExistsError(errno.EEXIST, "target already exists", target)
|
|
102
|
+
directory = os.path.dirname(os.path.abspath(target))
|
|
103
|
+
existing = existing_stat(target, follow_symlinks)
|
|
104
|
+
private = permissions is not None or (preserve_mode and existing is not None)
|
|
105
|
+
tmp: str | None = None
|
|
106
|
+
published = False
|
|
107
|
+
try:
|
|
108
|
+
fd, tmp = create_temp(directory, os.path.basename(target), private)
|
|
109
|
+
try:
|
|
110
|
+
if binary:
|
|
111
|
+
handle: IO[Any] = os.fdopen(fd, "wb")
|
|
112
|
+
else:
|
|
113
|
+
handle = os.fdopen(fd, "w", encoding=encoding or "utf-8", newline=newline)
|
|
114
|
+
except BaseException:
|
|
115
|
+
os.close(fd)
|
|
116
|
+
raise
|
|
117
|
+
with handle as f:
|
|
118
|
+
yield f
|
|
119
|
+
f.flush()
|
|
120
|
+
apply_metadata(
|
|
121
|
+
f.fileno(),
|
|
122
|
+
tmp,
|
|
123
|
+
existing,
|
|
124
|
+
preserve_mode=preserve_mode,
|
|
125
|
+
permissions=permissions,
|
|
126
|
+
preserve_owner=preserve_owner,
|
|
127
|
+
)
|
|
128
|
+
if sync_file:
|
|
129
|
+
os.fsync(f.fileno())
|
|
130
|
+
publish(tmp, target, overwrite=overwrite, retry=retry)
|
|
131
|
+
published = True
|
|
132
|
+
finally:
|
|
133
|
+
if tmp is not None and not published:
|
|
134
|
+
remove_temp(tmp, retry)
|
|
135
|
+
if sync_dir:
|
|
136
|
+
try:
|
|
137
|
+
fsync_directory(directory)
|
|
138
|
+
except OSError as exc:
|
|
139
|
+
raise DirectorySyncError(
|
|
140
|
+
exc.errno, f"file replaced, but its directory was not synced: {exc}", directory
|
|
141
|
+
) from exc
|
durablewrite/_cleanup.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Removal of temp files left behind by writers that were killed mid-write."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from ._fs import TEMP_SUFFIX, StrPath, temp_stem
|
|
10
|
+
|
|
11
|
+
_TAIL = r"\.[0-9a-f]{8}" + re.escape(TEMP_SUFFIX) + r"$"
|
|
12
|
+
_TEMP_NAME = re.compile(r"^\..+" + _TAIL, re.DOTALL)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def cleanup_temp_files(
|
|
16
|
+
directory: StrPath,
|
|
17
|
+
*,
|
|
18
|
+
older_than: float = 3600.0,
|
|
19
|
+
target: StrPath | None = None,
|
|
20
|
+
now: float | None = None,
|
|
21
|
+
) -> list[str]:
|
|
22
|
+
"""Delete stale durablewrite temp files in ``directory`` and return their paths.
|
|
23
|
+
|
|
24
|
+
A writer that is killed (or loses power) between creating its temp file and
|
|
25
|
+
renaming it cannot clean up after itself. Such files are named
|
|
26
|
+
``.<target name>.<8 hex digits>.dwtmp``; only regular files matching that
|
|
27
|
+
pattern are considered, so nothing else in the directory is ever touched.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
directory: The directory to scan (not recursive).
|
|
31
|
+
older_than: Minimum age in seconds, by modification time. The default of
|
|
32
|
+
one hour leaves alone the temp files of running writers, unless a
|
|
33
|
+
writer keeps one open for longer than that; pass ``0`` only when
|
|
34
|
+
you know no writer is active.
|
|
35
|
+
target: Restrict the cleanup to temp files of this one target file.
|
|
36
|
+
now: The current time as a Unix timestamp; defaults to :func:`time.time`.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
The removed paths, sorted. Files that vanish concurrently or that are
|
|
40
|
+
still held open (Windows refuses to delete them) are skipped.
|
|
41
|
+
"""
|
|
42
|
+
if older_than < 0:
|
|
43
|
+
raise ValueError("older_than must not be negative")
|
|
44
|
+
cutoff = (time.time() if now is None else now) - older_than
|
|
45
|
+
pattern = _TEMP_NAME
|
|
46
|
+
if target is not None:
|
|
47
|
+
stem = temp_stem(os.path.basename(os.fspath(target)))
|
|
48
|
+
pattern = re.compile(r"^\." + re.escape(stem) + _TAIL, re.DOTALL)
|
|
49
|
+
removed: list[str] = []
|
|
50
|
+
with os.scandir(directory) as entries:
|
|
51
|
+
for entry in entries:
|
|
52
|
+
if not pattern.match(entry.name):
|
|
53
|
+
continue
|
|
54
|
+
try:
|
|
55
|
+
if not entry.is_file(follow_symlinks=False):
|
|
56
|
+
continue
|
|
57
|
+
if entry.stat(follow_symlinks=False).st_mtime > cutoff:
|
|
58
|
+
continue
|
|
59
|
+
os.remove(entry.path)
|
|
60
|
+
except (FileNotFoundError, PermissionError):
|
|
61
|
+
continue
|
|
62
|
+
removed.append(entry.path)
|
|
63
|
+
return sorted(removed)
|
durablewrite/_cli.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""``durablewrite`` command: write standard input to a file atomically, like sponge."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import stat
|
|
10
|
+
import sys
|
|
11
|
+
from typing import IO, NoReturn
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from ._atomic import atomic_write
|
|
15
|
+
from ._errors import DirectorySyncError
|
|
16
|
+
from ._options import RetryPolicy
|
|
17
|
+
|
|
18
|
+
_EPILOG = """\
|
|
19
|
+
examples:
|
|
20
|
+
sort names.txt | durablewrite names.txt
|
|
21
|
+
sort a file in place; names.txt is replaced only after sort finishes
|
|
22
|
+
jq '.debug = true' config.json | durablewrite --json config.json
|
|
23
|
+
edit JSON in place; invalid output never replaces the file
|
|
24
|
+
echo "one more line" | durablewrite --append notes.txt
|
|
25
|
+
append atomically: readers see the old file or the longer one
|
|
26
|
+
printf 's3cret' | durablewrite --permissions 600 --no-overwrite token.txt
|
|
27
|
+
create a private file, failing if it already exists
|
|
28
|
+
|
|
29
|
+
The target is replaced only once standard input reaches end of file, so a
|
|
30
|
+
pipeline may read the same file it writes (sort f | durablewrite f). On POSIX
|
|
31
|
+
`durablewrite f < f` works too; on Windows the shell keeps a redirected file
|
|
32
|
+
open, which blocks the rename, so that form is refused: pipe the file instead.
|
|
33
|
+
Concurrent --append runs may lose each other's lines.
|
|
34
|
+
|
|
35
|
+
exit status: 0 on success; 1 on any error, with the target left as it was;
|
|
36
|
+
3 when the target was replaced but its directory could not be fsynced.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _Parser(argparse.ArgumentParser):
|
|
41
|
+
def error(self, message: str) -> NoReturn:
|
|
42
|
+
self.print_usage(sys.stderr)
|
|
43
|
+
self.exit(1, f"{self.prog}: error: {message}\n")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _octal(text: str) -> int:
|
|
47
|
+
try:
|
|
48
|
+
value = int(text, 8)
|
|
49
|
+
except ValueError:
|
|
50
|
+
raise argparse.ArgumentTypeError(f"not an octal mode: {text!r}") from None
|
|
51
|
+
if not 0 <= value <= 0o7777:
|
|
52
|
+
raise argparse.ArgumentTypeError(f"mode out of range: {text!r}")
|
|
53
|
+
return value
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _positive(text: str) -> int:
|
|
57
|
+
try:
|
|
58
|
+
value = int(text)
|
|
59
|
+
except ValueError:
|
|
60
|
+
value = 0
|
|
61
|
+
if value < 1:
|
|
62
|
+
raise argparse.ArgumentTypeError(f"must be a positive integer: {text!r}")
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
67
|
+
"""Return the argument parser for the ``durablewrite`` command."""
|
|
68
|
+
parser = _Parser(
|
|
69
|
+
prog="durablewrite",
|
|
70
|
+
description="Read standard input and write it to TARGET atomically and durably.",
|
|
71
|
+
epilog=_EPILOG,
|
|
72
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
73
|
+
)
|
|
74
|
+
parser.add_argument("target", metavar="TARGET", help="file to create or replace")
|
|
75
|
+
parser.add_argument("--version", action="version", version=f"durablewrite {__version__}")
|
|
76
|
+
mode = parser.add_mutually_exclusive_group()
|
|
77
|
+
mode.add_argument(
|
|
78
|
+
"-a", "--append", action="store_true", help="keep the current content and add stdin"
|
|
79
|
+
)
|
|
80
|
+
mode.add_argument(
|
|
81
|
+
"--json", action="store_true", help="refuse to write unless stdin is valid UTF-8 JSON"
|
|
82
|
+
)
|
|
83
|
+
parser.add_argument(
|
|
84
|
+
"--fsync",
|
|
85
|
+
choices=("none", "file", "full"),
|
|
86
|
+
default="full",
|
|
87
|
+
help="what to flush to disk before returning (default: full)",
|
|
88
|
+
)
|
|
89
|
+
parser.add_argument("--no-overwrite", action="store_true", help="fail if TARGET already exists")
|
|
90
|
+
parser.add_argument(
|
|
91
|
+
"--permissions", type=_octal, metavar="OCTAL", help="permission bits, e.g. 644"
|
|
92
|
+
)
|
|
93
|
+
parser.add_argument(
|
|
94
|
+
"--no-follow-symlinks",
|
|
95
|
+
action="store_true",
|
|
96
|
+
help="replace a symlink TARGET itself instead of the file it points to",
|
|
97
|
+
)
|
|
98
|
+
parser.add_argument(
|
|
99
|
+
"--attempts",
|
|
100
|
+
type=_positive,
|
|
101
|
+
default=8,
|
|
102
|
+
metavar="N",
|
|
103
|
+
help="rename attempts while Windows reports TARGET locked (default: 8)",
|
|
104
|
+
)
|
|
105
|
+
return parser
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _reject_constant(name: str) -> None:
|
|
109
|
+
raise ValueError(f"{name} is not valid JSON")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _check_json(payload: bytes) -> None:
|
|
113
|
+
try:
|
|
114
|
+
json.loads(payload.decode("utf-8"), parse_constant=_reject_constant)
|
|
115
|
+
except (ValueError, RecursionError) as exc:
|
|
116
|
+
raise ValueError(f"standard input is not valid UTF-8 JSON ({exc})") from exc
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _refuse_redirected_target(source: IO[bytes], target: str) -> None:
|
|
120
|
+
"""On Windows, fail fast when standard input is the target file itself."""
|
|
121
|
+
if os.name != "nt":
|
|
122
|
+
return
|
|
123
|
+
try:
|
|
124
|
+
src, dst = os.fstat(source.fileno()), os.stat(target)
|
|
125
|
+
except (OSError, ValueError):
|
|
126
|
+
return
|
|
127
|
+
if stat.S_ISREG(src.st_mode) and os.path.samestat(src, dst):
|
|
128
|
+
raise ValueError(
|
|
129
|
+
"on Windows TARGET cannot also be redirected into standard input: the shell "
|
|
130
|
+
"keeps it open, which blocks the rename. Pipe it instead: type FILE | durablewrite FILE"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _write(args: argparse.Namespace, source: IO[bytes]) -> None:
|
|
135
|
+
_refuse_redirected_target(source, args.target)
|
|
136
|
+
payload: bytes | None = None
|
|
137
|
+
if args.json:
|
|
138
|
+
payload = source.read()
|
|
139
|
+
_check_json(payload)
|
|
140
|
+
with atomic_write(
|
|
141
|
+
args.target,
|
|
142
|
+
"wb",
|
|
143
|
+
fsync=args.fsync,
|
|
144
|
+
overwrite=not args.no_overwrite,
|
|
145
|
+
permissions=args.permissions,
|
|
146
|
+
follow_symlinks=not args.no_follow_symlinks,
|
|
147
|
+
retry=RetryPolicy(max_attempts=args.attempts),
|
|
148
|
+
) as out:
|
|
149
|
+
if args.append:
|
|
150
|
+
try:
|
|
151
|
+
with open(args.target, "rb") as current:
|
|
152
|
+
shutil.copyfileobj(current, out)
|
|
153
|
+
except FileNotFoundError:
|
|
154
|
+
pass
|
|
155
|
+
if payload is not None:
|
|
156
|
+
out.write(payload)
|
|
157
|
+
else:
|
|
158
|
+
shutil.copyfileobj(source, out)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def main(argv: list[str] | None = None) -> int:
|
|
162
|
+
"""Run the command line tool and return its exit status."""
|
|
163
|
+
args = build_parser().parse_args(argv)
|
|
164
|
+
if sys.stdin is None:
|
|
165
|
+
print("durablewrite: error: standard input is closed", file=sys.stderr)
|
|
166
|
+
return 1
|
|
167
|
+
try:
|
|
168
|
+
_write(args, sys.stdin.buffer)
|
|
169
|
+
except DirectorySyncError as exc:
|
|
170
|
+
print(f"durablewrite: warning: {args.target}: {exc}", file=sys.stderr)
|
|
171
|
+
return 3
|
|
172
|
+
except (OSError, ValueError) as exc:
|
|
173
|
+
print(f"durablewrite: error: {exc}", file=sys.stderr)
|
|
174
|
+
return 1
|
|
175
|
+
except KeyboardInterrupt:
|
|
176
|
+
print("durablewrite: interrupted, target left unchanged", file=sys.stderr)
|
|
177
|
+
return 130
|
|
178
|
+
return 0
|
durablewrite/_errors.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Exceptions raised by durablewrite itself."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DurableWriteError(OSError):
|
|
7
|
+
"""Base class for errors that durablewrite raises on its own.
|
|
8
|
+
|
|
9
|
+
It subclasses :class:`OSError`, so code that already handles ``OSError``
|
|
10
|
+
around a file write keeps working unchanged.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SharingViolationError(DurableWriteError, PermissionError):
|
|
15
|
+
"""The target stayed locked by another process for the whole retry budget.
|
|
16
|
+
|
|
17
|
+
Raised on Windows when ``MoveFileExW`` keeps failing with
|
|
18
|
+
``ERROR_ACCESS_DENIED``, ``ERROR_SHARING_VIOLATION`` or
|
|
19
|
+
``ERROR_LOCK_VIOLATION`` because something holds the target (or the fresh
|
|
20
|
+
temp file) open without ``FILE_SHARE_DELETE``: another process, an antivirus
|
|
21
|
+
scanner or the search indexer. A directory ACL that denies the rename looks
|
|
22
|
+
the same to Windows and ends here too. The original target is untouched.
|
|
23
|
+
The temp file is removed, retrying under the same policy while it is locked;
|
|
24
|
+
if it stays locked it is left for :func:`~durablewrite.cleanup_temp_files`.
|
|
25
|
+
``attempts`` is the number of rename attempts made. The last underlying
|
|
26
|
+
error, with its ``winerror`` code, is chained as ``__cause__``.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
attempts: int = 0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DirectorySyncError(DurableWriteError):
|
|
33
|
+
"""The file was replaced, but its directory could not be fsynced afterwards.
|
|
34
|
+
|
|
35
|
+
The new content is in place and readers see it, but the rename may not
|
|
36
|
+
survive a power loss. Raised only with ``fsync="full"`` on POSIX, for
|
|
37
|
+
example when the directory cannot be opened for reading or the filesystem
|
|
38
|
+
refuses to fsync directories. ``filename`` is the directory; the
|
|
39
|
+
underlying error is chained as ``__cause__``.
|
|
40
|
+
"""
|
durablewrite/_fs.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Low-level filesystem steps: temp file creation, metadata, fsync, removal."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import errno
|
|
6
|
+
import hashlib
|
|
7
|
+
import os
|
|
8
|
+
import secrets
|
|
9
|
+
import stat
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Union
|
|
12
|
+
|
|
13
|
+
StrPath = Union[str, "os.PathLike[str]"]
|
|
14
|
+
|
|
15
|
+
TEMP_SUFFIX = ".dwtmp"
|
|
16
|
+
# Keeps ".<stem>.<8 hex>.dwtmp" under the 255-byte name limit even for 4-byte UTF-8.
|
|
17
|
+
_MAX_STEM_CHARS = 48
|
|
18
|
+
_CREATE_FLAGS = (
|
|
19
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0) | getattr(os, "O_CLOEXEC", 0)
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def temp_stem(base: str) -> str:
|
|
24
|
+
"""Return the part of a temp file name that identifies target ``base``.
|
|
25
|
+
|
|
26
|
+
Short names are used as is. Longer ones keep a readable head plus a hash of
|
|
27
|
+
the full name, so two long names never share temp files.
|
|
28
|
+
"""
|
|
29
|
+
if len(base) <= _MAX_STEM_CHARS:
|
|
30
|
+
return base
|
|
31
|
+
digest = hashlib.sha256(base.encode("utf-8", "surrogatepass")).hexdigest()[:8]
|
|
32
|
+
return f"{base[: _MAX_STEM_CHARS - 9]}~{digest}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def resolve_target(path: str, follow_symlinks: bool) -> str:
|
|
36
|
+
"""Return the path whose directory entry the rename will replace."""
|
|
37
|
+
if not path:
|
|
38
|
+
raise ValueError("path must not be empty")
|
|
39
|
+
if path.endswith(os.sep) or (os.altsep is not None and path.endswith(os.altsep)):
|
|
40
|
+
raise ValueError(f"path must name a file, not end with a separator: {path!r}")
|
|
41
|
+
if follow_symlinks and os.path.islink(path):
|
|
42
|
+
return os.path.realpath(path)
|
|
43
|
+
return path
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def existing_stat(path: str, follow_symlinks: bool) -> os.stat_result | None:
|
|
47
|
+
"""Stat the file being replaced, or None for a new file.
|
|
48
|
+
|
|
49
|
+
A symlink that is itself replaced (``follow_symlinks=False``) counts as a
|
|
50
|
+
new file: the mode of whatever it points to is not inherited.
|
|
51
|
+
"""
|
|
52
|
+
try:
|
|
53
|
+
if not follow_symlinks and os.path.islink(path):
|
|
54
|
+
return None
|
|
55
|
+
return os.stat(path)
|
|
56
|
+
except FileNotFoundError:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def create_temp(directory: str, base: str, private: bool) -> tuple[int, str]:
|
|
61
|
+
"""Create an empty temp file next to the target and return ``(fd, path)``.
|
|
62
|
+
|
|
63
|
+
``private`` creates it as ``0o600`` so content meant for a restrictive final
|
|
64
|
+
mode is never readable by others while it is written. Otherwise ``0o666``
|
|
65
|
+
lets the process umask apply exactly as it does for ``open()``.
|
|
66
|
+
"""
|
|
67
|
+
prefix = f".{temp_stem(base)}."
|
|
68
|
+
mode = 0o600 if private else 0o666
|
|
69
|
+
for _ in range(100):
|
|
70
|
+
path = os.path.join(directory, f"{prefix}{secrets.token_hex(4)}{TEMP_SUFFIX}")
|
|
71
|
+
try:
|
|
72
|
+
return os.open(path, _CREATE_FLAGS, mode), path
|
|
73
|
+
except FileExistsError:
|
|
74
|
+
continue
|
|
75
|
+
raise FileExistsError(errno.EEXIST, "could not pick a free temp file name", directory)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def apply_metadata(
|
|
79
|
+
fd: int,
|
|
80
|
+
tmp: str,
|
|
81
|
+
existing: os.stat_result | None,
|
|
82
|
+
*,
|
|
83
|
+
preserve_mode: bool,
|
|
84
|
+
permissions: int | None,
|
|
85
|
+
preserve_owner: bool,
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Give the temp file the ownership and permission bits it should end with."""
|
|
88
|
+
if sys.platform != "win32" and preserve_owner and existing is not None:
|
|
89
|
+
# chown before chmod: chown clears setuid/setgid bits on Linux.
|
|
90
|
+
os.fchown(fd, existing.st_uid, existing.st_gid)
|
|
91
|
+
if permissions is not None:
|
|
92
|
+
os.chmod(tmp, permissions)
|
|
93
|
+
elif preserve_mode and existing is not None and os.name != "nt":
|
|
94
|
+
os.chmod(tmp, stat.S_IMODE(existing.st_mode))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def fsync_directory(directory: str) -> None:
|
|
98
|
+
"""Flush the directory entry so a completed rename survives power loss.
|
|
99
|
+
|
|
100
|
+
Windows offers no way to fsync a directory through the C runtime, so this
|
|
101
|
+
is a documented no-op there.
|
|
102
|
+
"""
|
|
103
|
+
if os.name == "nt":
|
|
104
|
+
return
|
|
105
|
+
fd = os.open(directory, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
|
106
|
+
try:
|
|
107
|
+
os.fsync(fd)
|
|
108
|
+
finally:
|
|
109
|
+
os.close(fd)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def try_remove(path: str) -> bool | None:
|
|
113
|
+
"""Remove ``path``: True if removed or gone, None if locked, False on other errors."""
|
|
114
|
+
try:
|
|
115
|
+
os.remove(path)
|
|
116
|
+
return True
|
|
117
|
+
except FileNotFoundError:
|
|
118
|
+
return True
|
|
119
|
+
except PermissionError as exc:
|
|
120
|
+
if os.name != "nt":
|
|
121
|
+
return False
|
|
122
|
+
if getattr(exc, "winerror", None) in (32, 33):
|
|
123
|
+
return None
|
|
124
|
+
try:
|
|
125
|
+
# A read-only attribute blocks deletion on Windows; clear it and retry once.
|
|
126
|
+
os.chmod(path, stat.S_IWRITE)
|
|
127
|
+
os.remove(path)
|
|
128
|
+
return True
|
|
129
|
+
except FileNotFoundError:
|
|
130
|
+
return True
|
|
131
|
+
except OSError:
|
|
132
|
+
return None
|
|
133
|
+
except OSError:
|
|
134
|
+
return False
|
durablewrite/_helpers.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""One-shot helpers for text, bytes and JSON built on :func:`atomic_write`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ._atomic import atomic_write
|
|
10
|
+
from ._fs import StrPath
|
|
11
|
+
from ._options import DEFAULT_RETRY, FsyncLevel, RetryPolicy
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def write_text_atomic(
|
|
15
|
+
path: StrPath,
|
|
16
|
+
text: str,
|
|
17
|
+
encoding: str = "utf-8",
|
|
18
|
+
*,
|
|
19
|
+
newline: str | None = None,
|
|
20
|
+
durable: bool = True,
|
|
21
|
+
fsync_dir: bool = True,
|
|
22
|
+
fsync: FsyncLevel | None = None,
|
|
23
|
+
overwrite: bool = True,
|
|
24
|
+
preserve_mode: bool = True,
|
|
25
|
+
permissions: int | None = None,
|
|
26
|
+
preserve_owner: bool = False,
|
|
27
|
+
follow_symlinks: bool = True,
|
|
28
|
+
retry: RetryPolicy = DEFAULT_RETRY,
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Atomically replace ``path`` with ``text``.
|
|
31
|
+
|
|
32
|
+
Keyword arguments have the meaning documented on :func:`atomic_write`.
|
|
33
|
+
"""
|
|
34
|
+
with atomic_write(
|
|
35
|
+
path,
|
|
36
|
+
"w",
|
|
37
|
+
encoding=encoding,
|
|
38
|
+
newline=newline,
|
|
39
|
+
durable=durable,
|
|
40
|
+
fsync_dir=fsync_dir,
|
|
41
|
+
fsync=fsync,
|
|
42
|
+
overwrite=overwrite,
|
|
43
|
+
preserve_mode=preserve_mode,
|
|
44
|
+
permissions=permissions,
|
|
45
|
+
preserve_owner=preserve_owner,
|
|
46
|
+
follow_symlinks=follow_symlinks,
|
|
47
|
+
retry=retry,
|
|
48
|
+
) as f:
|
|
49
|
+
f.write(text)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def write_bytes_atomic(
|
|
53
|
+
path: StrPath,
|
|
54
|
+
data: bytes,
|
|
55
|
+
*,
|
|
56
|
+
durable: bool = True,
|
|
57
|
+
fsync_dir: bool = True,
|
|
58
|
+
fsync: FsyncLevel | None = None,
|
|
59
|
+
overwrite: bool = True,
|
|
60
|
+
preserve_mode: bool = True,
|
|
61
|
+
permissions: int | None = None,
|
|
62
|
+
preserve_owner: bool = False,
|
|
63
|
+
follow_symlinks: bool = True,
|
|
64
|
+
retry: RetryPolicy = DEFAULT_RETRY,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Atomically replace ``path`` with ``data``.
|
|
67
|
+
|
|
68
|
+
Keyword arguments have the meaning documented on :func:`atomic_write`.
|
|
69
|
+
"""
|
|
70
|
+
with atomic_write(
|
|
71
|
+
path,
|
|
72
|
+
"wb",
|
|
73
|
+
durable=durable,
|
|
74
|
+
fsync_dir=fsync_dir,
|
|
75
|
+
fsync=fsync,
|
|
76
|
+
overwrite=overwrite,
|
|
77
|
+
preserve_mode=preserve_mode,
|
|
78
|
+
permissions=permissions,
|
|
79
|
+
preserve_owner=preserve_owner,
|
|
80
|
+
follow_symlinks=follow_symlinks,
|
|
81
|
+
retry=retry,
|
|
82
|
+
) as f:
|
|
83
|
+
f.write(data)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def write_json_atomic(
|
|
87
|
+
path: StrPath,
|
|
88
|
+
obj: Any,
|
|
89
|
+
*,
|
|
90
|
+
indent: int | str | None = 2,
|
|
91
|
+
sort_keys: bool = False,
|
|
92
|
+
ensure_ascii: bool = False,
|
|
93
|
+
default: Callable[[Any], Any] | None = None,
|
|
94
|
+
allow_nan: bool = False,
|
|
95
|
+
fsync: FsyncLevel = "full",
|
|
96
|
+
overwrite: bool = True,
|
|
97
|
+
preserve_mode: bool = True,
|
|
98
|
+
permissions: int | None = None,
|
|
99
|
+
preserve_owner: bool = False,
|
|
100
|
+
follow_symlinks: bool = True,
|
|
101
|
+
retry: RetryPolicy = DEFAULT_RETRY,
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Atomically replace ``path`` with ``obj`` encoded as UTF-8 JSON.
|
|
104
|
+
|
|
105
|
+
The value is serialised before any file is touched, so an object that
|
|
106
|
+
cannot be encoded raises :class:`TypeError` (or :class:`ValueError` for a
|
|
107
|
+
circular reference) and leaves both the target and the directory unchanged.
|
|
108
|
+
``NaN`` and infinities are not valid JSON (RFC 8259), so they raise
|
|
109
|
+
:class:`ValueError` unless ``allow_nan=True``.
|
|
110
|
+
The output ends with a single ``\n`` and uses ``\n`` line endings on every
|
|
111
|
+
platform. ``indent``, ``sort_keys``, ``ensure_ascii``, ``default`` and ``allow_nan`` are
|
|
112
|
+
passed to :func:`json.dumps`; the rest is documented on :func:`atomic_write`.
|
|
113
|
+
"""
|
|
114
|
+
text = json.dumps(
|
|
115
|
+
obj,
|
|
116
|
+
indent=indent,
|
|
117
|
+
sort_keys=sort_keys,
|
|
118
|
+
ensure_ascii=ensure_ascii,
|
|
119
|
+
default=default,
|
|
120
|
+
allow_nan=allow_nan,
|
|
121
|
+
)
|
|
122
|
+
write_bytes_atomic(
|
|
123
|
+
path,
|
|
124
|
+
(text + "\n").encode("utf-8"),
|
|
125
|
+
fsync=fsync,
|
|
126
|
+
overwrite=overwrite,
|
|
127
|
+
preserve_mode=preserve_mode,
|
|
128
|
+
permissions=permissions,
|
|
129
|
+
preserve_owner=preserve_owner,
|
|
130
|
+
follow_symlinks=follow_symlinks,
|
|
131
|
+
retry=retry,
|
|
132
|
+
)
|
durablewrite/_options.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Fsync levels and the retry policy used when the final rename is blocked."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable, Iterator
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
FsyncLevel = Literal["none", "file", "full"]
|
|
11
|
+
"""How much to flush to stable storage.
|
|
12
|
+
|
|
13
|
+
``"none"``: no fsync at all. The write is still atomic for readers and survives
|
|
14
|
+
a crash of the writing process, but not a power loss or kernel crash.
|
|
15
|
+
``"file"``: fsync the temp file before the rename, so the new content is on disk
|
|
16
|
+
before its name can point at it.
|
|
17
|
+
``"full"``: ``"file"`` plus an fsync of the parent directory after the rename, so
|
|
18
|
+
the rename itself is durable. Windows has no directory fsync, so there
|
|
19
|
+
``"full"`` behaves like ``"file"``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
_LEVELS: dict[str, tuple[bool, bool]] = {
|
|
23
|
+
"none": (False, False),
|
|
24
|
+
"file": (True, False),
|
|
25
|
+
"full": (True, True),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def resolve_fsync(fsync: FsyncLevel | None, durable: bool, fsync_dir: bool) -> tuple[bool, bool]:
|
|
30
|
+
"""Return ``(sync_file, sync_dir)``; an explicit ``fsync`` wins over the flags."""
|
|
31
|
+
if fsync is None:
|
|
32
|
+
return durable, fsync_dir
|
|
33
|
+
try:
|
|
34
|
+
return _LEVELS[fsync]
|
|
35
|
+
except KeyError:
|
|
36
|
+
raise ValueError(f"fsync must be one of {sorted(_LEVELS)!r}, got {fsync!r}") from None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class RetryPolicy:
|
|
41
|
+
"""Bounded exponential backoff for a rename that a Windows lock blocks.
|
|
42
|
+
|
|
43
|
+
Only transient Windows lock errors are retried (see
|
|
44
|
+
:class:`~durablewrite.SharingViolationError`). Every other error, including
|
|
45
|
+
a read-only target and any POSIX ``EACCES``, is raised on the first attempt.
|
|
46
|
+
With the defaults the writer waits at most about 1.1 seconds in total.
|
|
47
|
+
|
|
48
|
+
``max_attempts`` counts every rename attempt, so ``1`` disables retrying.
|
|
49
|
+
The wait before attempt ``n + 1`` is ``initial_delay * multiplier**(n - 1)``,
|
|
50
|
+
capped at ``max_delay``. ``sleep`` is injectable for tests.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
max_attempts: int = 8
|
|
54
|
+
initial_delay: float = 0.01
|
|
55
|
+
multiplier: float = 2.0
|
|
56
|
+
max_delay: float = 0.5
|
|
57
|
+
sleep: Callable[[float], None] = field(default=time.sleep, compare=False, repr=False)
|
|
58
|
+
|
|
59
|
+
def __post_init__(self) -> None:
|
|
60
|
+
if self.max_attempts < 1:
|
|
61
|
+
raise ValueError("max_attempts must be at least 1")
|
|
62
|
+
if self.initial_delay < 0 or self.max_delay < 0:
|
|
63
|
+
raise ValueError("delays must not be negative")
|
|
64
|
+
if self.multiplier < 1:
|
|
65
|
+
raise ValueError("multiplier must be at least 1")
|
|
66
|
+
|
|
67
|
+
def delays(self) -> Iterator[float]:
|
|
68
|
+
"""Yield the wait before each retry: ``max_attempts - 1`` values."""
|
|
69
|
+
delay = self.initial_delay
|
|
70
|
+
for _ in range(self.max_attempts - 1):
|
|
71
|
+
yield min(delay, self.max_delay)
|
|
72
|
+
delay *= self.multiplier
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
DEFAULT_RETRY = RetryPolicy()
|
|
76
|
+
"""The policy used when none is given: 8 attempts, about 1.1 seconds at most."""
|
|
77
|
+
|
|
78
|
+
NO_RETRY = RetryPolicy(max_attempts=1)
|
|
79
|
+
"""Fail on the first blocked rename."""
|
durablewrite/_replace.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Publishing the finished temp file under the target name, with bounded retry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import errno
|
|
6
|
+
import os
|
|
7
|
+
import stat
|
|
8
|
+
from contextlib import suppress
|
|
9
|
+
|
|
10
|
+
from ._errors import SharingViolationError
|
|
11
|
+
from ._fs import try_remove
|
|
12
|
+
from ._options import RetryPolicy
|
|
13
|
+
|
|
14
|
+
# ERROR_ACCESS_DENIED, ERROR_SHARING_VIOLATION, ERROR_LOCK_VIOLATION.
|
|
15
|
+
_TRANSIENT_WINERRORS = frozenset({5, 32, 33})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _is_read_only(path: str) -> bool:
|
|
19
|
+
try:
|
|
20
|
+
attributes = getattr(os.stat(path), "st_file_attributes", 0)
|
|
21
|
+
except OSError:
|
|
22
|
+
return False
|
|
23
|
+
return bool(attributes & stat.FILE_ATTRIBUTE_READONLY)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _is_transient(exc: OSError, target: str) -> bool:
|
|
27
|
+
"""True for a Windows lock that may clear by itself, never for a real denial."""
|
|
28
|
+
if os.name != "nt" or not isinstance(exc, PermissionError):
|
|
29
|
+
return False
|
|
30
|
+
if getattr(exc, "winerror", None) not in _TRANSIENT_WINERRORS:
|
|
31
|
+
return False
|
|
32
|
+
return not _is_read_only(target) and not os.path.isdir(target)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _create_new(tmp: str, target: str) -> None:
|
|
36
|
+
"""Move ``tmp`` to ``target`` only if ``target`` does not exist, atomically.
|
|
37
|
+
|
|
38
|
+
POSIX ``rename`` always overwrites, so a hard link is used instead: ``link``
|
|
39
|
+
fails with ``EEXIST`` if the name is taken. On Windows ``MoveFileExW``
|
|
40
|
+
without ``MOVEFILE_REPLACE_EXISTING`` fails the same way.
|
|
41
|
+
"""
|
|
42
|
+
if os.name == "nt":
|
|
43
|
+
os.rename(tmp, target)
|
|
44
|
+
return
|
|
45
|
+
os.link(tmp, target)
|
|
46
|
+
with suppress(FileNotFoundError):
|
|
47
|
+
os.remove(tmp)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def publish(tmp: str, target: str, *, overwrite: bool, retry: RetryPolicy) -> None:
|
|
51
|
+
"""Rename ``tmp`` onto ``target``, retrying transient Windows locks only."""
|
|
52
|
+
move = os.replace if overwrite else _create_new
|
|
53
|
+
delays = retry.delays()
|
|
54
|
+
attempt = 1
|
|
55
|
+
while True:
|
|
56
|
+
try:
|
|
57
|
+
move(tmp, target)
|
|
58
|
+
return
|
|
59
|
+
except OSError as exc:
|
|
60
|
+
if not _is_transient(exc, target):
|
|
61
|
+
raise
|
|
62
|
+
delay = next(delays, None)
|
|
63
|
+
if delay is None:
|
|
64
|
+
error = SharingViolationError(
|
|
65
|
+
errno.EACCES,
|
|
66
|
+
f"rename kept failing with a sharing, lock or access-denied error "
|
|
67
|
+
f"after {attempt} attempts (is another process holding the file open?)",
|
|
68
|
+
target,
|
|
69
|
+
)
|
|
70
|
+
error.attempts = attempt
|
|
71
|
+
raise error from exc
|
|
72
|
+
retry.sleep(delay)
|
|
73
|
+
attempt += 1
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def remove_temp(tmp: str, retry: RetryPolicy) -> bool:
|
|
77
|
+
"""Remove the temp file on an error path, waiting out Windows locks.
|
|
78
|
+
|
|
79
|
+
Never raises, so it cannot mask the error being handled. Returns False if
|
|
80
|
+
the file is left behind (still locked, or another error); such files are
|
|
81
|
+
what :func:`~durablewrite.cleanup_temp_files` reclaims.
|
|
82
|
+
"""
|
|
83
|
+
delays = retry.delays()
|
|
84
|
+
while True:
|
|
85
|
+
outcome = try_remove(tmp)
|
|
86
|
+
if outcome is not None:
|
|
87
|
+
return outcome
|
|
88
|
+
delay = next(delays, None)
|
|
89
|
+
if delay is None:
|
|
90
|
+
return False
|
|
91
|
+
try:
|
|
92
|
+
retry.sleep(delay)
|
|
93
|
+
except Exception:
|
|
94
|
+
return False
|
durablewrite/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: durablewrite
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Durable, cross-platform atomic file writes: temp-same-dir plus os.replace plus file and dir fsync
|
|
5
|
+
Project-URL: Homepage, https://lianes8studio.com
|
|
6
|
+
Project-URL: Repository, https://github.com/JaviLianes8/durablewrite
|
|
7
|
+
Project-URL: Issues, https://github.com/JaviLianes8/durablewrite/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/JaviLianes8/durablewrite/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: LinkedIn, https://www.linkedin.com/in/jlianes/
|
|
10
|
+
Project-URL: Funding, https://buymeacoffee.com/jlianesglrs
|
|
11
|
+
Author-email: Javier Lianes <javier@lianes8studio.com>
|
|
12
|
+
License-Expression: MIT
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Keywords: atomic,crash-safe,durable,file,fsync,os.replace,write
|
|
15
|
+
Classifier: Development Status :: 4 - Beta
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: build; extra == 'dev'
|
|
22
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
24
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
25
|
+
Requires-Dist: twine; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# durablewrite
|
|
29
|
+
|
|
30
|
+
Durable, cross-platform atomic file writes for Python. Data goes to a temp file
|
|
31
|
+
in the same directory, is flushed and fsynced, then moved onto the target with
|
|
32
|
+
`os.replace`, and the directory is fsynced. A reader never sees a half-written
|
|
33
|
+
file, and a crash leaves either the previous version or the complete new one.
|
|
34
|
+
|
|
35
|
+
Typed, standard library only, Python 3.10+, Windows and POSIX.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
pip install durablewrite
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from durablewrite import atomic_write, write_bytes_atomic, write_json_atomic, write_text_atomic
|
|
47
|
+
|
|
48
|
+
# One-shot helpers.
|
|
49
|
+
write_text_atomic("notes.txt", "hello\n")
|
|
50
|
+
write_bytes_atomic("snapshot.bin", b"\x00\x01\x02")
|
|
51
|
+
write_json_atomic("config.json", {"ready": True}) # serialised before any file is touched
|
|
52
|
+
|
|
53
|
+
# Stream into a file: it is replaced only if the block finishes without an error.
|
|
54
|
+
with atomic_write("report.csv") as f:
|
|
55
|
+
for row in rows:
|
|
56
|
+
f.write(row + "\n")
|
|
57
|
+
|
|
58
|
+
with atomic_write("image.png", "wb") as f:
|
|
59
|
+
f.write(data)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Options (on every writer)
|
|
63
|
+
|
|
64
|
+
| Option | Default | Meaning |
|
|
65
|
+
| --- | --- | --- |
|
|
66
|
+
| `fsync` | `"full"` | `"none"`, `"file"` (fsync the data) or `"full"` (data and directory) |
|
|
67
|
+
| `overwrite` | `True` | `False`: create only if absent, atomically, else `FileExistsError` |
|
|
68
|
+
| `preserve_mode` | `True` | keep the permission bits of the file being replaced (POSIX) |
|
|
69
|
+
| `permissions` | `None` | explicit permission bits, e.g. `0o600` |
|
|
70
|
+
| `preserve_owner` | `False` | keep uid and gid of the replaced file (POSIX) |
|
|
71
|
+
| `follow_symlinks` | `True` | write through a symlink and keep it, or replace the link itself |
|
|
72
|
+
| `retry` | `DEFAULT_RETRY` | bounded retry while Windows reports the target locked |
|
|
73
|
+
|
|
74
|
+
New files get `0o666` minus your umask, exactly like `open()`. When the final mode
|
|
75
|
+
comes from `permissions` or from the replaced file, the temp file is created as
|
|
76
|
+
`0o600`, so a secret is never readable by others while it is being written.
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from durablewrite import NO_RETRY, RetryPolicy, SharingViolationError, write_text_atomic
|
|
80
|
+
|
|
81
|
+
write_text_atomic("cache.txt", payload, fsync="none") # atomic, not durable
|
|
82
|
+
write_text_atomic("token", secret, permissions=0o600, overwrite=False)
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
write_text_atomic("held.txt", "x", retry=RetryPolicy(max_attempts=20, max_delay=0.2))
|
|
86
|
+
except SharingViolationError as exc: # a PermissionError; target untouched
|
|
87
|
+
print("still locked after", exc.attempts, "attempts")
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
A writer that is killed mid-write cannot remove its temp file. Clean those up
|
|
91
|
+
at startup:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from durablewrite import cleanup_temp_files
|
|
95
|
+
|
|
96
|
+
cleanup_temp_files("data/", older_than=3600) # only *.dwtmp files written by durablewrite
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Command line
|
|
100
|
+
|
|
101
|
+
`durablewrite TARGET` writes standard input to TARGET atomically, like
|
|
102
|
+
`sponge`. TARGET is replaced only after the input ends.
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
sort names.txt | durablewrite names.txt
|
|
106
|
+
jq '.debug = true' config.json | durablewrite --json config.json
|
|
107
|
+
echo "one more line" | durablewrite --append notes.txt
|
|
108
|
+
printf 's3cret' | durablewrite --permissions 600 --no-overwrite token.txt
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Options: `--append`, `--json` (refuse invalid JSON), `--fsync {none,file,full}`,
|
|
112
|
+
`--no-overwrite`, `--permissions OCTAL`, `--no-follow-symlinks`,
|
|
113
|
+
`--attempts N`. Errors go to standard error with exit status 1, and TARGET is
|
|
114
|
+
left as it was. If TARGET was replaced but its directory could not be fsynced,
|
|
115
|
+
the exit status is 3. `python -m durablewrite` works too.
|
|
116
|
+
|
|
117
|
+
On Windows, redirecting TARGET into the command (`durablewrite f < f`) is refused,
|
|
118
|
+
because the shell keeps the redirected file open and that blocks the rename; pipe
|
|
119
|
+
it instead (`type f | durablewrite f`). Concurrent `--append` runs on one file may
|
|
120
|
+
lose each other's lines: each run is atomic, but they do not merge.
|
|
121
|
+
|
|
122
|
+
## Guarantees and limits
|
|
123
|
+
|
|
124
|
+
The short version (details and sources in [GUIDE.md](GUIDE.md)):
|
|
125
|
+
|
|
126
|
+
- **Atomic for readers and against process crashes** on local filesystems,
|
|
127
|
+
Windows and POSIX. Exercised by a seeded test that kills a writer process at
|
|
128
|
+
random moments and by a multi-process concurrent writer test.
|
|
129
|
+
- **Durable against power loss** on Linux with `fsync="full"`. On macOS, `fsync`
|
|
130
|
+
does not flush the drive cache (`F_FULLFSYNC` is not issued). On Windows the
|
|
131
|
+
file data is flushed but a directory cannot be fsynced.
|
|
132
|
+
- **Windows**: `os.replace` is `MoveFileExW`, not documented as atomic by
|
|
133
|
+
Microsoft; tested on NTFS, not guaranteed on FAT or network shares. An open
|
|
134
|
+
handle elsewhere blocks the rename; it is retried for about a second by
|
|
135
|
+
default, then `SharingViolationError` is raised. While a rename is in flight,
|
|
136
|
+
a concurrent `open()` of the target can fail with `PermissionError`; readers
|
|
137
|
+
that poll should retry. A read-only target cannot be replaced on Windows.
|
|
138
|
+
- **After the rename**, a failing directory fsync raises `DirectorySyncError`:
|
|
139
|
+
the new content is in place, but the rename may not survive a power loss.
|
|
140
|
+
- Replacing a file creates a new one: other hard links keep the old content,
|
|
141
|
+
and extended attributes, ACLs and alternate data streams are not copied.
|
|
142
|
+
|
|
143
|
+
## Why
|
|
144
|
+
|
|
145
|
+
Writing a file in place can corrupt it: a crash, a full disk or a concurrent
|
|
146
|
+
reader can catch it half-written. The safe pattern (temp file in the same
|
|
147
|
+
directory, fsync, `os.replace`, fsync the directory) is well known but fiddly
|
|
148
|
+
to get right on every platform.
|
|
149
|
+
|
|
150
|
+
The options that existed each fall short:
|
|
151
|
+
|
|
152
|
+
- `atomicwrites`, the de-facto package, was **archived by its author in 2022**
|
|
153
|
+
and takes no fixes.
|
|
154
|
+
- `boltons.fileutils.atomic_save` has **no Windows support**.
|
|
155
|
+
- The `atomicfile` and `atomicwrite` packages on PyPI are **abandoned** (last
|
|
156
|
+
releases in 2020 and 2015) and neither is typed.
|
|
157
|
+
- `safewrite` is minimal and obscure.
|
|
158
|
+
|
|
159
|
+
`durablewrite` does the full sequence, keeps permissions, handles Windows
|
|
160
|
+
sharing violations, documents exactly what each platform guarantees, and ships
|
|
161
|
+
a command-line tool.
|
|
162
|
+
|
|
163
|
+
## License
|
|
164
|
+
|
|
165
|
+
MIT. See [LICENSE](LICENSE).
|
|
166
|
+
|
|
167
|
+
## Links
|
|
168
|
+
|
|
169
|
+
- Website: https://lianes8studio.com
|
|
170
|
+
- GitHub: https://github.com/JaviLianes8
|
|
171
|
+
- LinkedIn: https://www.linkedin.com/in/jlianes/
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
durablewrite/__init__.py,sha256=VfuwRzlq3Pd0168dfx_bT2Uajwtl6BHXu94ssKVzAcQ,743
|
|
2
|
+
durablewrite/__main__.py,sha256=76Vu0YuiXneRegw-5Z9nbhwXZvVp8DjXQ4ewyh-cKEw,130
|
|
3
|
+
durablewrite/_atomic.py,sha256=YyhVq80a60e_cW8W0q6uVirA-Ny3YdK_a7Ir6J8WiTs,5948
|
|
4
|
+
durablewrite/_cleanup.py,sha256=8_MRhgI1Pa1IJqr0lE_FJjSwDWw6UNpe-TYjy332RQM,2444
|
|
5
|
+
durablewrite/_cli.py,sha256=IpOX1_uAojRgvSyUUJFwaJ53Kolxip0j7kZ18svcuzk,6280
|
|
6
|
+
durablewrite/_errors.py,sha256=Axi1R7WyklnEQ6E22vEQGtT0rAFasR1xjXRz9ilWG7Q,1725
|
|
7
|
+
durablewrite/_fs.py,sha256=ogn0-4qxl00nmr9EgNcI36c5mY4bunJ5RQULcAcedLc,4534
|
|
8
|
+
durablewrite/_helpers.py,sha256=BmfpB6JWNe6Hxbcg4IeRwZqbOCzwXJbYd-vaWAmU8Js,3817
|
|
9
|
+
durablewrite/_options.py,sha256=PZst3wxioPPZta817l9UhpiLdZD1PuGTBEL5ii8BILI,2985
|
|
10
|
+
durablewrite/_replace.py,sha256=e8wOUawCPYgJ-iZy1DohNePP3ng5q4fpXL2Kt5B0dNw,3147
|
|
11
|
+
durablewrite/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
durablewrite-0.2.0.dist-info/METADATA,sha256=pF_BXdNjWmD1sOB3oxPuZzw7tAyS4MweSAuC1ePVjiE,7181
|
|
13
|
+
durablewrite-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
14
|
+
durablewrite-0.2.0.dist-info/entry_points.txt,sha256=R9sqjd-QNJ7wSm-zu_uJbtBSuvssxwDU9XFa6bM9LGs,56
|
|
15
|
+
durablewrite-0.2.0.dist-info/licenses/LICENSE,sha256=wirD5nCA2aQa5UusK_1YHLBogI3CGcw23xAnulp3L_U,1091
|
|
16
|
+
durablewrite-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Javier Lianes
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|