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.
@@ -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
+ ]