protonfs 0.1.dev43__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.
- protonfs/__init__.py +6 -0
- protonfs/batching.py +17 -0
- protonfs/cli.py +226 -0
- protonfs/commands/__init__.py +0 -0
- protonfs/commands/auth.py +36 -0
- protonfs/commands/ls.py +61 -0
- protonfs/commands/pull.py +85 -0
- protonfs/commands/push.py +94 -0
- protonfs/commands/refresh.py +86 -0
- protonfs/commands/restore.py +9 -0
- protonfs/commands/rm.py +52 -0
- protonfs/commands/setup.py +179 -0
- protonfs/commands/status.py +17 -0
- protonfs/config.py +67 -0
- protonfs/context.py +28 -0
- protonfs/diff.py +83 -0
- protonfs/drive.py +221 -0
- protonfs/ignore.py +43 -0
- protonfs/index.py +55 -0
- protonfs/install.py +288 -0
- protonfs/lfs.py +37 -0
- protonfs/localscan.py +54 -0
- protonfs-0.1.dev43.dist-info/METADATA +198 -0
- protonfs-0.1.dev43.dist-info/RECORD +27 -0
- protonfs-0.1.dev43.dist-info/WHEEL +4 -0
- protonfs-0.1.dev43.dist-info/entry_points.txt +2 -0
- protonfs-0.1.dev43.dist-info/licenses/LICENSE +133 -0
protonfs/__init__.py
ADDED
protonfs/batching.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
DEFAULT_BATCH_SIZE = 200
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def group_by_parent(rel_paths: list[str]) -> dict[str, list[str]]:
|
|
9
|
+
groups: dict[str, list[str]] = {}
|
|
10
|
+
for rel in rel_paths:
|
|
11
|
+
parent = str(Path(rel).parent)
|
|
12
|
+
groups.setdefault(parent, []).append(rel)
|
|
13
|
+
return groups
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def batches(items: list, size: int = DEFAULT_BATCH_SIZE) -> list[list]:
|
|
17
|
+
return [items[i : i + size] for i in range(0, len(items), size)]
|
protonfs/cli.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from protonfs import __version__
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _drive_error_boundary(func):
|
|
11
|
+
"""Wrap a command so DriveError/DriveAuthError become clean ClickExceptions.
|
|
12
|
+
|
|
13
|
+
Runtime commands call into the Drive CLI; a mid-run failure there (auth
|
|
14
|
+
expiry, network, missing path) would otherwise surface as a raw Python
|
|
15
|
+
traceback instead of a readable message.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
@functools.wraps(func)
|
|
19
|
+
def wrapper(*args, **kwargs):
|
|
20
|
+
from protonfs.drive import DriveAuthError, DriveError
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
return func(*args, **kwargs)
|
|
24
|
+
except DriveAuthError as exc:
|
|
25
|
+
raise click.ClickException(
|
|
26
|
+
f"{exc}\nRun `proton-drive auth login` to re-authenticate, "
|
|
27
|
+
"then retry this command."
|
|
28
|
+
) from exc
|
|
29
|
+
except DriveError as exc:
|
|
30
|
+
raise click.ClickException(str(exc)) from exc
|
|
31
|
+
|
|
32
|
+
return wrapper
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@click.group()
|
|
36
|
+
@click.version_option(__version__, prog_name="protonfs")
|
|
37
|
+
def main() -> None:
|
|
38
|
+
"""Sync a local directory tree with Proton Drive."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@main.command()
|
|
42
|
+
@click.option("--dry-run", is_flag=True, help="Preview the LFS migration without making changes.")
|
|
43
|
+
def setup(dry_run: bool) -> None:
|
|
44
|
+
"""Install/verify the proton-drive CLI, init .protonfs/, migrate off git-lfs if present."""
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
|
|
47
|
+
from protonfs.commands.setup import run_setup
|
|
48
|
+
|
|
49
|
+
run_setup(Path.cwd(), dry_run=dry_run)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@main.command()
|
|
53
|
+
@click.argument("path", required=False)
|
|
54
|
+
def status(path: str | None) -> None:
|
|
55
|
+
"""Summarize sync state (counts by local-only/remote-only/synced/conflict)."""
|
|
56
|
+
from protonfs.commands.status import compute_status
|
|
57
|
+
from protonfs.context import load_context
|
|
58
|
+
from protonfs.diff import SyncState
|
|
59
|
+
|
|
60
|
+
ctx = load_context()
|
|
61
|
+
counts = compute_status(ctx, path)
|
|
62
|
+
for state in SyncState:
|
|
63
|
+
click.echo(f"{state.value}: {counts.get(state.value, 0)}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@main.command()
|
|
67
|
+
@click.argument("path", required=False)
|
|
68
|
+
@click.option("--remote", is_flag=True, help="Force a live Drive listing instead of the index.")
|
|
69
|
+
@click.option("--trash", is_flag=True, help="List /trash instead.")
|
|
70
|
+
@_drive_error_boundary
|
|
71
|
+
def ls(path: str | None, remote: bool, trash: bool) -> None:
|
|
72
|
+
"""List tracked files with their sync state."""
|
|
73
|
+
from rich.console import Console
|
|
74
|
+
|
|
75
|
+
from protonfs.commands.ls import render_ls
|
|
76
|
+
from protonfs.context import load_context
|
|
77
|
+
|
|
78
|
+
ctx = load_context()
|
|
79
|
+
render_ls(ctx, path, remote, trash, Console())
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@main.command()
|
|
83
|
+
@click.argument("path", required=False)
|
|
84
|
+
@click.option("--resolve", type=click.Choice(["merge", "keep-both", "replace", "skip"]))
|
|
85
|
+
@click.option("--dry-run", is_flag=True)
|
|
86
|
+
@_drive_error_boundary
|
|
87
|
+
def push(path: str | None, resolve: str | None, dry_run: bool) -> None:
|
|
88
|
+
"""Upload local-only/changed files to Drive."""
|
|
89
|
+
from protonfs.commands.push import push as push_files
|
|
90
|
+
from protonfs.context import load_context
|
|
91
|
+
|
|
92
|
+
ctx = load_context()
|
|
93
|
+
result = push_files(ctx, path, resolve, dry_run)
|
|
94
|
+
click.echo(
|
|
95
|
+
f"transferred={result.transferred_items} skipped={result.skipped_items} "
|
|
96
|
+
f"failed={result.failed_items}"
|
|
97
|
+
)
|
|
98
|
+
for failure in result.failures:
|
|
99
|
+
click.echo(f" FAILED {failure['name']}: {failure['error']}")
|
|
100
|
+
if result.failed_items:
|
|
101
|
+
if not resolve:
|
|
102
|
+
click.echo(
|
|
103
|
+
" -> these are remote conflicts; re-run with "
|
|
104
|
+
"--resolve=merge|keep-both|replace|skip to resolve them."
|
|
105
|
+
)
|
|
106
|
+
raise click.exceptions.Exit(1)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@main.command()
|
|
110
|
+
@click.argument("path", required=False)
|
|
111
|
+
@click.option("--resolve", type=click.Choice(["merge", "keep-both", "replace", "skip"]))
|
|
112
|
+
@click.option("--dry-run", is_flag=True)
|
|
113
|
+
@click.option(
|
|
114
|
+
"--refresh",
|
|
115
|
+
is_flag=True,
|
|
116
|
+
help="Discover remote files (seed the index) before pulling.",
|
|
117
|
+
)
|
|
118
|
+
@_drive_error_boundary
|
|
119
|
+
def pull(path: str | None, resolve: str | None, dry_run: bool, refresh: bool) -> None:
|
|
120
|
+
"""Download remote-only/changed files from Drive."""
|
|
121
|
+
from protonfs.commands.pull import pull as pull_files
|
|
122
|
+
from protonfs.context import load_context
|
|
123
|
+
|
|
124
|
+
ctx = load_context()
|
|
125
|
+
if not refresh and not ctx.index.all():
|
|
126
|
+
click.echo("index empty; run `protonfs refresh` first (or `pull --refresh`)")
|
|
127
|
+
return
|
|
128
|
+
result = pull_files(ctx, path, resolve, dry_run, refresh=refresh)
|
|
129
|
+
click.echo(
|
|
130
|
+
f"transferred={result.transferred_items} skipped={result.skipped_items} "
|
|
131
|
+
f"failed={result.failed_items}"
|
|
132
|
+
)
|
|
133
|
+
for failure in result.failures:
|
|
134
|
+
click.echo(f" FAILED {failure['name']}: {failure['error']}")
|
|
135
|
+
if result.failed_items:
|
|
136
|
+
raise click.exceptions.Exit(1)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@main.command()
|
|
140
|
+
@click.argument("path")
|
|
141
|
+
@click.option("-r", "--recursive", is_flag=True)
|
|
142
|
+
@click.option("-f", "--force", is_flag=True, help="Permanently delete (trash, then delete).")
|
|
143
|
+
@click.option("--yes", is_flag=True, help="Skip confirmation prompt.")
|
|
144
|
+
@_drive_error_boundary
|
|
145
|
+
def rm(path: str, recursive: bool, force: bool, yes: bool) -> None:
|
|
146
|
+
"""Remove a file/directory from Drive (trash by default, -f for permanent)."""
|
|
147
|
+
from protonfs.commands.rm import rm as rm_path
|
|
148
|
+
from protonfs.context import load_context
|
|
149
|
+
|
|
150
|
+
ctx = load_context()
|
|
151
|
+
rm_path(ctx, path, recursive, force, confirmed=yes)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@main.command()
|
|
155
|
+
@click.argument("path")
|
|
156
|
+
@_drive_error_boundary
|
|
157
|
+
def restore(path: str) -> None:
|
|
158
|
+
"""Restore a trashed file/directory on Drive."""
|
|
159
|
+
from protonfs.commands.restore import restore as restore_path
|
|
160
|
+
from protonfs.context import load_context
|
|
161
|
+
|
|
162
|
+
ctx = load_context()
|
|
163
|
+
restore_path(ctx, path)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@main.command()
|
|
167
|
+
@click.argument("path", required=False)
|
|
168
|
+
@click.option("--prune", is_flag=True, help="Drop index entries for files deleted on the remote.")
|
|
169
|
+
@_drive_error_boundary
|
|
170
|
+
def refresh(path: str | None, prune: bool) -> None:
|
|
171
|
+
"""Discover remote files and seed the local index (metadata-only)."""
|
|
172
|
+
from protonfs.commands.refresh import refresh as refresh_index
|
|
173
|
+
from protonfs.context import load_context
|
|
174
|
+
|
|
175
|
+
ctx = load_context()
|
|
176
|
+
result = refresh_index(ctx, path, prune)
|
|
177
|
+
click.echo(f"Discovered {result.seeded} new remote file(s) (metadata-only).")
|
|
178
|
+
if result.remote_changed:
|
|
179
|
+
click.echo(f" {result.remote_changed} file(s) changed on the remote (remote-changed):")
|
|
180
|
+
for p in result.changed_paths:
|
|
181
|
+
click.echo(f" {p}")
|
|
182
|
+
click.echo(
|
|
183
|
+
" -> `protonfs pull --resolve=replace <path>` to take the remote version, "
|
|
184
|
+
"or `protonfs push --resolve=replace <path>` to overwrite it with your local copy."
|
|
185
|
+
)
|
|
186
|
+
if result.remote_deleted:
|
|
187
|
+
verb = "pruned" if prune else "found"
|
|
188
|
+
click.echo(f" {result.remote_deleted} file(s) deleted on the remote ({verb}):")
|
|
189
|
+
for p in result.deleted_paths:
|
|
190
|
+
click.echo(f" {p}")
|
|
191
|
+
if not prune:
|
|
192
|
+
click.echo(" -> `protonfs refresh --prune` to drop them from your local index.")
|
|
193
|
+
if result.seeded:
|
|
194
|
+
click.echo(f"Run `protonfs pull` to download the {result.seeded} discovered file(s).")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@main.command("install-drive")
|
|
198
|
+
@click.option("--version", default=None, help="proton-drive version to install (default: pinned).")
|
|
199
|
+
def install_drive_cmd(version: str | None) -> None:
|
|
200
|
+
"""Download and verify the official proton-drive CLI binary."""
|
|
201
|
+
from protonfs.install import InstallError, install_drive
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
result = install_drive(version=version)
|
|
205
|
+
except InstallError as exc:
|
|
206
|
+
raise click.ClickException(str(exc)) from exc
|
|
207
|
+
click.echo(f"Installed proton-drive to {result.path} (SHA-512 verified).")
|
|
208
|
+
for warning in result.warnings:
|
|
209
|
+
click.echo(f" ! {warning}")
|
|
210
|
+
click.echo("Next: run `protonfs auth login` to authenticate.")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@main.command()
|
|
214
|
+
@click.argument("action", type=click.Choice(["login", "logout", "status"]))
|
|
215
|
+
@_drive_error_boundary
|
|
216
|
+
def auth(action: str) -> None:
|
|
217
|
+
"""Authenticate the proton-drive CLI: login | logout | status (passthrough)."""
|
|
218
|
+
from protonfs.commands.auth import auth_passthrough
|
|
219
|
+
|
|
220
|
+
code = auth_passthrough(action)
|
|
221
|
+
if code != 0:
|
|
222
|
+
raise click.exceptions.Exit(code)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
if __name__ == "__main__":
|
|
226
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# src/protonfs/commands/auth.py
|
|
2
|
+
"""`protonfs auth {login,logout,status}` — a thin passthrough to `proton-drive auth`.
|
|
3
|
+
|
|
4
|
+
Auth is left entirely to proton-drive (D3.3): it prints a URL to open on any
|
|
5
|
+
device and persists the session to the OS keyring, so this works headlessly with
|
|
6
|
+
no custom handling. We inherit stdio (no --json, no capture) so the interactive
|
|
7
|
+
login URL reaches the user's terminal.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from protonfs.drive import DriveError, binary_path
|
|
16
|
+
|
|
17
|
+
AUTH_SUBCOMMANDS = ("login", "logout", "status")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def auth_passthrough(subcommand: str, binary: str | None = None, runner=subprocess.run) -> int:
|
|
21
|
+
"""Invoke `proton-drive auth <subcommand>` with inherited stdio; return exit code.
|
|
22
|
+
|
|
23
|
+
Raises DriveError (rendered cleanly by the CLI error boundary) if the subcommand
|
|
24
|
+
is unknown or the proton-drive binary is not installed/on PATH -- so a first-time
|
|
25
|
+
user who runs `auth login` before `install-drive` gets an instructive message
|
|
26
|
+
instead of a raw FileNotFoundError.
|
|
27
|
+
"""
|
|
28
|
+
if subcommand not in AUTH_SUBCOMMANDS:
|
|
29
|
+
raise ValueError(f"unknown auth subcommand: {subcommand!r}")
|
|
30
|
+
bin_path = binary or binary_path()
|
|
31
|
+
if shutil.which(bin_path) is None and not Path(bin_path).exists():
|
|
32
|
+
raise DriveError(
|
|
33
|
+
f"proton-drive binary not found: {bin_path}. Run `protonfs install-drive` first."
|
|
34
|
+
)
|
|
35
|
+
result = runner([bin_path, "auth", subcommand])
|
|
36
|
+
return result.returncode
|
protonfs/commands/ls.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# src/protonfs/commands/ls.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from protonfs.context import RepoContext
|
|
10
|
+
from protonfs.diff import classify, within_subpath
|
|
11
|
+
from protonfs.drive import decrypted_name
|
|
12
|
+
from protonfs.ignore import IgnoreMatcher
|
|
13
|
+
from protonfs.localscan import scan
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def remote_rel_paths(ctx: RepoContext, subpath: str | None = None) -> dict[str, int]:
|
|
17
|
+
"""Recursive files-only remote listing, scoped to `subpath` when given. rel_paths
|
|
18
|
+
are re-prefixed with the subpath so they match the index's repo-root-relative keys
|
|
19
|
+
(same convention as `refresh`)."""
|
|
20
|
+
remote_root = ctx.config.remote_root
|
|
21
|
+
if subpath:
|
|
22
|
+
remote_root = f"{remote_root}/{subpath}"
|
|
23
|
+
result = {e.rel_path: e.size for e in ctx.drive.walk(remote_root) if not e.is_dir}
|
|
24
|
+
if subpath:
|
|
25
|
+
result = {f"{subpath}/{rel}": size for rel, size in result.items()}
|
|
26
|
+
return result
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def render_ls(
|
|
30
|
+
ctx: RepoContext,
|
|
31
|
+
subpath: str | None,
|
|
32
|
+
remote: bool,
|
|
33
|
+
trash: bool,
|
|
34
|
+
console: Console,
|
|
35
|
+
) -> None:
|
|
36
|
+
if trash:
|
|
37
|
+
entries = ctx.drive.list("/trash")
|
|
38
|
+
table = Table("name", "type")
|
|
39
|
+
for entry in entries:
|
|
40
|
+
name_val = decrypted_name(entry)
|
|
41
|
+
if name_val is not None:
|
|
42
|
+
table.add_row(name_val, entry.get("type", ""))
|
|
43
|
+
console.print(table)
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
ignore = IgnoreMatcher.from_file(ctx.root)
|
|
47
|
+
scan_root = Path(subpath) if subpath else Path(".")
|
|
48
|
+
local = scan(ctx.root, scan_root, ignore, ctx.index, low_io=ctx.config.defaults.low_io)
|
|
49
|
+
remote_map = remote_rel_paths(ctx, subpath) if remote else None
|
|
50
|
+
# classify reasons over the whole repo-wide index; when a subpath was given, the
|
|
51
|
+
# local scan and remote walk are scoped to it, so restrict the rows to that
|
|
52
|
+
# subpath too -- otherwise out-of-scope index entries (never scanned/walked) show
|
|
53
|
+
# up, and with a remote view are misread as remote-deleted.
|
|
54
|
+
diff_entries = [
|
|
55
|
+
e for e in classify(local, ctx.index, remote_map) if within_subpath(e.rel_path, subpath)
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
table = Table("path", "state")
|
|
59
|
+
for entry in diff_entries:
|
|
60
|
+
table.add_row(entry.rel_path, entry.state.value)
|
|
61
|
+
console.print(table)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# src/protonfs/commands/pull.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from protonfs.batching import batches, group_by_parent
|
|
8
|
+
from protonfs.context import RepoContext
|
|
9
|
+
from protonfs.diff import SyncState, classify
|
|
10
|
+
from protonfs.drive import TransferResult
|
|
11
|
+
from protonfs.ignore import IgnoreMatcher
|
|
12
|
+
from protonfs.index import IndexEntry
|
|
13
|
+
from protonfs.localscan import hash_file, scan
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def pull(
|
|
17
|
+
ctx: RepoContext,
|
|
18
|
+
subpath: str | None,
|
|
19
|
+
resolve: str | None,
|
|
20
|
+
dry_run: bool,
|
|
21
|
+
refresh: bool = False,
|
|
22
|
+
) -> TransferResult:
|
|
23
|
+
ignore = IgnoreMatcher.from_file(ctx.root)
|
|
24
|
+
scan_root = Path(subpath) if subpath else Path(".")
|
|
25
|
+
local = scan(ctx.root, scan_root, ignore, ctx.index, low_io=ctx.config.defaults.low_io)
|
|
26
|
+
if refresh:
|
|
27
|
+
from protonfs.commands.refresh import refresh as refresh_index
|
|
28
|
+
|
|
29
|
+
# Reuse the scan we just did; on a dry run seed only in memory so the preview
|
|
30
|
+
# is accurate without persisting metadata-only entries to index.json.
|
|
31
|
+
refresh_index(ctx, subpath, prune=False, persist=not dry_run, local=local)
|
|
32
|
+
diff_entries = classify(local, ctx.index)
|
|
33
|
+
|
|
34
|
+
to_pull = [
|
|
35
|
+
e.rel_path
|
|
36
|
+
for e in diff_entries
|
|
37
|
+
if e.state in (SyncState.REMOTE_ONLY, SyncState.METADATA_ONLY)
|
|
38
|
+
]
|
|
39
|
+
if dry_run or not to_pull:
|
|
40
|
+
return TransferResult(len(to_pull), 0, 0, [])
|
|
41
|
+
|
|
42
|
+
strategy = resolve or ctx.config.defaults.on_conflict
|
|
43
|
+
groups = group_by_parent(to_pull)
|
|
44
|
+
total = TransferResult(0, 0, 0, [])
|
|
45
|
+
now = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
46
|
+
|
|
47
|
+
for parent, rels in groups.items():
|
|
48
|
+
local_folder = ctx.root if parent == "." else ctx.root / parent
|
|
49
|
+
local_folder.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
for batch in batches(rels):
|
|
51
|
+
remote_paths = []
|
|
52
|
+
for rel in batch:
|
|
53
|
+
entry = ctx.index.get(rel)
|
|
54
|
+
default_remote = f"{ctx.config.remote_root}/{rel}"
|
|
55
|
+
remote_paths.append(entry.remote_path if entry else default_remote)
|
|
56
|
+
result = ctx.drive.download(remote_paths, local_folder, file_strategy=strategy)
|
|
57
|
+
total.transferred_items += result.transferred_items
|
|
58
|
+
total.skipped_items += result.skipped_items
|
|
59
|
+
total.failed_items += result.failed_items
|
|
60
|
+
total.failures += result.failures
|
|
61
|
+
|
|
62
|
+
failed_names = {f["name"] for f in result.failures}
|
|
63
|
+
for rel in batch:
|
|
64
|
+
if Path(rel).name in failed_names:
|
|
65
|
+
continue
|
|
66
|
+
downloaded_path = ctx.root / rel
|
|
67
|
+
if not downloaded_path.exists():
|
|
68
|
+
continue
|
|
69
|
+
stat = downloaded_path.stat()
|
|
70
|
+
prior = ctx.index.get(rel)
|
|
71
|
+
default_remote = f"{ctx.config.remote_root}/{rel}"
|
|
72
|
+
ctx.index.set(
|
|
73
|
+
rel,
|
|
74
|
+
IndexEntry(
|
|
75
|
+
size=stat.st_size,
|
|
76
|
+
mtime=stat.st_mtime,
|
|
77
|
+
sha256=hash_file(downloaded_path),
|
|
78
|
+
remote_path=prior.remote_path if prior else default_remote,
|
|
79
|
+
origin_device=prior.origin_device if prior else "unknown",
|
|
80
|
+
local_state="present",
|
|
81
|
+
last_synced=now,
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
ctx.index.save()
|
|
85
|
+
return total
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from protonfs.batching import batches, group_by_parent
|
|
7
|
+
from protonfs.context import RepoContext
|
|
8
|
+
from protonfs.diff import SyncState, classify
|
|
9
|
+
from protonfs.drive import DriveError, TransferResult
|
|
10
|
+
from protonfs.ignore import IgnoreMatcher
|
|
11
|
+
from protonfs.index import IndexEntry
|
|
12
|
+
from protonfs.localscan import scan
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _ensure_remote_dir(ctx: RepoContext, remote_dir: str) -> None:
|
|
16
|
+
root = ctx.config.remote_root.rstrip("/")
|
|
17
|
+
if not remote_dir.startswith(root):
|
|
18
|
+
return
|
|
19
|
+
relative = remote_dir[len(root) :].strip("/")
|
|
20
|
+
if not relative:
|
|
21
|
+
return
|
|
22
|
+
current = root
|
|
23
|
+
for segment in relative.split("/"):
|
|
24
|
+
try:
|
|
25
|
+
ctx.drive.create_folder(current, segment)
|
|
26
|
+
except DriveError:
|
|
27
|
+
pass # already exists -- a real failure will surface on the upload call below
|
|
28
|
+
current = f"{current}/{segment}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def push(
|
|
32
|
+
ctx: RepoContext,
|
|
33
|
+
subpath: str | None,
|
|
34
|
+
resolve: str | None,
|
|
35
|
+
dry_run: bool,
|
|
36
|
+
) -> TransferResult:
|
|
37
|
+
ignore = IgnoreMatcher.from_file(ctx.root)
|
|
38
|
+
scan_root = Path(subpath) if subpath else Path(".")
|
|
39
|
+
local = scan(ctx.root, scan_root, ignore, ctx.index, low_io=ctx.config.defaults.low_io)
|
|
40
|
+
diff_entries = classify(local, ctx.index)
|
|
41
|
+
|
|
42
|
+
to_push = [
|
|
43
|
+
e.rel_path for e in diff_entries if e.state in (SyncState.LOCAL_ONLY, SyncState.CONFLICT)
|
|
44
|
+
]
|
|
45
|
+
if dry_run or not to_push:
|
|
46
|
+
return TransferResult(len(to_push), 0, 0, [])
|
|
47
|
+
|
|
48
|
+
# D2.1: default push applies NO conflict strategy so the CLI surfaces conflicts as
|
|
49
|
+
# named per-file failures (never a silent skip that we'd falsely index). A strategy
|
|
50
|
+
# is used only when the user explicitly asks via --resolve.
|
|
51
|
+
strategy = resolve
|
|
52
|
+
groups = group_by_parent(to_push)
|
|
53
|
+
total = TransferResult(0, 0, 0, [])
|
|
54
|
+
now = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
55
|
+
|
|
56
|
+
for parent, rels in groups.items():
|
|
57
|
+
remote_parent = (
|
|
58
|
+
f"{ctx.config.remote_root}/{parent}" if parent != "." else ctx.config.remote_root
|
|
59
|
+
)
|
|
60
|
+
_ensure_remote_dir(ctx, remote_parent)
|
|
61
|
+
for batch in batches(rels):
|
|
62
|
+
local_paths = [ctx.root / rel for rel in batch]
|
|
63
|
+
result = ctx.drive.upload(local_paths, remote_parent, file_strategy=strategy)
|
|
64
|
+
total.transferred_items += result.transferred_items
|
|
65
|
+
total.skipped_items += result.skipped_items
|
|
66
|
+
total.failed_items += result.failed_items
|
|
67
|
+
total.failures += result.failures
|
|
68
|
+
|
|
69
|
+
# D2.1: a skip is reported only as an aggregate count, so we cannot tell
|
|
70
|
+
# WHICH files in the batch were skipped. Rather than falsely record an
|
|
71
|
+
# unconfirmed hash, index none of this batch's non-failed files and leave
|
|
72
|
+
# them for the next push.
|
|
73
|
+
if result.skipped_items > 0:
|
|
74
|
+
continue
|
|
75
|
+
failed_names = {f["name"] for f in result.failures}
|
|
76
|
+
for rel in batch:
|
|
77
|
+
if Path(rel).name in failed_names:
|
|
78
|
+
continue
|
|
79
|
+
entry = local[rel]
|
|
80
|
+
remote_path = f"{remote_parent}/{Path(rel).name}"
|
|
81
|
+
ctx.index.set(
|
|
82
|
+
rel,
|
|
83
|
+
IndexEntry(
|
|
84
|
+
size=entry.size,
|
|
85
|
+
mtime=entry.mtime,
|
|
86
|
+
sha256=entry.sha256,
|
|
87
|
+
remote_path=remote_path,
|
|
88
|
+
origin_device=ctx.config.device_id,
|
|
89
|
+
local_state="present",
|
|
90
|
+
last_synced=now,
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
ctx.index.save()
|
|
94
|
+
return total
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# src/protonfs/commands/refresh.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import datetime
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from protonfs.context import RepoContext
|
|
9
|
+
from protonfs.diff import SyncState, classify, within_subpath
|
|
10
|
+
from protonfs.ignore import IgnoreMatcher
|
|
11
|
+
from protonfs.index import IndexEntry
|
|
12
|
+
from protonfs.localscan import ScanEntry, scan
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class RefreshResult:
|
|
17
|
+
seeded: int = 0
|
|
18
|
+
remote_changed: int = 0
|
|
19
|
+
remote_deleted: int = 0
|
|
20
|
+
pruned: int = 0
|
|
21
|
+
changed_paths: list[str] = field(default_factory=list)
|
|
22
|
+
deleted_paths: list[str] = field(default_factory=list)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def refresh(
|
|
26
|
+
ctx: RepoContext,
|
|
27
|
+
subpath: str | None,
|
|
28
|
+
prune: bool,
|
|
29
|
+
persist: bool = True,
|
|
30
|
+
local: dict[str, ScanEntry] | None = None,
|
|
31
|
+
) -> RefreshResult:
|
|
32
|
+
remote_root = ctx.config.remote_root
|
|
33
|
+
if subpath:
|
|
34
|
+
remote_root = f"{remote_root}/{subpath}"
|
|
35
|
+
entries = ctx.drive.walk(remote_root)
|
|
36
|
+
remote = {e.rel_path: e.size for e in entries if not e.is_dir}
|
|
37
|
+
# rel_paths from the walk are relative to remote_root; if a subpath was given,
|
|
38
|
+
# re-prefix so keys match the index's repo-root-relative rel_paths.
|
|
39
|
+
if subpath:
|
|
40
|
+
remote = {f"{subpath}/{rel}": size for rel, size in remote.items()}
|
|
41
|
+
|
|
42
|
+
# `local` may be supplied by a caller (e.g. pull --refresh) that already scanned
|
|
43
|
+
# the same tree, so we don't pay for a second recursive walk + re-hash.
|
|
44
|
+
if local is None:
|
|
45
|
+
ignore = IgnoreMatcher.from_file(ctx.root)
|
|
46
|
+
scan_root = Path(subpath) if subpath else Path(".")
|
|
47
|
+
local = scan(ctx.root, scan_root, ignore, ctx.index, low_io=ctx.config.defaults.low_io)
|
|
48
|
+
diff_entries = classify(local, ctx.index, remote)
|
|
49
|
+
|
|
50
|
+
result = RefreshResult()
|
|
51
|
+
now = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
52
|
+
|
|
53
|
+
for rel, size in remote.items():
|
|
54
|
+
if ctx.index.get(rel) is None and rel not in local:
|
|
55
|
+
ctx.index.set(
|
|
56
|
+
rel,
|
|
57
|
+
IndexEntry(
|
|
58
|
+
size=size,
|
|
59
|
+
mtime=0.0,
|
|
60
|
+
sha256="",
|
|
61
|
+
remote_path=f"{ctx.config.remote_root}/{rel}",
|
|
62
|
+
origin_device="unknown",
|
|
63
|
+
local_state="metadata-only",
|
|
64
|
+
last_synced=now,
|
|
65
|
+
),
|
|
66
|
+
)
|
|
67
|
+
result.seeded += 1
|
|
68
|
+
|
|
69
|
+
for entry in diff_entries:
|
|
70
|
+
# A subpath-scoped walk only saw files under `subpath`; index entries outside
|
|
71
|
+
# it were never checked, so their absence from `remote` is not a deletion.
|
|
72
|
+
if not within_subpath(entry.rel_path, subpath):
|
|
73
|
+
continue
|
|
74
|
+
if entry.state == SyncState.REMOTE_CHANGED:
|
|
75
|
+
result.remote_changed += 1
|
|
76
|
+
result.changed_paths.append(entry.rel_path)
|
|
77
|
+
elif entry.state == SyncState.REMOTE_DELETED:
|
|
78
|
+
result.remote_deleted += 1
|
|
79
|
+
result.deleted_paths.append(entry.rel_path)
|
|
80
|
+
if prune:
|
|
81
|
+
ctx.index.remove(entry.rel_path)
|
|
82
|
+
result.pruned += 1
|
|
83
|
+
|
|
84
|
+
if persist:
|
|
85
|
+
ctx.index.save()
|
|
86
|
+
return result
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from protonfs.context import RepoContext
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def restore(ctx: RepoContext, rel_path: str) -> None:
|
|
7
|
+
entry = ctx.index.get(rel_path)
|
|
8
|
+
remote_path = entry.remote_path if entry else f"{ctx.config.remote_root}/{rel_path}"
|
|
9
|
+
ctx.drive.restore([remote_path])
|
protonfs/commands/rm.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import PurePosixPath
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from protonfs.context import RepoContext
|
|
8
|
+
from protonfs.drive import decrypted_name
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def rm(ctx: RepoContext, rel_path: str, recursive: bool, force: bool, confirmed: bool) -> None:
|
|
12
|
+
local_target = ctx.root / rel_path
|
|
13
|
+
if local_target.is_dir() and not recursive:
|
|
14
|
+
raise click.ClickException(
|
|
15
|
+
f"'{rel_path}' is a directory; pass -r/--recursive to remove it."
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
if not confirmed:
|
|
19
|
+
kind = "permanently delete" if force else "trash"
|
|
20
|
+
click.confirm(f"{kind.capitalize()} '{rel_path}' on Drive?", abort=True)
|
|
21
|
+
|
|
22
|
+
entry = ctx.index.get(rel_path)
|
|
23
|
+
remote_path = entry.remote_path if entry else f"{ctx.config.remote_root}/{rel_path}"
|
|
24
|
+
|
|
25
|
+
ctx.drive.trash([remote_path])
|
|
26
|
+
if force:
|
|
27
|
+
# D2.2: permanent delete works only against /trash/<basename>, and with
|
|
28
|
+
# duplicate basenames the CLI deletes one arbitrarily. So only delete when
|
|
29
|
+
# exactly one trashed item carries this basename; otherwise leave it trashed
|
|
30
|
+
# (still reversible) and tell the user to resolve it manually.
|
|
31
|
+
name = PurePosixPath(remote_path).name
|
|
32
|
+
matches = [
|
|
33
|
+
entry for entry in ctx.drive.list("/trash") if decrypted_name(entry) == name
|
|
34
|
+
]
|
|
35
|
+
if len(matches) == 1:
|
|
36
|
+
ctx.drive.delete([f"/trash/{name}"])
|
|
37
|
+
elif len(matches) > 1:
|
|
38
|
+
click.echo(
|
|
39
|
+
f"{len(matches)} items named '{name}' are in trash; protonfs can't "
|
|
40
|
+
f"safely pick yours for permanent deletion. Resolve it via the Proton "
|
|
41
|
+
f"Drive app/web, or leave it trashed (it is already reversible)."
|
|
42
|
+
)
|
|
43
|
+
else:
|
|
44
|
+
click.echo(
|
|
45
|
+
f"'{name}' was trashed but could not be found in trash for permanent "
|
|
46
|
+
f"deletion (it may still be processing); it remains trashed and reversible."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
for indexed_rel in list(ctx.index.all()):
|
|
50
|
+
if indexed_rel == rel_path or indexed_rel.startswith(rel_path + "/"):
|
|
51
|
+
ctx.index.remove(indexed_rel)
|
|
52
|
+
ctx.index.save()
|