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.
@@ -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
+ ]
@@ -0,0 +1,9 @@
1
+ """Allow ``python -m durablewrite``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from ._cli import main
8
+
9
+ sys.exit(main())
@@ -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
@@ -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
@@ -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
@@ -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
+ )
@@ -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."""
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ durablewrite = durablewrite._cli:main
@@ -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.