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,84 @@
|
|
|
1
|
+
"""Stable process exit codes.
|
|
2
|
+
|
|
3
|
+
These numbers are part of the published contract: scripts and agents may branch
|
|
4
|
+
on them. They are documented in ``README.md`` and must not be renumbered.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from ..core.errors import ErrorCode, FilekeeperError
|
|
10
|
+
|
|
11
|
+
SUCCESS = 0
|
|
12
|
+
GENERAL_ERROR = 1
|
|
13
|
+
INVALID_ARGUMENT = 2
|
|
14
|
+
PATH_NOT_FOUND = 3
|
|
15
|
+
ACCESS_DENIED = 4
|
|
16
|
+
CONFLICT = 5
|
|
17
|
+
ABSOLUTE_PATH_OUTSIDE_WORKSPACE = 6
|
|
18
|
+
PATCH_FAILED = 7
|
|
19
|
+
PARTIAL_RECOVERY = 8
|
|
20
|
+
STORAGE_ERROR = 9
|
|
21
|
+
SENSITIVE_PATH_REFUSED = 10
|
|
22
|
+
SYMLINK_ESCAPE_REFUSED = 11
|
|
23
|
+
UNDO_CONFLICT = 12
|
|
24
|
+
HISTORY_UNAVAILABLE = 13
|
|
25
|
+
|
|
26
|
+
_BY_CODE: dict[str, int] = {
|
|
27
|
+
str(ErrorCode.INVALID_ARGUMENT): INVALID_ARGUMENT,
|
|
28
|
+
str(ErrorCode.NOT_A_FILE): INVALID_ARGUMENT,
|
|
29
|
+
str(ErrorCode.NOT_A_DIRECTORY): INVALID_ARGUMENT,
|
|
30
|
+
str(ErrorCode.LIMIT_EXCEEDED): INVALID_ARGUMENT,
|
|
31
|
+
str(ErrorCode.INVALID_PATTERN): INVALID_ARGUMENT,
|
|
32
|
+
str(ErrorCode.NO_WORKSPACE_SELECTED): INVALID_ARGUMENT,
|
|
33
|
+
str(ErrorCode.ALREADY_EXISTS): INVALID_ARGUMENT,
|
|
34
|
+
str(ErrorCode.BINARY_CONTENT_REFUSED): INVALID_ARGUMENT,
|
|
35
|
+
str(ErrorCode.PATH_NOT_FOUND): PATH_NOT_FOUND,
|
|
36
|
+
str(ErrorCode.WORKSPACE_NOT_FOUND): PATH_NOT_FOUND,
|
|
37
|
+
str(ErrorCode.PATH_OUTSIDE_WORKSPACE): ACCESS_DENIED,
|
|
38
|
+
str(ErrorCode.ABSOLUTE_PATH_OUTSIDE_WORKSPACE): ABSOLUTE_PATH_OUTSIDE_WORKSPACE,
|
|
39
|
+
str(ErrorCode.SYMLINK_ESCAPE_REFUSED): SYMLINK_ESCAPE_REFUSED,
|
|
40
|
+
str(ErrorCode.PATH_IS_STATE_PATH): ACCESS_DENIED,
|
|
41
|
+
str(ErrorCode.SYMLINK_WRITE_REFUSED): ACCESS_DENIED,
|
|
42
|
+
str(ErrorCode.SPECIAL_FILE_REFUSED): ACCESS_DENIED,
|
|
43
|
+
str(ErrorCode.SENSITIVE_PATH_REFUSED): SENSITIVE_PATH_REFUSED,
|
|
44
|
+
str(ErrorCode.CONTENT_CONFLICT): CONFLICT,
|
|
45
|
+
str(ErrorCode.NO_MATCH): CONFLICT,
|
|
46
|
+
str(ErrorCode.MATCH_COUNT_MISMATCH): CONFLICT,
|
|
47
|
+
str(ErrorCode.PATCH_FAILED): PATCH_FAILED,
|
|
48
|
+
str(ErrorCode.PARTIAL_RECOVERY): PARTIAL_RECOVERY,
|
|
49
|
+
str(ErrorCode.STORAGE_ERROR): STORAGE_ERROR,
|
|
50
|
+
str(ErrorCode.MIGRATION_ERROR): STORAGE_ERROR,
|
|
51
|
+
str(ErrorCode.OPERATION_NOT_FOUND): PATH_NOT_FOUND,
|
|
52
|
+
str(ErrorCode.UNDO_CONFLICT): UNDO_CONFLICT,
|
|
53
|
+
str(ErrorCode.HISTORY_UNAVAILABLE): HISTORY_UNAVAILABLE,
|
|
54
|
+
str(ErrorCode.WORKSPACE_EXISTS): INVALID_ARGUMENT,
|
|
55
|
+
str(ErrorCode.NOT_IMPLEMENTED_YET): GENERAL_ERROR,
|
|
56
|
+
str(ErrorCode.INTERNAL_ERROR): GENERAL_ERROR,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
EXIT_CODE_DOCUMENTATION: tuple[tuple[int, str], ...] = (
|
|
60
|
+
(SUCCESS, "success"),
|
|
61
|
+
(GENERAL_ERROR, "unexpected error"),
|
|
62
|
+
(INVALID_ARGUMENT, "invalid arguments or validation error"),
|
|
63
|
+
(PATH_NOT_FOUND, "path or workspace not found"),
|
|
64
|
+
(ACCESS_DENIED, "permission or workspace-boundary violation"),
|
|
65
|
+
(CONFLICT, "concurrency conflict: stale hash, missing match or match-count mismatch"),
|
|
66
|
+
(ABSOLUTE_PATH_OUTSIDE_WORKSPACE, "absolute path outside the workspace"),
|
|
67
|
+
(PATCH_FAILED, "patch could not be applied; the pre-patch state is intact"),
|
|
68
|
+
(PARTIAL_RECOVERY, "a failed patch could not be fully rolled back"),
|
|
69
|
+
(STORAGE_ERROR, "storage or database error"),
|
|
70
|
+
(SENSITIVE_PATH_REFUSED, "write refused: target looks like credential material"),
|
|
71
|
+
(SYMLINK_ESCAPE_REFUSED, "symlink target outside the workspace"),
|
|
72
|
+
(UNDO_CONFLICT, "undo refused: the path changed after the operation"),
|
|
73
|
+
(HISTORY_UNAVAILABLE, "history needed for restore is missing, corrupt or skipped"),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def exit_code_for(error: FilekeeperError) -> int:
|
|
78
|
+
return _BY_CODE.get(str(error.code), GENERAL_ERROR)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def exit_code_for_code(code: str) -> int:
|
|
82
|
+
"""Map a bare error-code string, for results that report failures as data."""
|
|
83
|
+
|
|
84
|
+
return _BY_CODE.get(code, GENERAL_ERROR)
|
filekeeper/cli/output.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Rendering: human-readable text by default, one stable JSON object with --json.
|
|
2
|
+
|
|
3
|
+
Nothing here decides behaviour; it prints what Core returned. JSON payloads are
|
|
4
|
+
documented in ``README.md``: always an object, always with ``ok``, and errors
|
|
5
|
+
carry ``error.code`` / ``error.message`` / ``error.retryable``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import dataclasses
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from ..core.errors import FilekeeperError
|
|
18
|
+
from ..core.models import (
|
|
19
|
+
ContentSearchResult,
|
|
20
|
+
DirectoryListing,
|
|
21
|
+
ExistsResult,
|
|
22
|
+
FileReadResult,
|
|
23
|
+
MutationResult,
|
|
24
|
+
OperationDiff,
|
|
25
|
+
OperationRecord,
|
|
26
|
+
PathInfo,
|
|
27
|
+
PathSearchResult,
|
|
28
|
+
ReadManyEntry,
|
|
29
|
+
Workspace,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def jsonable(value: Any) -> Any:
|
|
34
|
+
"""Convert dataclasses, enums, tuples and paths into JSON-ready values."""
|
|
35
|
+
|
|
36
|
+
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
37
|
+
fields = dataclasses.fields(value)
|
|
38
|
+
return {field.name: jsonable(getattr(value, field.name)) for field in fields}
|
|
39
|
+
if isinstance(value, Enum):
|
|
40
|
+
return value.value
|
|
41
|
+
if isinstance(value, Path):
|
|
42
|
+
return str(value)
|
|
43
|
+
if isinstance(value, dict):
|
|
44
|
+
return {str(key): jsonable(item) for key, item in value.items()}
|
|
45
|
+
if isinstance(value, (list, tuple)):
|
|
46
|
+
return [jsonable(item) for item in value]
|
|
47
|
+
return value
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def print_json(payload: dict[str, Any]) -> None:
|
|
51
|
+
sys.stdout.write(json.dumps(jsonable(payload), ensure_ascii=False, indent=2, sort_keys=False))
|
|
52
|
+
sys.stdout.write("\n")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def print_error(error: FilekeeperError, *, as_json: bool) -> None:
|
|
56
|
+
if as_json:
|
|
57
|
+
print_json({"ok": False, "error": error.to_dict()})
|
|
58
|
+
return
|
|
59
|
+
sys.stderr.write(f"error: {error.code}: {error.message}\n")
|
|
60
|
+
for key, value in error.details.items():
|
|
61
|
+
sys.stderr.write(f" {key}: {value}\n")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def result_payload(result: Any) -> dict[str, Any]:
|
|
65
|
+
payload = jsonable(result)
|
|
66
|
+
merged: dict[str, Any] = {"ok": True}
|
|
67
|
+
merged.update(payload)
|
|
68
|
+
return merged
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# -- human renderers -------------------------------------------------------
|
|
72
|
+
def read(result: FileReadResult) -> str:
|
|
73
|
+
return result.content
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def read_many(entries: tuple[ReadManyEntry, ...]) -> str:
|
|
77
|
+
chunks: list[str] = []
|
|
78
|
+
for entry in entries:
|
|
79
|
+
chunks.append(f"== {entry.path} ==")
|
|
80
|
+
if entry.ok and entry.result is not None:
|
|
81
|
+
chunks.append(entry.result.content.rstrip("\n"))
|
|
82
|
+
else:
|
|
83
|
+
code = entry.error.code if entry.error else "UNKNOWN"
|
|
84
|
+
message = entry.error.message if entry.error else "read failed"
|
|
85
|
+
chunks.append(f"[{code}] {message}")
|
|
86
|
+
return "\n".join(chunks)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def listing(result: DirectoryListing) -> str:
|
|
90
|
+
lines: list[str] = []
|
|
91
|
+
for entry in result.entries:
|
|
92
|
+
marker = "/" if entry.kind == "directory" else "@" if entry.kind == "symlink" else ""
|
|
93
|
+
lines.append(f"{entry.kind:<9} {entry.size_bytes:>10} {entry.path}{marker}")
|
|
94
|
+
if result.truncated:
|
|
95
|
+
lines.append(f"... truncated at {result.max_entries} entries")
|
|
96
|
+
return "\n".join(lines)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def info(result: PathInfo) -> str:
|
|
100
|
+
lines = [
|
|
101
|
+
f"path: {result.path}",
|
|
102
|
+
f"kind: {result.kind}",
|
|
103
|
+
f"size_bytes: {result.size_bytes}",
|
|
104
|
+
f"mode: {result.mode:04o}",
|
|
105
|
+
f"modified_at: {result.modified_at}",
|
|
106
|
+
]
|
|
107
|
+
if result.is_symlink:
|
|
108
|
+
lines.append(f"symlink_target: {result.symlink_target}")
|
|
109
|
+
if result.content_hash is not None:
|
|
110
|
+
lines.append(f"content_hash: {result.content_hash}")
|
|
111
|
+
if result.is_text is not None:
|
|
112
|
+
lines.append(f"is_text: {result.is_text}")
|
|
113
|
+
if result.line_count is not None:
|
|
114
|
+
lines.append(f"line_count: {result.line_count}")
|
|
115
|
+
if result.encoding is not None:
|
|
116
|
+
lines.append(f"encoding: {result.encoding}")
|
|
117
|
+
if result.newline is not None:
|
|
118
|
+
lines.append(f"newline: {json.dumps(result.newline)}")
|
|
119
|
+
return "\n".join(lines)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def exists(result: ExistsResult) -> str:
|
|
123
|
+
if not result.exists:
|
|
124
|
+
return f"{result.path}: absent"
|
|
125
|
+
return f"{result.path}: present ({result.kind})"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def path_search(result: PathSearchResult) -> str:
|
|
129
|
+
lines = list(result.paths)
|
|
130
|
+
if result.truncated:
|
|
131
|
+
lines.append("... truncated; narrow the glob or raise the result limit")
|
|
132
|
+
return "\n".join(lines)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def content_search(result: ContentSearchResult) -> str:
|
|
136
|
+
lines = [
|
|
137
|
+
f"{match.path}:{match.line_number}:{match.column}: {match.line}" for match in result.matches
|
|
138
|
+
]
|
|
139
|
+
if result.truncated:
|
|
140
|
+
lines.append("... truncated; narrow the query or raise the result limit")
|
|
141
|
+
return "\n".join(lines)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def mutation(result: MutationResult) -> str:
|
|
145
|
+
lines: list[str] = []
|
|
146
|
+
for item in result.items:
|
|
147
|
+
lines.append(f"{item.change:<9} {item.path}")
|
|
148
|
+
lines.append(result.summary)
|
|
149
|
+
if result.after_hash:
|
|
150
|
+
lines.append(f"after_hash: {result.after_hash}")
|
|
151
|
+
if result.before_hash and result.before_hash != result.after_hash:
|
|
152
|
+
lines.append(f"before_hash: {result.before_hash}")
|
|
153
|
+
if result.match_stage is not None:
|
|
154
|
+
lines.append(f"match_stage: {result.match_stage}")
|
|
155
|
+
if result.match_count is not None:
|
|
156
|
+
lines.append(f"match_count: {result.match_count}")
|
|
157
|
+
if result.patch_files:
|
|
158
|
+
lines.append(
|
|
159
|
+
f"patch_files: {len(result.patch_files)} "
|
|
160
|
+
"(recoverable through history, not atomic across files)"
|
|
161
|
+
)
|
|
162
|
+
for file in result.patch_files:
|
|
163
|
+
hunks = ", ".join(
|
|
164
|
+
f"hunk {hunk.old_start}: {hunk.status}" for hunk in file.hunks
|
|
165
|
+
)
|
|
166
|
+
lines.append(f" {file.change:<9} {file.path} {hunks}")
|
|
167
|
+
if result.preview_diff:
|
|
168
|
+
lines.append("diff:")
|
|
169
|
+
lines.extend(result.preview_diff.rstrip("\n").split("\n"))
|
|
170
|
+
if result.operation_id is not None:
|
|
171
|
+
lines.append(f"operation_id: {result.operation_id}")
|
|
172
|
+
lines.append(f"undo_available: {'yes' if result.undo_available else 'no'}")
|
|
173
|
+
elif result.dry_run:
|
|
174
|
+
lines.append("operation_id: none (dry run: nothing was recorded)")
|
|
175
|
+
for warning in result.warnings:
|
|
176
|
+
lines.append(f"warning: {warning}")
|
|
177
|
+
return "\n".join(lines)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def history_list(operations: tuple[OperationRecord, ...]) -> str:
|
|
181
|
+
if not operations:
|
|
182
|
+
return "no recorded operations in this workspace"
|
|
183
|
+
lines = [
|
|
184
|
+
f"{record.operation_id} {record.created_at} {record.status:<9} "
|
|
185
|
+
f"{record.operation_type:<17} {record.target_path or '-'}"
|
|
186
|
+
+ (f" [{record.actor}]" if record.actor else "")
|
|
187
|
+
for record in operations
|
|
188
|
+
]
|
|
189
|
+
return "\n".join(lines)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def history_show(record: OperationRecord) -> str:
|
|
193
|
+
lines = [
|
|
194
|
+
f"operation_id: {record.operation_id}",
|
|
195
|
+
f"workspace_id: {record.workspace_id}",
|
|
196
|
+
f"operation_type: {record.operation_type}",
|
|
197
|
+
f"status: {record.status}",
|
|
198
|
+
f"created_at: {record.created_at}",
|
|
199
|
+
f"target_path: {record.target_path or '-'}",
|
|
200
|
+
f"actor: {record.actor or '-'}",
|
|
201
|
+
f"task_id: {record.task_id or '-'}",
|
|
202
|
+
f"summary: {record.summary or '-'}",
|
|
203
|
+
f"before_hash: {record.before_hash or '-'}",
|
|
204
|
+
f"after_hash: {record.after_hash or '-'}",
|
|
205
|
+
"items:",
|
|
206
|
+
]
|
|
207
|
+
for item in record.items:
|
|
208
|
+
exists = f"{int(item.before_exists)}->{int(item.after_exists)}"
|
|
209
|
+
lines.append(
|
|
210
|
+
f" {item.item_type:<9} {item.relative_path} exists {exists} "
|
|
211
|
+
f"before {item.before_blob_hash or '-'} after {item.after_blob_hash or '-'}"
|
|
212
|
+
)
|
|
213
|
+
if record.history_skipped:
|
|
214
|
+
lines.append("history_skipped: " + ", ".join(record.history_skipped))
|
|
215
|
+
return "\n".join(lines)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def history_diff(diff: OperationDiff) -> str:
|
|
219
|
+
chunks: list[str] = []
|
|
220
|
+
for entry in diff.items:
|
|
221
|
+
if entry.is_binary:
|
|
222
|
+
chunks.append(f"# {entry.path}: binary content, no textual diff")
|
|
223
|
+
continue
|
|
224
|
+
if entry.unified_diff:
|
|
225
|
+
chunks.append(entry.unified_diff.rstrip("\n"))
|
|
226
|
+
return "\n".join(chunks) if chunks else "no textual differences recorded"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def workspace_summary(workspace: Workspace, *, is_default: bool = False) -> str:
|
|
230
|
+
suffix = " (default)" if is_default else ""
|
|
231
|
+
return f"{workspace.name}\t{workspace.root_path}\t{workspace.id}{suffix}"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def workspace_detail(workspace: Workspace, *, is_default: bool = False) -> str:
|
|
235
|
+
return "\n".join(
|
|
236
|
+
[
|
|
237
|
+
f"name: {workspace.name}",
|
|
238
|
+
f"id: {workspace.id}",
|
|
239
|
+
f"root: {workspace.root_path}",
|
|
240
|
+
f"created_at: {workspace.created_at}",
|
|
241
|
+
f"updated_at: {workspace.updated_at}",
|
|
242
|
+
f"default: {is_default}",
|
|
243
|
+
]
|
|
244
|
+
)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Filekeeper Core: workspace-confined, crash-safe filesystem operations.
|
|
2
|
+
|
|
3
|
+
Core imports nothing outside the Python standard library. It has no knowledge of
|
|
4
|
+
the CLI, of the MCP server, or of any filesystem protocol.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .config import Config
|
|
8
|
+
from .errors import ErrorCode, FilekeeperError
|
|
9
|
+
from .history.blobs import BlobStore
|
|
10
|
+
from .models import (
|
|
11
|
+
ContentMatch,
|
|
12
|
+
ContentSearchResult,
|
|
13
|
+
DirectoryEntry,
|
|
14
|
+
DirectoryListing,
|
|
15
|
+
ErrorDetail,
|
|
16
|
+
ExistsResult,
|
|
17
|
+
FileReadResult,
|
|
18
|
+
MutationResult,
|
|
19
|
+
OperationItemResult,
|
|
20
|
+
PathInfo,
|
|
21
|
+
PathSearchResult,
|
|
22
|
+
ReadManyEntry,
|
|
23
|
+
Workspace,
|
|
24
|
+
)
|
|
25
|
+
from .service import Filekeeper, FilekeeperService, init_home
|
|
26
|
+
from .workspace import WorkspaceHandle, WorkspaceRegistry
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"BlobStore",
|
|
30
|
+
"Config",
|
|
31
|
+
"ContentMatch",
|
|
32
|
+
"ContentSearchResult",
|
|
33
|
+
"DirectoryEntry",
|
|
34
|
+
"DirectoryListing",
|
|
35
|
+
"ErrorCode",
|
|
36
|
+
"ErrorDetail",
|
|
37
|
+
"ExistsResult",
|
|
38
|
+
"FileReadResult",
|
|
39
|
+
"Filekeeper",
|
|
40
|
+
"FilekeeperError",
|
|
41
|
+
"FilekeeperService",
|
|
42
|
+
"MutationResult",
|
|
43
|
+
"OperationItemResult",
|
|
44
|
+
"PathInfo",
|
|
45
|
+
"PathSearchResult",
|
|
46
|
+
"ReadManyEntry",
|
|
47
|
+
"Workspace",
|
|
48
|
+
"WorkspaceHandle",
|
|
49
|
+
"WorkspaceRegistry",
|
|
50
|
+
"init_home",
|
|
51
|
+
]
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Home directory resolution and configuration.
|
|
2
|
+
|
|
3
|
+
Two kinds of state exist, and they live in different places on purpose:
|
|
4
|
+
|
|
5
|
+
* the **registry** (workspace registrations and settings) lives in the user's
|
|
6
|
+
home, because it spans workspaces;
|
|
7
|
+
* **per-workspace state** (the future operation journal and blob store) lives in
|
|
8
|
+
``<workspace>/.filekeeper/`` by default, so it travels with the checkout.
|
|
9
|
+
``state_location = "global"`` opts into one shared store under the home
|
|
10
|
+
directory instead.
|
|
11
|
+
|
|
12
|
+
``config.toml`` in the home directory holds tunable limits. It is read on every
|
|
13
|
+
invocation; writing it is left to the user (``keeper init`` creates it with the
|
|
14
|
+
defaults spelled out).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import tomllib
|
|
21
|
+
from dataclasses import dataclass, fields
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from .errors import StorageError, ValidationError
|
|
25
|
+
|
|
26
|
+
CONFIG_FILENAME = "config.toml"
|
|
27
|
+
STATE_DIR_NAME = ".filekeeper"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class Config:
|
|
32
|
+
"""Limits and behaviour switches. Values are bytes unless stated otherwise."""
|
|
33
|
+
|
|
34
|
+
max_read_bytes: int = 5 * 1024 * 1024
|
|
35
|
+
max_write_bytes: int = 20 * 1024 * 1024
|
|
36
|
+
max_search_results: int = 500
|
|
37
|
+
max_directory_entries: int = 2000
|
|
38
|
+
max_recursion_depth: int = 25
|
|
39
|
+
max_line_length: int = 2000
|
|
40
|
+
max_history_blob_bytes: int = 4 * 1024 * 1024
|
|
41
|
+
fsync: bool = True
|
|
42
|
+
state_location: str = "project"
|
|
43
|
+
|
|
44
|
+
def __post_init__(self) -> None:
|
|
45
|
+
if self.state_location not in {"project", "global"}:
|
|
46
|
+
raise ValidationError(
|
|
47
|
+
"state_location must be 'project' or 'global'",
|
|
48
|
+
details={"state_location": self.state_location},
|
|
49
|
+
)
|
|
50
|
+
for field_info in fields(self):
|
|
51
|
+
value = getattr(self, field_info.name)
|
|
52
|
+
if isinstance(value, int) and not isinstance(value, bool) and value <= 0:
|
|
53
|
+
raise ValidationError(
|
|
54
|
+
f"{field_info.name} must be positive", details={"value": value}
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def resolve_home(override: str | os.PathLike[str] | None = None) -> Path:
|
|
59
|
+
"""Resolve Filekeeper's home directory: explicit, ``FILEKEEPER_HOME``, XDG, then default."""
|
|
60
|
+
|
|
61
|
+
if override is not None:
|
|
62
|
+
return Path(override).expanduser()
|
|
63
|
+
from_env = os.environ.get("FILEKEEPER_HOME")
|
|
64
|
+
if from_env:
|
|
65
|
+
return Path(from_env).expanduser()
|
|
66
|
+
xdg = os.environ.get("XDG_DATA_HOME")
|
|
67
|
+
if xdg:
|
|
68
|
+
return Path(xdg).expanduser() / "filekeeper"
|
|
69
|
+
return Path.home() / ".local" / "share" / "filekeeper"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def config_path(home: Path) -> Path:
|
|
73
|
+
return home / CONFIG_FILENAME
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def load_config(home: Path) -> Config:
|
|
77
|
+
"""Load ``config.toml``; a missing file yields defaults. Unknown keys are ignored."""
|
|
78
|
+
|
|
79
|
+
path = config_path(home)
|
|
80
|
+
if not path.exists():
|
|
81
|
+
return Config()
|
|
82
|
+
try:
|
|
83
|
+
with path.open("rb") as handle:
|
|
84
|
+
raw = tomllib.load(handle)
|
|
85
|
+
except (OSError, tomllib.TOMLDecodeError) as exc:
|
|
86
|
+
raise StorageError(
|
|
87
|
+
f"cannot read configuration file: "
|
|
88
|
+
f"{getattr(exc, 'strerror', None) or exc}",
|
|
89
|
+
details={"path": str(path)},
|
|
90
|
+
) from exc
|
|
91
|
+
|
|
92
|
+
section = raw.get("filekeeper", raw)
|
|
93
|
+
if not isinstance(section, dict):
|
|
94
|
+
raise StorageError("configuration file must contain a table", details={"path": str(path)})
|
|
95
|
+
|
|
96
|
+
known = {field_info.name for field_info in fields(Config)}
|
|
97
|
+
values: dict[str, object] = {}
|
|
98
|
+
for key, value in section.items():
|
|
99
|
+
if key in known:
|
|
100
|
+
values[key] = value
|
|
101
|
+
try:
|
|
102
|
+
return Config(**values) # type: ignore[arg-type]
|
|
103
|
+
except TypeError as exc:
|
|
104
|
+
raise StorageError(
|
|
105
|
+
f"invalid configuration value: {exc}", details={"path": str(path)}
|
|
106
|
+
) from exc
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
DEFAULT_CONFIG_TOML = """\
|
|
110
|
+
# Filekeeper configuration.
|
|
111
|
+
# All sizes are bytes. Delete a key to fall back to the built-in default.
|
|
112
|
+
|
|
113
|
+
[filekeeper]
|
|
114
|
+
max_read_bytes = 5242880
|
|
115
|
+
max_write_bytes = 20971520
|
|
116
|
+
max_search_results = 500
|
|
117
|
+
max_directory_entries = 2000
|
|
118
|
+
max_recursion_depth = 25
|
|
119
|
+
max_line_length = 2000
|
|
120
|
+
|
|
121
|
+
# Files above this size are skipped by history: the mutation is still recorded,
|
|
122
|
+
# but its previous bytes are not stored, so it cannot be undone. This keeps one
|
|
123
|
+
# large edit from silently filling the blob store.
|
|
124
|
+
max_history_blob_bytes = 4194304
|
|
125
|
+
|
|
126
|
+
# Durability of atomic writes: flush and fsync the temporary file and the
|
|
127
|
+
# parent directory before reporting success.
|
|
128
|
+
fsync = true
|
|
129
|
+
|
|
130
|
+
# "project" keeps per-workspace state in <workspace>/.filekeeper/ (default).
|
|
131
|
+
# "global" keeps one shared store under this home directory instead.
|
|
132
|
+
state_location = "project"
|
|
133
|
+
"""
|