filekeeper-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- filekeeper/__init__.py +35 -0
- filekeeper/cli/__init__.py +5 -0
- filekeeper/cli/app.py +866 -0
- filekeeper/cli/exit_codes.py +84 -0
- filekeeper/cli/output.py +244 -0
- filekeeper/core/__init__.py +51 -0
- filekeeper/core/config.py +133 -0
- filekeeper/core/database.py +226 -0
- filekeeper/core/diffing.py +127 -0
- filekeeper/core/errors.py +278 -0
- filekeeper/core/history/__init__.py +20 -0
- filekeeper/core/history/blobs.py +92 -0
- filekeeper/core/history/journal.py +430 -0
- filekeeper/core/history/repository.py +152 -0
- filekeeper/core/history/undo.py +358 -0
- filekeeper/core/models.py +293 -0
- filekeeper/core/operations/__init__.py +9 -0
- filekeeper/core/operations/directory.py +475 -0
- filekeeper/core/operations/edit.py +1239 -0
- filekeeper/core/operations/read.py +370 -0
- filekeeper/core/operations/search.py +198 -0
- filekeeper/core/operations/write.py +763 -0
- filekeeper/core/paths.py +236 -0
- filekeeper/core/safety/__init__.py +5 -0
- filekeeper/core/safety/atomic.py +129 -0
- filekeeper/core/safety/hashing.py +73 -0
- filekeeper/core/safety/sensitive.py +65 -0
- filekeeper/core/safety/textdata.py +142 -0
- filekeeper/core/safety/vcs.py +51 -0
- filekeeper/core/service.py +706 -0
- filekeeper/core/workspace.py +300 -0
- filekeeper_cli-0.1.0.dist-info/METADATA +498 -0
- filekeeper_cli-0.1.0.dist-info/RECORD +36 -0
- filekeeper_cli-0.1.0.dist-info/WHEEL +4 -0
- filekeeper_cli-0.1.0.dist-info/entry_points.txt +3 -0
- filekeeper_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""SQLite connection setup and forward-only migrations.
|
|
2
|
+
|
|
3
|
+
There are two migration chains because there are two stores:
|
|
4
|
+
|
|
5
|
+
* the **registry** database (``<home>/metadata.sqlite3``) holds ``workspaces``
|
|
6
|
+
and ``settings`` — data that spans workspaces and cannot live inside one;
|
|
7
|
+
* the **workspace** database (``<workspace>/.filekeeper/state.sqlite3``, or the
|
|
8
|
+
shared location in global mode) holds ``operations``, ``operation_items``,
|
|
9
|
+
``blobs`` and ``locks`` — per-workspace state that travels with the tree.
|
|
10
|
+
|
|
11
|
+
Both chains are ordered, numbered, and applied at most once. A database whose
|
|
12
|
+
recorded version is ahead of this code is refused rather than guessed at.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import sqlite3
|
|
19
|
+
import stat
|
|
20
|
+
import uuid
|
|
21
|
+
from collections.abc import Sequence
|
|
22
|
+
from datetime import UTC, datetime
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from .errors import MigrationError, StorageError
|
|
26
|
+
|
|
27
|
+
REGISTRY_SCHEMA_VERSION = 1
|
|
28
|
+
WORKSPACE_SCHEMA_VERSION = 1
|
|
29
|
+
|
|
30
|
+
_REGISTRY_MIGRATIONS: tuple[tuple[int, tuple[str, ...]], ...] = (
|
|
31
|
+
(
|
|
32
|
+
1,
|
|
33
|
+
(
|
|
34
|
+
"""
|
|
35
|
+
CREATE TABLE workspaces (
|
|
36
|
+
id TEXT PRIMARY KEY,
|
|
37
|
+
name TEXT NOT NULL UNIQUE,
|
|
38
|
+
root_path TEXT NOT NULL,
|
|
39
|
+
created_at TEXT NOT NULL,
|
|
40
|
+
updated_at TEXT NOT NULL
|
|
41
|
+
)
|
|
42
|
+
""",
|
|
43
|
+
"CREATE UNIQUE INDEX idx_workspaces_root_path ON workspaces(root_path)",
|
|
44
|
+
"""
|
|
45
|
+
CREATE TABLE settings (
|
|
46
|
+
key TEXT PRIMARY KEY,
|
|
47
|
+
value TEXT NOT NULL
|
|
48
|
+
)
|
|
49
|
+
""",
|
|
50
|
+
),
|
|
51
|
+
),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
_WORKSPACE_MIGRATIONS: tuple[tuple[int, tuple[str, ...]], ...] = (
|
|
55
|
+
(
|
|
56
|
+
1,
|
|
57
|
+
(
|
|
58
|
+
"""
|
|
59
|
+
CREATE TABLE operations (
|
|
60
|
+
id TEXT PRIMARY KEY,
|
|
61
|
+
workspace_id TEXT NOT NULL,
|
|
62
|
+
operation_type TEXT NOT NULL,
|
|
63
|
+
target_path TEXT,
|
|
64
|
+
status TEXT NOT NULL CHECK (
|
|
65
|
+
status IN ('pending', 'committed', 'undone', 'failed', 'partially_undone')
|
|
66
|
+
),
|
|
67
|
+
created_at TEXT NOT NULL,
|
|
68
|
+
actor TEXT,
|
|
69
|
+
task_id TEXT,
|
|
70
|
+
summary TEXT,
|
|
71
|
+
before_hash TEXT,
|
|
72
|
+
after_hash TEXT,
|
|
73
|
+
metadata_json TEXT
|
|
74
|
+
)
|
|
75
|
+
""",
|
|
76
|
+
"CREATE INDEX idx_operations_workspace_time ON operations(workspace_id, created_at)",
|
|
77
|
+
"CREATE INDEX idx_operations_target_path ON operations(target_path)",
|
|
78
|
+
"CREATE INDEX idx_operations_task_id ON operations(task_id)",
|
|
79
|
+
"""
|
|
80
|
+
CREATE TABLE operation_items (
|
|
81
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
82
|
+
operation_id TEXT NOT NULL REFERENCES operations(id) ON DELETE CASCADE,
|
|
83
|
+
relative_path TEXT NOT NULL,
|
|
84
|
+
item_type TEXT NOT NULL,
|
|
85
|
+
before_blob_hash TEXT,
|
|
86
|
+
after_blob_hash TEXT,
|
|
87
|
+
before_exists INTEGER NOT NULL DEFAULT 0,
|
|
88
|
+
after_exists INTEGER NOT NULL DEFAULT 0,
|
|
89
|
+
before_mode INTEGER,
|
|
90
|
+
after_mode INTEGER
|
|
91
|
+
)
|
|
92
|
+
""",
|
|
93
|
+
"CREATE INDEX idx_operation_items_operation ON operation_items(operation_id)",
|
|
94
|
+
"CREATE INDEX idx_operation_items_path ON operation_items(relative_path)",
|
|
95
|
+
"""
|
|
96
|
+
CREATE TABLE blobs (
|
|
97
|
+
hash TEXT PRIMARY KEY,
|
|
98
|
+
size INTEGER NOT NULL,
|
|
99
|
+
created_at TEXT NOT NULL
|
|
100
|
+
)
|
|
101
|
+
""",
|
|
102
|
+
"""
|
|
103
|
+
CREATE TABLE locks (
|
|
104
|
+
workspace_id TEXT NOT NULL,
|
|
105
|
+
relative_path TEXT NOT NULL,
|
|
106
|
+
owner TEXT NOT NULL,
|
|
107
|
+
token TEXT NOT NULL,
|
|
108
|
+
expires_at TEXT NOT NULL,
|
|
109
|
+
created_at TEXT NOT NULL,
|
|
110
|
+
PRIMARY KEY (workspace_id, relative_path)
|
|
111
|
+
)
|
|
112
|
+
""",
|
|
113
|
+
"CREATE INDEX idx_locks_expires_at ON locks(expires_at)",
|
|
114
|
+
),
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
_MIGRATION_TABLE_SQL = """
|
|
119
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
120
|
+
version INTEGER PRIMARY KEY,
|
|
121
|
+
applied_at TEXT NOT NULL
|
|
122
|
+
)
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def utc_now() -> str:
|
|
127
|
+
"""Current UTC time as an ISO-8601 string with second precision."""
|
|
128
|
+
|
|
129
|
+
return datetime.now(UTC).replace(microsecond=0).isoformat()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def uuid_token() -> str:
|
|
133
|
+
"""A short, URL-safe, collision-resistant identifier fragment."""
|
|
134
|
+
|
|
135
|
+
return uuid.uuid4().hex[:16]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def connect(path: Path) -> sqlite3.Connection:
|
|
139
|
+
"""Open a SQLite database with durable, foreign-key-enforcing settings."""
|
|
140
|
+
|
|
141
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
try:
|
|
143
|
+
os.chmod(path.parent, stat.S_IRWXU)
|
|
144
|
+
except OSError: # pragma: no cover - restricted filesystems
|
|
145
|
+
pass
|
|
146
|
+
try:
|
|
147
|
+
connection = sqlite3.connect(path, timeout=10.0)
|
|
148
|
+
except sqlite3.Error as exc:
|
|
149
|
+
raise StorageError(f"cannot open database: {exc}", details={"path": str(path)}) from exc
|
|
150
|
+
connection.row_factory = sqlite3.Row
|
|
151
|
+
connection.execute("PRAGMA journal_mode = WAL")
|
|
152
|
+
# Full synchronous mode: a committed record survives an OS crash, which the
|
|
153
|
+
# recovery story depends on.
|
|
154
|
+
connection.execute("PRAGMA synchronous = FULL")
|
|
155
|
+
connection.execute("PRAGMA foreign_keys = ON")
|
|
156
|
+
connection.execute("PRAGMA busy_timeout = 5000")
|
|
157
|
+
return connection
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _current_version(connection: sqlite3.Connection) -> int:
|
|
161
|
+
connection.execute(_MIGRATION_TABLE_SQL)
|
|
162
|
+
row = connection.execute("SELECT MAX(version) AS version FROM schema_migrations").fetchone()
|
|
163
|
+
if row is None or row["version"] is None:
|
|
164
|
+
return 0
|
|
165
|
+
return int(row["version"])
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def migrate(
|
|
169
|
+
connection: sqlite3.Connection,
|
|
170
|
+
migrations: Sequence[tuple[int, tuple[str, ...]]],
|
|
171
|
+
*,
|
|
172
|
+
label: str,
|
|
173
|
+
) -> int:
|
|
174
|
+
"""Apply every pending migration in order; return the resulting version."""
|
|
175
|
+
|
|
176
|
+
current = _current_version(connection)
|
|
177
|
+
target = max((version for version, _ in migrations), default=0)
|
|
178
|
+
if current > target:
|
|
179
|
+
raise MigrationError(
|
|
180
|
+
f"{label} database schema version {current} is newer than this release "
|
|
181
|
+
f"supports ({target}); upgrade Filekeeper",
|
|
182
|
+
details={"current": current, "supported": target},
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
for version, statements in migrations:
|
|
186
|
+
if version <= current:
|
|
187
|
+
continue
|
|
188
|
+
try:
|
|
189
|
+
with connection:
|
|
190
|
+
for statement in statements:
|
|
191
|
+
connection.execute(statement)
|
|
192
|
+
connection.execute(
|
|
193
|
+
"INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)",
|
|
194
|
+
(version, utc_now()),
|
|
195
|
+
)
|
|
196
|
+
except sqlite3.Error as exc:
|
|
197
|
+
raise MigrationError(
|
|
198
|
+
f"cannot migrate {label} database to version {version}: {exc}",
|
|
199
|
+
details={"label": label, "version": version},
|
|
200
|
+
) from exc
|
|
201
|
+
current = version
|
|
202
|
+
return current
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def migrate_registry(connection: sqlite3.Connection) -> int:
|
|
206
|
+
return migrate(connection, _REGISTRY_MIGRATIONS, label="registry")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def migrate_workspace(connection: sqlite3.Connection) -> int:
|
|
210
|
+
return migrate(connection, _WORKSPACE_MIGRATIONS, label="workspace")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def open_registry_database(path: Path) -> sqlite3.Connection:
|
|
214
|
+
connection = connect(path)
|
|
215
|
+
migrate_registry(connection)
|
|
216
|
+
return connection
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def open_workspace_database(path: Path) -> sqlite3.Connection:
|
|
220
|
+
connection = connect(path)
|
|
221
|
+
migrate_workspace(connection)
|
|
222
|
+
try:
|
|
223
|
+
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
|
224
|
+
except OSError: # pragma: no cover - restricted filesystems
|
|
225
|
+
pass
|
|
226
|
+
return connection
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Unified diff rendering that never concatenates two lines.
|
|
2
|
+
|
|
3
|
+
``difflib.unified_diff`` renders each input line exactly as it received it, so a
|
|
4
|
+
final line without a newline carries no terminator and runs straight into the
|
|
5
|
+
next rendered line. That produces output no patch consumer can read.
|
|
6
|
+
|
|
7
|
+
This module normalises every rendered content line to end with a newline and
|
|
8
|
+
states a missing final newline explicitly with the conventional
|
|
9
|
+
``\`` marker. Every line of the result, including the
|
|
10
|
+
last, ends with ``\\n``.
|
|
11
|
+
|
|
12
|
+
The same function feeds the human view, the JSON payload and the dry-run
|
|
13
|
+
preview, so all three describe a change identically.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import difflib
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
|
|
21
|
+
NO_NEWLINE_MARKER = "\"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class DiffLine:
|
|
26
|
+
"""One line of text, split from its terminator.
|
|
27
|
+
|
|
28
|
+
``text`` keeps every character except the terminator, including unusual
|
|
29
|
+
separators such as a form feed, so joining ``raw`` values reproduces the
|
|
30
|
+
input exactly.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
text: str
|
|
34
|
+
ending: str
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def raw(self) -> str:
|
|
38
|
+
return self.text + self.ending
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def split_lines(text: str) -> list[DiffLine]:
|
|
42
|
+
"""Split text into lines without discarding each line's own terminator."""
|
|
43
|
+
|
|
44
|
+
lines: list[DiffLine] = []
|
|
45
|
+
for piece in text.splitlines(keepends=True):
|
|
46
|
+
if piece.endswith("\r\n"):
|
|
47
|
+
lines.append(DiffLine(piece[:-2], "\r\n"))
|
|
48
|
+
elif piece.endswith(("\n", "\r")):
|
|
49
|
+
lines.append(DiffLine(piece[:-1], piece[-1]))
|
|
50
|
+
else:
|
|
51
|
+
lines.append(DiffLine(piece, ""))
|
|
52
|
+
return lines
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def join_lines(lines: list[DiffLine]) -> str:
|
|
56
|
+
return "".join(line.raw for line in lines)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _unterminated_last_line(text: str) -> bool:
|
|
60
|
+
if not text:
|
|
61
|
+
return False
|
|
62
|
+
return not text.endswith(("\n", "\r"))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def unified_diff_text(
|
|
66
|
+
before: str,
|
|
67
|
+
after: str,
|
|
68
|
+
*,
|
|
69
|
+
fromfile: str,
|
|
70
|
+
tofile: str,
|
|
71
|
+
) -> str:
|
|
72
|
+
"""Render a unified diff in which every line is newline-terminated.
|
|
73
|
+
|
|
74
|
+
A side that does not end with a newline gets an explicit marker after the
|
|
75
|
+
rendered line that represents its final line. The marker is emitted once
|
|
76
|
+
when both sides share the same final context line.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
before_lines = split_lines(before)
|
|
80
|
+
after_lines = split_lines(after)
|
|
81
|
+
before_texts = [line.text for line in before_lines]
|
|
82
|
+
after_texts = [line.text for line in after_lines]
|
|
83
|
+
|
|
84
|
+
rendered = list(
|
|
85
|
+
difflib.unified_diff(
|
|
86
|
+
[text + "\n" for text in before_texts],
|
|
87
|
+
[text + "\n" for text in after_texts],
|
|
88
|
+
fromfile=fromfile,
|
|
89
|
+
tofile=tofile,
|
|
90
|
+
lineterm="\n",
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
if not rendered:
|
|
94
|
+
return ""
|
|
95
|
+
|
|
96
|
+
inserts: dict[int, bool] = {}
|
|
97
|
+
|
|
98
|
+
if before_lines and not before_lines[-1].ending:
|
|
99
|
+
target = before_texts[-1] + "\n"
|
|
100
|
+
for index in range(len(rendered) - 1, 1, -1):
|
|
101
|
+
if rendered[index][:1] in {"-", " "} and rendered[index][1:] == target:
|
|
102
|
+
inserts[index] = True
|
|
103
|
+
break
|
|
104
|
+
|
|
105
|
+
if after_lines and not after_lines[-1].ending:
|
|
106
|
+
target = after_texts[-1] + "\n"
|
|
107
|
+
for index in range(len(rendered) - 1, 1, -1):
|
|
108
|
+
if rendered[index][:1] in {"+", " "} and rendered[index][1:] == target:
|
|
109
|
+
inserts[index] = True
|
|
110
|
+
break
|
|
111
|
+
|
|
112
|
+
output: list[str] = []
|
|
113
|
+
marker = NO_NEWLINE_MARKER + "\n"
|
|
114
|
+
for index, line in enumerate(rendered):
|
|
115
|
+
output.append(line)
|
|
116
|
+
if index in inserts:
|
|
117
|
+
output.append(marker)
|
|
118
|
+
return "".join(output)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
__all__ = [
|
|
122
|
+
"NO_NEWLINE_MARKER",
|
|
123
|
+
"DiffLine",
|
|
124
|
+
"join_lines",
|
|
125
|
+
"split_lines",
|
|
126
|
+
"unified_diff_text",
|
|
127
|
+
]
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Structured errors with stable, documented codes.
|
|
2
|
+
|
|
3
|
+
Every failure surfaced by Core is a :class:`FilekeeperError`. The code is the
|
|
4
|
+
stable part of the contract: adapters (CLI, MCP) map codes to exit codes and to
|
|
5
|
+
error payloads, and callers may branch on the code without parsing prose.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from enum import StrEnum
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ErrorCode(StrEnum):
|
|
15
|
+
"""Stable error codes. Adding a code is a compatible change; renaming one is not."""
|
|
16
|
+
|
|
17
|
+
INVALID_ARGUMENT = "INVALID_ARGUMENT"
|
|
18
|
+
PATH_NOT_FOUND = "PATH_NOT_FOUND"
|
|
19
|
+
NOT_A_FILE = "NOT_A_FILE"
|
|
20
|
+
NOT_A_DIRECTORY = "NOT_A_DIRECTORY"
|
|
21
|
+
PATH_OUTSIDE_WORKSPACE = "PATH_OUTSIDE_WORKSPACE"
|
|
22
|
+
ABSOLUTE_PATH_OUTSIDE_WORKSPACE = "ABSOLUTE_PATH_OUTSIDE_WORKSPACE"
|
|
23
|
+
SYMLINK_ESCAPE_REFUSED = "SYMLINK_ESCAPE_REFUSED"
|
|
24
|
+
PATH_IS_STATE_PATH = "PATH_IS_STATE_PATH"
|
|
25
|
+
SYMLINK_WRITE_REFUSED = "SYMLINK_WRITE_REFUSED"
|
|
26
|
+
SPECIAL_FILE_REFUSED = "SPECIAL_FILE_REFUSED"
|
|
27
|
+
SENSITIVE_PATH_REFUSED = "SENSITIVE_PATH_REFUSED"
|
|
28
|
+
ALREADY_EXISTS = "ALREADY_EXISTS"
|
|
29
|
+
CONTENT_CONFLICT = "CONTENT_CONFLICT"
|
|
30
|
+
NO_MATCH = "NO_MATCH"
|
|
31
|
+
MATCH_COUNT_MISMATCH = "MATCH_COUNT_MISMATCH"
|
|
32
|
+
PATCH_FAILED = "PATCH_FAILED"
|
|
33
|
+
PARTIAL_RECOVERY = "PARTIAL_RECOVERY"
|
|
34
|
+
BINARY_CONTENT_REFUSED = "BINARY_CONTENT_REFUSED"
|
|
35
|
+
LIMIT_EXCEEDED = "LIMIT_EXCEEDED"
|
|
36
|
+
INVALID_PATTERN = "INVALID_PATTERN"
|
|
37
|
+
WORKSPACE_NOT_FOUND = "WORKSPACE_NOT_FOUND"
|
|
38
|
+
WORKSPACE_EXISTS = "WORKSPACE_EXISTS"
|
|
39
|
+
NO_WORKSPACE_SELECTED = "NO_WORKSPACE_SELECTED"
|
|
40
|
+
STORAGE_ERROR = "STORAGE_ERROR"
|
|
41
|
+
MIGRATION_ERROR = "MIGRATION_ERROR"
|
|
42
|
+
OPERATION_NOT_FOUND = "OPERATION_NOT_FOUND"
|
|
43
|
+
UNDO_CONFLICT = "UNDO_CONFLICT"
|
|
44
|
+
HISTORY_UNAVAILABLE = "HISTORY_UNAVAILABLE"
|
|
45
|
+
NOT_IMPLEMENTED_YET = "NOT_IMPLEMENTED_YET"
|
|
46
|
+
INTERNAL_ERROR = "INTERNAL_ERROR"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class FilekeeperError(Exception):
|
|
50
|
+
"""Base error carrying a stable code, a human message, and optional details."""
|
|
51
|
+
|
|
52
|
+
code: ErrorCode = ErrorCode.INTERNAL_ERROR
|
|
53
|
+
retryable: bool = False
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
message: str,
|
|
58
|
+
*,
|
|
59
|
+
code: ErrorCode | None = None,
|
|
60
|
+
retryable: bool | None = None,
|
|
61
|
+
details: dict[str, Any] | None = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
super().__init__(message)
|
|
64
|
+
self.message = message
|
|
65
|
+
if code is not None:
|
|
66
|
+
self.code = code
|
|
67
|
+
if retryable is not None:
|
|
68
|
+
self.retryable = retryable
|
|
69
|
+
self.details: dict[str, Any] = details or {}
|
|
70
|
+
|
|
71
|
+
def to_dict(self) -> dict[str, Any]:
|
|
72
|
+
payload: dict[str, Any] = {
|
|
73
|
+
"code": str(self.code),
|
|
74
|
+
"message": self.message,
|
|
75
|
+
"retryable": self.retryable,
|
|
76
|
+
}
|
|
77
|
+
if self.details:
|
|
78
|
+
payload["details"] = dict(self.details)
|
|
79
|
+
return payload
|
|
80
|
+
|
|
81
|
+
def __str__(self) -> str:
|
|
82
|
+
return f"{self.code}: {self.message}"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class ValidationError(FilekeeperError):
|
|
86
|
+
code = ErrorCode.INVALID_ARGUMENT
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class PathNotFoundError(FilekeeperError):
|
|
90
|
+
code = ErrorCode.PATH_NOT_FOUND
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class NotAFileError(FilekeeperError):
|
|
94
|
+
code = ErrorCode.NOT_A_FILE
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class NotADirectoryError(FilekeeperError):
|
|
98
|
+
code = ErrorCode.NOT_A_DIRECTORY
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class PathOutsideWorkspaceError(FilekeeperError):
|
|
102
|
+
"""A relative path used ``..`` traversal."""
|
|
103
|
+
|
|
104
|
+
code = ErrorCode.PATH_OUTSIDE_WORKSPACE
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class AbsolutePathOutsideWorkspaceError(PathOutsideWorkspaceError):
|
|
108
|
+
"""An absolute path resolved outside the selected workspace."""
|
|
109
|
+
|
|
110
|
+
code = ErrorCode.ABSOLUTE_PATH_OUTSIDE_WORKSPACE
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class SymlinkEscapeRefusedError(PathOutsideWorkspaceError):
|
|
114
|
+
"""A symlink inside the workspace resolved to a target outside it."""
|
|
115
|
+
|
|
116
|
+
code = ErrorCode.SYMLINK_ESCAPE_REFUSED
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class StatePathError(FilekeeperError):
|
|
120
|
+
"""A path points inside Filekeeper's own state directory."""
|
|
121
|
+
|
|
122
|
+
code = ErrorCode.PATH_IS_STATE_PATH
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class SymlinkWriteRefusedError(FilekeeperError):
|
|
126
|
+
code = ErrorCode.SYMLINK_WRITE_REFUSED
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class SpecialFileRefusedError(FilekeeperError):
|
|
130
|
+
code = ErrorCode.SPECIAL_FILE_REFUSED
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class SensitivePathRefusedError(FilekeeperError):
|
|
134
|
+
"""The target looks like credential or key material and was refused."""
|
|
135
|
+
|
|
136
|
+
code = ErrorCode.SENSITIVE_PATH_REFUSED
|
|
137
|
+
|
|
138
|
+
def __init__(self, message: str, *, details: dict[str, Any] | None = None) -> None:
|
|
139
|
+
super().__init__(message, details=details)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class AlreadyExistsError(FilekeeperError):
|
|
143
|
+
code = ErrorCode.ALREADY_EXISTS
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class ContentConflictError(FilekeeperError):
|
|
147
|
+
"""The guarded write's expected hash or content no longer matches.
|
|
148
|
+
|
|
149
|
+
The identical request cannot succeed, so ``retryable`` is ``False``. The
|
|
150
|
+
recovery path is stated in the payload: read the current state again and
|
|
151
|
+
issue the write against the hash that read returned.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
code = ErrorCode.CONTENT_CONFLICT
|
|
155
|
+
retryable = False
|
|
156
|
+
|
|
157
|
+
def __init__(self, message: str, *, details: dict[str, Any] | None = None) -> None:
|
|
158
|
+
payload: dict[str, Any] = {
|
|
159
|
+
"recovery": "re-read the target, then retry against its current hash"
|
|
160
|
+
}
|
|
161
|
+
if details:
|
|
162
|
+
payload.update(details)
|
|
163
|
+
super().__init__(message, details=payload)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class NoMatchError(FilekeeperError):
|
|
167
|
+
"""No occurrence of the requested text was found; nothing was modified."""
|
|
168
|
+
|
|
169
|
+
code = ErrorCode.NO_MATCH
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class MatchCountMismatchError(FilekeeperError):
|
|
173
|
+
"""The requested text matched a different number of times than expected."""
|
|
174
|
+
|
|
175
|
+
code = ErrorCode.MATCH_COUNT_MISMATCH
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class PatchFailedError(FilekeeperError):
|
|
179
|
+
"""A patch was rejected or could not be applied; the pre-patch state is intact."""
|
|
180
|
+
|
|
181
|
+
code = ErrorCode.PATCH_FAILED
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class PartialRecoveryError(FilekeeperError):
|
|
185
|
+
"""A failed multi-file application could not be fully rolled back."""
|
|
186
|
+
|
|
187
|
+
code = ErrorCode.PARTIAL_RECOVERY
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class BinaryContentRefusedError(FilekeeperError):
|
|
191
|
+
code = ErrorCode.BINARY_CONTENT_REFUSED
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class LimitExceededError(FilekeeperError):
|
|
195
|
+
code = ErrorCode.LIMIT_EXCEEDED
|
|
196
|
+
|
|
197
|
+
def __init__(
|
|
198
|
+
self, message: str, *, limit: int, actual: int, details: dict[str, Any] | None = None
|
|
199
|
+
) -> None:
|
|
200
|
+
payload: dict[str, Any] = {"limit": limit, "actual": actual}
|
|
201
|
+
if details:
|
|
202
|
+
payload.update(details)
|
|
203
|
+
super().__init__(message, details=payload)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class InvalidPatternError(FilekeeperError):
|
|
207
|
+
code = ErrorCode.INVALID_PATTERN
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class WorkspaceNotFoundError(FilekeeperError):
|
|
211
|
+
code = ErrorCode.WORKSPACE_NOT_FOUND
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class WorkspaceExistsError(FilekeeperError):
|
|
215
|
+
code = ErrorCode.WORKSPACE_EXISTS
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class NoWorkspaceSelectedError(FilekeeperError):
|
|
219
|
+
code = ErrorCode.NO_WORKSPACE_SELECTED
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class StorageError(FilekeeperError):
|
|
223
|
+
code = ErrorCode.STORAGE_ERROR
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class MigrationError(FilekeeperError):
|
|
227
|
+
code = ErrorCode.MIGRATION_ERROR
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class OperationNotFoundError(FilekeeperError):
|
|
231
|
+
"""No operation with that id is recorded in this workspace."""
|
|
232
|
+
|
|
233
|
+
code = ErrorCode.OPERATION_NOT_FOUND
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class UndoConflictError(FilekeeperError):
|
|
237
|
+
"""Undo would destroy a later change; nothing was modified."""
|
|
238
|
+
|
|
239
|
+
code = ErrorCode.UNDO_CONFLICT
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class HistoryUnavailableError(FilekeeperError):
|
|
243
|
+
"""The bytes needed to undo are missing, corrupt or were never stored."""
|
|
244
|
+
|
|
245
|
+
code = ErrorCode.HISTORY_UNAVAILABLE
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class NotImplementedYetError(FilekeeperError, NotImplementedError):
|
|
249
|
+
"""A published API surface whose implementation is deliberately a later release.
|
|
250
|
+
|
|
251
|
+
It subclasses :class:`NotImplementedError` as well, so callers that guard the
|
|
252
|
+
standard exception also catch it. It is never a silent no-op.
|
|
253
|
+
"""
|
|
254
|
+
|
|
255
|
+
code = ErrorCode.NOT_IMPLEMENTED_YET
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def storage_failure(action: str, path: str, exc: OSError) -> StorageError:
|
|
259
|
+
"""Build the :class:`StorageError` for a failed filesystem call on ``path``.
|
|
260
|
+
|
|
261
|
+
``path`` is the caller-facing path: the workspace-relative form for anything
|
|
262
|
+
inside a workspace. An ``OSError``'s own message embeds the resolved host
|
|
263
|
+
path, so only its standard reason phrase is carried into the message.
|
|
264
|
+
|
|
265
|
+
Guaranteed: a workspace file or directory is never named by its resolved
|
|
266
|
+
host path in a :class:`StorageError`, neither in the message nor in
|
|
267
|
+
``details``. Every construction site that has a workspace path in hand
|
|
268
|
+
passes the relative form, and :func:`filekeeper.core.safety.hashing.hash_file`
|
|
269
|
+
never falls back to the resolved path.
|
|
270
|
+
|
|
271
|
+
Not covered: locations the operator chooses rather than workspace content —
|
|
272
|
+
the state directory and its database, and the configuration file. Those
|
|
273
|
+
errors report the location as the operator supplied it, and their message
|
|
274
|
+
still carries only the reason phrase.
|
|
275
|
+
"""
|
|
276
|
+
|
|
277
|
+
reason = exc.strerror or type(exc).__name__
|
|
278
|
+
return StorageError(f"cannot {action} {path}: {reason}", details={"path": path})
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Durable operation history: blob store, journal, read surfaces, and undo."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .blobs import BlobStore
|
|
6
|
+
from .journal import BeginResult, OperationJournal, PendingItem
|
|
7
|
+
from .repository import diff_operation, get_operation, list_history
|
|
8
|
+
from .undo import undo_last, undo_operation
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"BeginResult",
|
|
12
|
+
"BlobStore",
|
|
13
|
+
"OperationJournal",
|
|
14
|
+
"PendingItem",
|
|
15
|
+
"diff_operation",
|
|
16
|
+
"get_operation",
|
|
17
|
+
"list_history",
|
|
18
|
+
"undo_last",
|
|
19
|
+
"undo_operation",
|
|
20
|
+
]
|