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,706 @@
|
|
|
1
|
+
"""The public Core API.
|
|
2
|
+
|
|
3
|
+
Two objects make up the surface:
|
|
4
|
+
|
|
5
|
+
* :class:`Filekeeper` — home-scoped: initialisation, workspace registration and
|
|
6
|
+
selection. It is the entry point for adapters.
|
|
7
|
+
* :class:`FilekeeperService` — workspace-scoped: every filesystem operation.
|
|
8
|
+
|
|
9
|
+
Both layers are plain Python with no adapter concerns. The CLI and the future MCP
|
|
10
|
+
server are thin: parse, call, render. The mutating methods already return the
|
|
11
|
+
result shape that the history layer will fill, so recording history later changes
|
|
12
|
+
values inside results, never their fields.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from . import database, operations
|
|
20
|
+
from .config import (
|
|
21
|
+
CONFIG_FILENAME,
|
|
22
|
+
DEFAULT_CONFIG_TOML,
|
|
23
|
+
STATE_DIR_NAME,
|
|
24
|
+
Config,
|
|
25
|
+
load_config,
|
|
26
|
+
resolve_home,
|
|
27
|
+
)
|
|
28
|
+
from .errors import NoWorkspaceSelectedError, ValidationError
|
|
29
|
+
from .history.blobs import BlobStore
|
|
30
|
+
from .history.journal import OperationJournal
|
|
31
|
+
from .history.repository import diff_operation as read_diff
|
|
32
|
+
from .history.repository import get_operation as read_operation
|
|
33
|
+
from .history.repository import list_history as read_history
|
|
34
|
+
from .history.undo import undo_last as perform_undo_last
|
|
35
|
+
from .history.undo import undo_operation as perform_undo
|
|
36
|
+
from .models import (
|
|
37
|
+
ContentSearchResult,
|
|
38
|
+
DirectoryListing,
|
|
39
|
+
ExistsResult,
|
|
40
|
+
FileReadResult,
|
|
41
|
+
MutationResult,
|
|
42
|
+
OperationDiff,
|
|
43
|
+
OperationRecord,
|
|
44
|
+
PathInfo,
|
|
45
|
+
PathSearchResult,
|
|
46
|
+
ReadManyEntry,
|
|
47
|
+
Workspace,
|
|
48
|
+
)
|
|
49
|
+
from .workspace import (
|
|
50
|
+
STATE_DATABASE_NAME,
|
|
51
|
+
WorkspaceHandle,
|
|
52
|
+
WorkspaceRegistry,
|
|
53
|
+
handle_for,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def init_home(home: Path | None = None) -> Path:
|
|
58
|
+
"""Create the home directory, default configuration and registry database."""
|
|
59
|
+
|
|
60
|
+
resolved_home = resolve_home(home)
|
|
61
|
+
resolved_home.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
config_file = resolved_home / CONFIG_FILENAME
|
|
63
|
+
if not config_file.exists():
|
|
64
|
+
config_file.write_text(DEFAULT_CONFIG_TOML, encoding="utf-8")
|
|
65
|
+
connection = database.open_registry_database(resolved_home / "metadata.sqlite3")
|
|
66
|
+
connection.close()
|
|
67
|
+
return resolved_home
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Filekeeper:
|
|
71
|
+
"""Home-scoped facade: configuration, workspaces, and service construction."""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
*,
|
|
76
|
+
home: str | Path | None = None,
|
|
77
|
+
config: Config | None = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
self.home = resolve_home(home)
|
|
80
|
+
self.config = config or load_config(self.home)
|
|
81
|
+
self.registry = WorkspaceRegistry(self.home, self.config)
|
|
82
|
+
|
|
83
|
+
# -- lifecycle ----------------------------------------------------------
|
|
84
|
+
def init(self) -> Path:
|
|
85
|
+
return init_home(self.home)
|
|
86
|
+
|
|
87
|
+
# -- workspace registration --------------------------------------------
|
|
88
|
+
def create_workspace(self, name: str, root: str | Path) -> Workspace:
|
|
89
|
+
return self.registry.create(name, root)
|
|
90
|
+
|
|
91
|
+
def list_workspaces(self) -> tuple[Workspace, ...]:
|
|
92
|
+
return self.registry.list()
|
|
93
|
+
|
|
94
|
+
def get_workspace(self, name_or_id: str) -> Workspace:
|
|
95
|
+
return self.registry.get(name_or_id)
|
|
96
|
+
|
|
97
|
+
def remove_workspace(self, name_or_id: str, *, delete_files: bool = False) -> Workspace:
|
|
98
|
+
return self.registry.remove(name_or_id, delete_files=delete_files)
|
|
99
|
+
|
|
100
|
+
def use_workspace(self, name_or_id: str) -> Workspace:
|
|
101
|
+
return self.registry.set_default(name_or_id)
|
|
102
|
+
|
|
103
|
+
def default_workspace(self) -> Workspace | None:
|
|
104
|
+
return self.registry.get_default()
|
|
105
|
+
|
|
106
|
+
# -- selection ----------------------------------------------------------
|
|
107
|
+
def workspace(
|
|
108
|
+
self,
|
|
109
|
+
*,
|
|
110
|
+
name: str | None = None,
|
|
111
|
+
root: str | Path | None = None,
|
|
112
|
+
) -> FilekeeperService:
|
|
113
|
+
"""Select a workspace: explicit name, then explicit root, then the default.
|
|
114
|
+
|
|
115
|
+
An explicit root that is not registered is used as a transient workspace:
|
|
116
|
+
it is confined exactly like a registered one, but nothing is recorded. The
|
|
117
|
+
selection is never guessed when several workspaces exist.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
if name is not None and root is not None:
|
|
121
|
+
raise ValidationError("select a workspace by name or by root, not both")
|
|
122
|
+
|
|
123
|
+
if name is not None:
|
|
124
|
+
workspace = self.registry.get(name)
|
|
125
|
+
return FilekeeperService(handle_for(workspace, self.config, self.home), self)
|
|
126
|
+
|
|
127
|
+
if root is not None:
|
|
128
|
+
candidate = Path(root).expanduser().resolve()
|
|
129
|
+
if not candidate.is_dir():
|
|
130
|
+
raise ValidationError(
|
|
131
|
+
"workspace root must be an existing directory",
|
|
132
|
+
details={"root": str(root)},
|
|
133
|
+
)
|
|
134
|
+
registered = self.registry.find_by_root(candidate)
|
|
135
|
+
if registered is not None:
|
|
136
|
+
return FilekeeperService(handle_for(registered, self.config, self.home), self)
|
|
137
|
+
transient = Workspace(
|
|
138
|
+
id=f"ws-transient-{database.uuid_token()}",
|
|
139
|
+
name=candidate.name or "workspace",
|
|
140
|
+
root_path=str(candidate),
|
|
141
|
+
created_at=database.utc_now(),
|
|
142
|
+
updated_at=database.utc_now(),
|
|
143
|
+
)
|
|
144
|
+
return FilekeeperService(
|
|
145
|
+
handle_for(transient, self.config, self.home), self, registered=False
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
default = self.registry.get_default()
|
|
149
|
+
if default is None:
|
|
150
|
+
workspaces = self.registry.list()
|
|
151
|
+
if len(workspaces) == 1:
|
|
152
|
+
# A single registered workspace is not an arbitrary choice; using it
|
|
153
|
+
# keeps the common one-project setup frictionless without ever
|
|
154
|
+
# guessing between several.
|
|
155
|
+
return FilekeeperService(handle_for(workspaces[0], self.config, self.home), self)
|
|
156
|
+
available = ", ".join(workspace.name for workspace in workspaces)
|
|
157
|
+
raise NoWorkspaceSelectedError(
|
|
158
|
+
"no workspace selected: pass --workspace or --root, or register a "
|
|
159
|
+
"default with 'keeper workspace use'"
|
|
160
|
+
+ (f"; known workspaces: {available}" if available else ""),
|
|
161
|
+
details={"known_workspaces": available},
|
|
162
|
+
)
|
|
163
|
+
return FilekeeperService(handle_for(default, self.config, self.home), self)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class FilekeeperService:
|
|
167
|
+
"""Workspace-scoped operations. All paths are workspace-relative or inside the root."""
|
|
168
|
+
|
|
169
|
+
def __init__(
|
|
170
|
+
self,
|
|
171
|
+
handle: WorkspaceHandle,
|
|
172
|
+
owner: Filekeeper,
|
|
173
|
+
*,
|
|
174
|
+
registered: bool = True,
|
|
175
|
+
) -> None:
|
|
176
|
+
self.handle = handle
|
|
177
|
+
self.owner = owner
|
|
178
|
+
self.registered = registered
|
|
179
|
+
self._journal: OperationJournal | None = None
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def name(self) -> str:
|
|
183
|
+
return self.handle.name
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def root(self) -> Path:
|
|
187
|
+
return self.handle.root
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def config(self) -> Config:
|
|
191
|
+
return self.handle.config
|
|
192
|
+
|
|
193
|
+
# -- state --------------------------------------------------------------
|
|
194
|
+
def state_database_path(self) -> Path:
|
|
195
|
+
"""Create (if needed) and return this workspace's state database.
|
|
196
|
+
|
|
197
|
+
The database is created on the first mutation that needs durable history,
|
|
198
|
+
which is also when ``.filekeeper/`` is created and gitignored.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
if self.registered and self.handle.workspace_id is not None:
|
|
202
|
+
workspace = self.owner.registry.get(self.handle.name)
|
|
203
|
+
return self.owner.registry.ensure_state_dir(workspace)
|
|
204
|
+
state_dir = self.handle.state_dir
|
|
205
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
206
|
+
target = state_dir / STATE_DATABASE_NAME
|
|
207
|
+
connection = database.open_workspace_database(target)
|
|
208
|
+
connection.close()
|
|
209
|
+
return target
|
|
210
|
+
|
|
211
|
+
def blob_store(self) -> BlobStore:
|
|
212
|
+
return BlobStore(self.state_database_path().parent / "blobs", fsync=self.config.fsync)
|
|
213
|
+
|
|
214
|
+
# -- history ------------------------------------------------------------
|
|
215
|
+
def _open_journal(self, *, create: bool) -> OperationJournal | None:
|
|
216
|
+
"""Return the journal, reconciling pending work once per service instance.
|
|
217
|
+
|
|
218
|
+
Reading history never creates the state directory: a workspace that was
|
|
219
|
+
never mutated simply has no history.
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
if self._journal is not None:
|
|
223
|
+
return self._journal
|
|
224
|
+
database_path = self.handle.state_dir / STATE_DATABASE_NAME
|
|
225
|
+
if not create and not database_path.exists():
|
|
226
|
+
return None
|
|
227
|
+
if create:
|
|
228
|
+
database_path = self.state_database_path()
|
|
229
|
+
journal = OperationJournal(
|
|
230
|
+
database_path,
|
|
231
|
+
root=self.root,
|
|
232
|
+
workspace_id=self.handle.workspace_id or "local",
|
|
233
|
+
config=self.config,
|
|
234
|
+
)
|
|
235
|
+
journal.reconcile()
|
|
236
|
+
self._journal = journal
|
|
237
|
+
return journal
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def history(self) -> OperationJournal:
|
|
241
|
+
journal = self._open_journal(create=True)
|
|
242
|
+
assert journal is not None
|
|
243
|
+
return journal
|
|
244
|
+
|
|
245
|
+
def reconcile_history(self) -> tuple[str, ...]:
|
|
246
|
+
"""Resolve operations left pending by an interrupted run."""
|
|
247
|
+
|
|
248
|
+
return self.history.reconcile()
|
|
249
|
+
|
|
250
|
+
def get_operation(self, operation_id: str) -> OperationRecord:
|
|
251
|
+
journal = self._open_journal(create=False)
|
|
252
|
+
if journal is None:
|
|
253
|
+
from .errors import OperationNotFoundError
|
|
254
|
+
|
|
255
|
+
raise OperationNotFoundError(
|
|
256
|
+
"no operation with that id in this workspace",
|
|
257
|
+
details={"operation_id": operation_id},
|
|
258
|
+
)
|
|
259
|
+
return read_operation(journal, operation_id)
|
|
260
|
+
|
|
261
|
+
def list_history(
|
|
262
|
+
self,
|
|
263
|
+
*,
|
|
264
|
+
path: str | None = None,
|
|
265
|
+
actor: str | None = None,
|
|
266
|
+
task_id: str | None = None,
|
|
267
|
+
limit: int = 50,
|
|
268
|
+
) -> tuple[OperationRecord, ...]:
|
|
269
|
+
journal = self._open_journal(create=False)
|
|
270
|
+
if journal is None:
|
|
271
|
+
return ()
|
|
272
|
+
return read_history(journal, path=path, actor=actor, task_id=task_id, limit=limit)
|
|
273
|
+
|
|
274
|
+
def diff_operation(self, operation_id: str) -> OperationDiff:
|
|
275
|
+
journal = self._open_journal(create=False)
|
|
276
|
+
if journal is None:
|
|
277
|
+
from .errors import OperationNotFoundError
|
|
278
|
+
|
|
279
|
+
raise OperationNotFoundError(
|
|
280
|
+
"no operation with that id in this workspace",
|
|
281
|
+
details={"operation_id": operation_id},
|
|
282
|
+
)
|
|
283
|
+
return read_diff(journal, operation_id)
|
|
284
|
+
|
|
285
|
+
def undo(
|
|
286
|
+
self,
|
|
287
|
+
operation_id: str,
|
|
288
|
+
*,
|
|
289
|
+
actor: str | None = None,
|
|
290
|
+
task_id: str | None = None,
|
|
291
|
+
) -> MutationResult:
|
|
292
|
+
return perform_undo(
|
|
293
|
+
self.history,
|
|
294
|
+
operation_id,
|
|
295
|
+
workspace_name=self.name,
|
|
296
|
+
actor=actor,
|
|
297
|
+
task_id=task_id,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
def undo_last(
|
|
301
|
+
self,
|
|
302
|
+
*,
|
|
303
|
+
actor: str | None = None,
|
|
304
|
+
task_id: str | None = None,
|
|
305
|
+
) -> MutationResult:
|
|
306
|
+
return perform_undo_last(
|
|
307
|
+
self.history, workspace_name=self.name, actor=actor, task_id=task_id
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
# -- read and inspection ------------------------------------------------
|
|
311
|
+
def read_file(
|
|
312
|
+
self,
|
|
313
|
+
path: str,
|
|
314
|
+
*,
|
|
315
|
+
start_line: int | None = None,
|
|
316
|
+
end_line: int | None = None,
|
|
317
|
+
max_bytes: int | None = None,
|
|
318
|
+
encoding: str | None = None,
|
|
319
|
+
) -> FileReadResult:
|
|
320
|
+
return operations.read.read_file(
|
|
321
|
+
self.handle,
|
|
322
|
+
path,
|
|
323
|
+
start_line=start_line,
|
|
324
|
+
end_line=end_line,
|
|
325
|
+
max_bytes=max_bytes,
|
|
326
|
+
encoding=encoding,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
def read_multiple_files(
|
|
330
|
+
self,
|
|
331
|
+
paths: list[str] | tuple[str, ...],
|
|
332
|
+
*,
|
|
333
|
+
max_bytes_per_file: int | None = None,
|
|
334
|
+
start_line: int | None = None,
|
|
335
|
+
end_line: int | None = None,
|
|
336
|
+
) -> tuple[ReadManyEntry, ...]:
|
|
337
|
+
return operations.read.read_multiple_files(
|
|
338
|
+
self.handle,
|
|
339
|
+
paths,
|
|
340
|
+
max_bytes_per_file=max_bytes_per_file,
|
|
341
|
+
start_line=start_line,
|
|
342
|
+
end_line=end_line,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
def list_directory(
|
|
346
|
+
self,
|
|
347
|
+
path: str = ".",
|
|
348
|
+
*,
|
|
349
|
+
recursive: bool = False,
|
|
350
|
+
glob: str | None = None,
|
|
351
|
+
max_entries: int | None = None,
|
|
352
|
+
) -> DirectoryListing:
|
|
353
|
+
return operations.read.list_directory(
|
|
354
|
+
self.handle,
|
|
355
|
+
path,
|
|
356
|
+
recursive=recursive,
|
|
357
|
+
glob=glob,
|
|
358
|
+
max_entries=max_entries,
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
def get_file_info(self, path: str) -> PathInfo:
|
|
362
|
+
return operations.read.get_file_info(self.handle, path)
|
|
363
|
+
|
|
364
|
+
def get_file_hash(self, path: str) -> str:
|
|
365
|
+
return operations.read.get_file_hash(self.handle, path)
|
|
366
|
+
|
|
367
|
+
def file_exists(self, path: str) -> ExistsResult:
|
|
368
|
+
return operations.read.file_exists(self.handle, path)
|
|
369
|
+
|
|
370
|
+
def search_files(
|
|
371
|
+
self, path: str, glob: str, *, max_results: int | None = None
|
|
372
|
+
) -> PathSearchResult:
|
|
373
|
+
return operations.search.search_files(self.handle, path, glob, max_results=max_results)
|
|
374
|
+
|
|
375
|
+
def search_content(
|
|
376
|
+
self,
|
|
377
|
+
path: str,
|
|
378
|
+
query: str,
|
|
379
|
+
*,
|
|
380
|
+
regex: bool = False,
|
|
381
|
+
glob: str | None = None,
|
|
382
|
+
max_results: int | None = None,
|
|
383
|
+
case_sensitive: bool = True,
|
|
384
|
+
) -> ContentSearchResult:
|
|
385
|
+
return operations.search.search_content(
|
|
386
|
+
self.handle,
|
|
387
|
+
path,
|
|
388
|
+
query,
|
|
389
|
+
regex=regex,
|
|
390
|
+
glob=glob,
|
|
391
|
+
max_results=max_results,
|
|
392
|
+
case_sensitive=case_sensitive,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
# -- mutations ----------------------------------------------------------
|
|
396
|
+
def create_file(
|
|
397
|
+
self,
|
|
398
|
+
path: str,
|
|
399
|
+
content: str,
|
|
400
|
+
*,
|
|
401
|
+
expected_absent: bool = True,
|
|
402
|
+
encoding: str = "utf-8",
|
|
403
|
+
allow_sensitive_write: bool = False,
|
|
404
|
+
dry_run: bool = False,
|
|
405
|
+
actor: str | None = None,
|
|
406
|
+
task_id: str | None = None,
|
|
407
|
+
) -> MutationResult:
|
|
408
|
+
return operations.write.create_file(
|
|
409
|
+
self.handle,
|
|
410
|
+
path,
|
|
411
|
+
content,
|
|
412
|
+
expected_absent=expected_absent,
|
|
413
|
+
encoding=encoding,
|
|
414
|
+
allow_sensitive_write=allow_sensitive_write,
|
|
415
|
+
dry_run=dry_run,
|
|
416
|
+
journal=None if dry_run else self.history,
|
|
417
|
+
actor=actor,
|
|
418
|
+
task_id=task_id,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
def write_file(
|
|
422
|
+
self,
|
|
423
|
+
path: str,
|
|
424
|
+
content: str,
|
|
425
|
+
*,
|
|
426
|
+
expected_hash: str | None = None,
|
|
427
|
+
encoding: str | None = None,
|
|
428
|
+
allow_sensitive_write: bool = False,
|
|
429
|
+
dry_run: bool = False,
|
|
430
|
+
actor: str | None = None,
|
|
431
|
+
task_id: str | None = None,
|
|
432
|
+
) -> MutationResult:
|
|
433
|
+
return operations.write.write_file(
|
|
434
|
+
self.handle,
|
|
435
|
+
path,
|
|
436
|
+
content,
|
|
437
|
+
expected_hash=expected_hash,
|
|
438
|
+
encoding=encoding,
|
|
439
|
+
allow_sensitive_write=allow_sensitive_write,
|
|
440
|
+
dry_run=dry_run,
|
|
441
|
+
journal=None if dry_run else self.history,
|
|
442
|
+
actor=actor,
|
|
443
|
+
task_id=task_id,
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
def copy_file(
|
|
447
|
+
self,
|
|
448
|
+
source: str,
|
|
449
|
+
destination: str,
|
|
450
|
+
*,
|
|
451
|
+
expected_destination_hash: str | None = None,
|
|
452
|
+
allow_sensitive_write: bool = False,
|
|
453
|
+
dry_run: bool = False,
|
|
454
|
+
actor: str | None = None,
|
|
455
|
+
task_id: str | None = None,
|
|
456
|
+
) -> MutationResult:
|
|
457
|
+
return operations.write.copy_file(
|
|
458
|
+
self.handle,
|
|
459
|
+
source,
|
|
460
|
+
destination,
|
|
461
|
+
expected_destination_hash=expected_destination_hash,
|
|
462
|
+
allow_sensitive_write=allow_sensitive_write,
|
|
463
|
+
dry_run=dry_run,
|
|
464
|
+
journal=None if dry_run else self.history,
|
|
465
|
+
actor=actor,
|
|
466
|
+
task_id=task_id,
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
def move_file(
|
|
470
|
+
self,
|
|
471
|
+
source: str,
|
|
472
|
+
destination: str,
|
|
473
|
+
*,
|
|
474
|
+
overwrite: bool = False,
|
|
475
|
+
allow_sensitive_write: bool = False,
|
|
476
|
+
dry_run: bool = False,
|
|
477
|
+
actor: str | None = None,
|
|
478
|
+
task_id: str | None = None,
|
|
479
|
+
) -> MutationResult:
|
|
480
|
+
return operations.write.move_file(
|
|
481
|
+
self.handle,
|
|
482
|
+
source,
|
|
483
|
+
destination,
|
|
484
|
+
overwrite=overwrite,
|
|
485
|
+
allow_sensitive_write=allow_sensitive_write,
|
|
486
|
+
dry_run=dry_run,
|
|
487
|
+
journal=None if dry_run else self.history,
|
|
488
|
+
actor=actor,
|
|
489
|
+
task_id=task_id,
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
def delete_file(
|
|
493
|
+
self,
|
|
494
|
+
path: str,
|
|
495
|
+
*,
|
|
496
|
+
expected_hash: str | None = None,
|
|
497
|
+
dry_run: bool = False,
|
|
498
|
+
actor: str | None = None,
|
|
499
|
+
task_id: str | None = None,
|
|
500
|
+
) -> MutationResult:
|
|
501
|
+
return operations.write.delete_file(
|
|
502
|
+
self.handle,
|
|
503
|
+
path,
|
|
504
|
+
expected_hash=expected_hash,
|
|
505
|
+
dry_run=dry_run,
|
|
506
|
+
journal=None if dry_run else self.history,
|
|
507
|
+
actor=actor,
|
|
508
|
+
task_id=task_id,
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
# -- text editing -------------------------------------------------------
|
|
512
|
+
def replace_text(
|
|
513
|
+
self,
|
|
514
|
+
path: str,
|
|
515
|
+
old: str,
|
|
516
|
+
new: str,
|
|
517
|
+
*,
|
|
518
|
+
match: str,
|
|
519
|
+
expected_hash: str | None = None,
|
|
520
|
+
expected_content: str | None = None,
|
|
521
|
+
expected_match_count: int | None = None,
|
|
522
|
+
allow_sensitive_write: bool = False,
|
|
523
|
+
dry_run: bool = False,
|
|
524
|
+
actor: str | None = None,
|
|
525
|
+
task_id: str | None = None,
|
|
526
|
+
) -> MutationResult:
|
|
527
|
+
return operations.edit.replace_text(
|
|
528
|
+
self.handle,
|
|
529
|
+
path,
|
|
530
|
+
old,
|
|
531
|
+
new,
|
|
532
|
+
match=match,
|
|
533
|
+
expected_hash=expected_hash,
|
|
534
|
+
expected_content=expected_content,
|
|
535
|
+
expected_match_count=expected_match_count,
|
|
536
|
+
allow_sensitive_write=allow_sensitive_write,
|
|
537
|
+
dry_run=dry_run,
|
|
538
|
+
journal=None if dry_run else self.history,
|
|
539
|
+
actor=actor,
|
|
540
|
+
task_id=task_id,
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
def replace_lines(
|
|
544
|
+
self,
|
|
545
|
+
path: str,
|
|
546
|
+
start_line: int,
|
|
547
|
+
end_line: int,
|
|
548
|
+
replacement: str,
|
|
549
|
+
*,
|
|
550
|
+
expected_content: str | None = None,
|
|
551
|
+
expected_hash: str | None = None,
|
|
552
|
+
dry_run: bool = False,
|
|
553
|
+
actor: str | None = None,
|
|
554
|
+
task_id: str | None = None,
|
|
555
|
+
) -> MutationResult:
|
|
556
|
+
return operations.edit.replace_lines(
|
|
557
|
+
self.handle,
|
|
558
|
+
path,
|
|
559
|
+
start_line,
|
|
560
|
+
end_line,
|
|
561
|
+
replacement,
|
|
562
|
+
expected_content=expected_content,
|
|
563
|
+
expected_hash=expected_hash,
|
|
564
|
+
dry_run=dry_run,
|
|
565
|
+
journal=None if dry_run else self.history,
|
|
566
|
+
actor=actor,
|
|
567
|
+
task_id=task_id,
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
def insert_lines(
|
|
571
|
+
self,
|
|
572
|
+
path: str,
|
|
573
|
+
line: int | None,
|
|
574
|
+
content: str,
|
|
575
|
+
*,
|
|
576
|
+
position: str = "after",
|
|
577
|
+
expected_content: str | None = None,
|
|
578
|
+
expected_hash: str | None = None,
|
|
579
|
+
dry_run: bool = False,
|
|
580
|
+
actor: str | None = None,
|
|
581
|
+
task_id: str | None = None,
|
|
582
|
+
) -> MutationResult:
|
|
583
|
+
return operations.edit.insert_lines(
|
|
584
|
+
self.handle,
|
|
585
|
+
path,
|
|
586
|
+
line,
|
|
587
|
+
content,
|
|
588
|
+
position=position,
|
|
589
|
+
expected_content=expected_content,
|
|
590
|
+
expected_hash=expected_hash,
|
|
591
|
+
dry_run=dry_run,
|
|
592
|
+
journal=None if dry_run else self.history,
|
|
593
|
+
actor=actor,
|
|
594
|
+
task_id=task_id,
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
def delete_lines(
|
|
598
|
+
self,
|
|
599
|
+
path: str,
|
|
600
|
+
start_line: int,
|
|
601
|
+
end_line: int,
|
|
602
|
+
*,
|
|
603
|
+
expected_content: str | None = None,
|
|
604
|
+
expected_hash: str | None = None,
|
|
605
|
+
dry_run: bool = False,
|
|
606
|
+
actor: str | None = None,
|
|
607
|
+
task_id: str | None = None,
|
|
608
|
+
) -> MutationResult:
|
|
609
|
+
return operations.edit.delete_lines(
|
|
610
|
+
self.handle,
|
|
611
|
+
path,
|
|
612
|
+
start_line,
|
|
613
|
+
end_line,
|
|
614
|
+
expected_content=expected_content,
|
|
615
|
+
expected_hash=expected_hash,
|
|
616
|
+
dry_run=dry_run,
|
|
617
|
+
journal=None if dry_run else self.history,
|
|
618
|
+
actor=actor,
|
|
619
|
+
task_id=task_id,
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
def apply_patch(
|
|
623
|
+
self,
|
|
624
|
+
patch_text: str,
|
|
625
|
+
*,
|
|
626
|
+
allow_sensitive_write: bool = False,
|
|
627
|
+
dry_run: bool = False,
|
|
628
|
+
actor: str | None = None,
|
|
629
|
+
task_id: str | None = None,
|
|
630
|
+
) -> MutationResult:
|
|
631
|
+
return operations.edit.apply_patch(
|
|
632
|
+
self.handle,
|
|
633
|
+
patch_text,
|
|
634
|
+
allow_sensitive_write=allow_sensitive_write,
|
|
635
|
+
dry_run=dry_run,
|
|
636
|
+
journal=None if dry_run else self.history,
|
|
637
|
+
actor=actor,
|
|
638
|
+
task_id=task_id,
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
def create_directory(
|
|
642
|
+
self,
|
|
643
|
+
path: str,
|
|
644
|
+
*,
|
|
645
|
+
parents: bool = False,
|
|
646
|
+
dry_run: bool = False,
|
|
647
|
+
actor: str | None = None,
|
|
648
|
+
task_id: str | None = None,
|
|
649
|
+
) -> MutationResult:
|
|
650
|
+
return operations.directory.create_directory(
|
|
651
|
+
self.handle,
|
|
652
|
+
path,
|
|
653
|
+
parents=parents,
|
|
654
|
+
dry_run=dry_run,
|
|
655
|
+
journal=None if dry_run else self.history,
|
|
656
|
+
actor=actor,
|
|
657
|
+
task_id=task_id,
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
def move_directory(
|
|
661
|
+
self,
|
|
662
|
+
source: str,
|
|
663
|
+
destination: str,
|
|
664
|
+
*,
|
|
665
|
+
dry_run: bool = False,
|
|
666
|
+
actor: str | None = None,
|
|
667
|
+
task_id: str | None = None,
|
|
668
|
+
) -> MutationResult:
|
|
669
|
+
return operations.directory.move_directory(
|
|
670
|
+
self.handle,
|
|
671
|
+
source,
|
|
672
|
+
destination,
|
|
673
|
+
dry_run=dry_run,
|
|
674
|
+
journal=None if dry_run else self.history,
|
|
675
|
+
actor=actor,
|
|
676
|
+
task_id=task_id,
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
def delete_directory(
|
|
680
|
+
self,
|
|
681
|
+
path: str,
|
|
682
|
+
*,
|
|
683
|
+
recursive: bool = False,
|
|
684
|
+
confirm_recursive: bool = False,
|
|
685
|
+
dry_run: bool = False,
|
|
686
|
+
actor: str | None = None,
|
|
687
|
+
task_id: str | None = None,
|
|
688
|
+
) -> MutationResult:
|
|
689
|
+
return operations.directory.delete_directory(
|
|
690
|
+
self.handle,
|
|
691
|
+
path,
|
|
692
|
+
recursive=recursive,
|
|
693
|
+
confirm_recursive=confirm_recursive,
|
|
694
|
+
dry_run=dry_run,
|
|
695
|
+
journal=None if dry_run else self.history,
|
|
696
|
+
actor=actor,
|
|
697
|
+
task_id=task_id,
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
__all__ = [
|
|
702
|
+
"STATE_DIR_NAME",
|
|
703
|
+
"Filekeeper",
|
|
704
|
+
"FilekeeperService",
|
|
705
|
+
"init_home",
|
|
706
|
+
]
|