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
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# src/protonfs/commands/setup.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from protonfs.commands.push import push as push_files
|
|
10
|
+
from protonfs.config import Config, init_config, load_config
|
|
11
|
+
from protonfs.context import RepoContext
|
|
12
|
+
from protonfs.drive import DriveClient
|
|
13
|
+
from protonfs.ignore import init_ignore
|
|
14
|
+
from protonfs.index import IndexStore
|
|
15
|
+
from protonfs.lfs import find_pointer_stubs, is_lfs_tracked
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def ensure_cli_present(drive: DriveClient) -> str:
|
|
19
|
+
version = drive.version()
|
|
20
|
+
if version is None:
|
|
21
|
+
raise click.ClickException(
|
|
22
|
+
"proton-drive CLI not found. Install it from "
|
|
23
|
+
"https://proton.me/download/drive/cli/index.html and ensure it's on PATH, "
|
|
24
|
+
"then re-run `protonfs setup`."
|
|
25
|
+
)
|
|
26
|
+
return version
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def ensure_authenticated(drive: DriveClient) -> None:
|
|
30
|
+
if not drive.is_authenticated():
|
|
31
|
+
raise click.ClickException(
|
|
32
|
+
"Not authenticated with Proton Drive. Run `proton-drive auth login`, "
|
|
33
|
+
"then re-run `protonfs setup`."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def ensure_config(root: Path) -> Config:
|
|
38
|
+
existing = load_config(root)
|
|
39
|
+
if existing is not None:
|
|
40
|
+
return existing
|
|
41
|
+
remote_root = click.prompt("Remote Drive root path for this repo (e.g. /my-files/myproject)")
|
|
42
|
+
return init_config(root, remote_root)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _untrack_lfs_patterns(root: Path) -> list[str]:
|
|
46
|
+
gitattributes = root / ".gitattributes"
|
|
47
|
+
if not gitattributes.exists():
|
|
48
|
+
return []
|
|
49
|
+
removed_patterns: list[str] = []
|
|
50
|
+
kept_lines: list[str] = []
|
|
51
|
+
for line in gitattributes.read_text().splitlines():
|
|
52
|
+
if "filter=lfs" in line:
|
|
53
|
+
removed_patterns.append(line.split()[0])
|
|
54
|
+
else:
|
|
55
|
+
kept_lines.append(line)
|
|
56
|
+
gitattributes.write_text("\n".join(kept_lines) + ("\n" if kept_lines else ""))
|
|
57
|
+
return removed_patterns
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _append_gitignore(root: Path, patterns: list[str]) -> None:
|
|
61
|
+
gitignore = root / ".gitignore"
|
|
62
|
+
existing = gitignore.read_text() if gitignore.exists() else ""
|
|
63
|
+
existing_lines = {line.strip() for line in existing.splitlines()}
|
|
64
|
+
lines_to_add = [p for p in patterns if p.strip() not in existing_lines]
|
|
65
|
+
if not lines_to_add:
|
|
66
|
+
return
|
|
67
|
+
with gitignore.open("a") as fh:
|
|
68
|
+
if existing and not existing.endswith("\n"):
|
|
69
|
+
fh.write("\n")
|
|
70
|
+
fh.write("\n".join(lines_to_add) + "\n")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def migrate_lfs(ctx: RepoContext, dry_run: bool = False) -> bool:
|
|
74
|
+
root = ctx.root
|
|
75
|
+
if not is_lfs_tracked(root):
|
|
76
|
+
click.echo("No git-lfs tracking found -- skipping migration.")
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
click.echo("git-lfs tracking detected.")
|
|
80
|
+
if dry_run:
|
|
81
|
+
click.echo("[dry-run] Would run: git lfs pull")
|
|
82
|
+
click.echo("[dry-run] Would upload LFS-tracked files to Drive via `protonfs push`.")
|
|
83
|
+
click.echo(
|
|
84
|
+
"[dry-run] Would remove LFS filter rules from .gitattributes, add to .gitignore,"
|
|
85
|
+
)
|
|
86
|
+
click.echo("[dry-run] git rm --cached the affected paths, and commit.")
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
click.echo("Materializing real file content (git lfs pull)...")
|
|
90
|
+
subprocess.run(["git", "-C", str(root), "lfs", "pull"], check=True)
|
|
91
|
+
|
|
92
|
+
click.echo("Uploading LFS-tracked files to Drive before touching git tracking...")
|
|
93
|
+
result = push_files(ctx, subpath=None, resolve=None, dry_run=False)
|
|
94
|
+
if result.failed_items:
|
|
95
|
+
raise click.ClickException(
|
|
96
|
+
f"{result.failed_items} file(s) failed to upload -- aborting migration before "
|
|
97
|
+
"touching git tracking. Re-run `protonfs setup` once uploads succeed."
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
click.confirm(
|
|
101
|
+
f"Uploaded {result.transferred_items} file(s) to Drive. This will now remove LFS "
|
|
102
|
+
"filter rules from .gitattributes, gitignore the same paths, `git rm --cached` them, "
|
|
103
|
+
"and create one commit. Continue?",
|
|
104
|
+
abort=True,
|
|
105
|
+
)
|
|
106
|
+
patterns = _untrack_lfs_patterns(root)
|
|
107
|
+
_append_gitignore(root, patterns)
|
|
108
|
+
try:
|
|
109
|
+
subprocess.run(
|
|
110
|
+
["git", "-C", str(root), "add", ".gitattributes", ".gitignore"], check=True
|
|
111
|
+
)
|
|
112
|
+
for pattern in patterns:
|
|
113
|
+
subprocess.run(
|
|
114
|
+
["git", "-C", str(root), "rm", "-r", "--cached", "--ignore-unmatch", pattern],
|
|
115
|
+
check=True,
|
|
116
|
+
)
|
|
117
|
+
subprocess.run(
|
|
118
|
+
[
|
|
119
|
+
"git", "-C", str(root), "commit", "-m",
|
|
120
|
+
"chore: migrate dump storage from git-lfs to protonfs/Proton Drive",
|
|
121
|
+
],
|
|
122
|
+
check=True,
|
|
123
|
+
)
|
|
124
|
+
except subprocess.CalledProcessError as exc:
|
|
125
|
+
raise click.ClickException(
|
|
126
|
+
f"git step of the LFS migration failed ({exc}). The files were already "
|
|
127
|
+
"uploaded to Drive successfully, but your working tree may now be "
|
|
128
|
+
"mid-migration -- inspect `git status` and revert/complete the git state "
|
|
129
|
+
"manually before re-running `protonfs setup`."
|
|
130
|
+
) from exc
|
|
131
|
+
click.echo("Migration commit created. Review it before pushing.")
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def clean_pointer_stubs(root: Path) -> int:
|
|
136
|
+
stubs = find_pointer_stubs(root, Path("."))
|
|
137
|
+
for stub in stubs:
|
|
138
|
+
stub.unlink()
|
|
139
|
+
return len(stubs)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def maybe_uninstall_lfs_filters(root: Path) -> None:
|
|
143
|
+
gitattributes = root / ".gitattributes"
|
|
144
|
+
still_has_lfs = gitattributes.exists() and "filter=lfs" in gitattributes.read_text()
|
|
145
|
+
if still_has_lfs:
|
|
146
|
+
return
|
|
147
|
+
result = subprocess.run(["git", "-C", str(root), "lfs", "env"], capture_output=True, text=True)
|
|
148
|
+
if result.returncode != 0:
|
|
149
|
+
return # git-lfs not installed at all -- nothing to uninstall
|
|
150
|
+
click.echo("No LFS patterns remain in .gitattributes; running `git lfs uninstall`.")
|
|
151
|
+
subprocess.run(["git", "-C", str(root), "lfs", "uninstall"], check=True)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def run_setup(root: Path, dry_run: bool = False) -> None:
|
|
155
|
+
drive = DriveClient()
|
|
156
|
+
version = ensure_cli_present(drive)
|
|
157
|
+
click.echo(f"proton-drive CLI found: {version}")
|
|
158
|
+
|
|
159
|
+
ensure_authenticated(drive)
|
|
160
|
+
click.echo("Authenticated with Proton Drive.")
|
|
161
|
+
|
|
162
|
+
config = ensure_config(root)
|
|
163
|
+
init_ignore(root)
|
|
164
|
+
config_file = root / ".protonfs" / "config.json"
|
|
165
|
+
click.echo(f"Config ready at {config_file} (remote_root={config.remote_root}).")
|
|
166
|
+
|
|
167
|
+
ctx = RepoContext(root=root, config=config, index=IndexStore(root), drive=drive)
|
|
168
|
+
migrated = migrate_lfs(ctx, dry_run=dry_run)
|
|
169
|
+
|
|
170
|
+
removed = clean_pointer_stubs(root)
|
|
171
|
+
if removed:
|
|
172
|
+
click.echo(f"Removed {removed} leftover git-lfs pointer stub file(s).")
|
|
173
|
+
else:
|
|
174
|
+
click.echo("No leftover git-lfs pointer stubs found.")
|
|
175
|
+
|
|
176
|
+
if migrated and not dry_run:
|
|
177
|
+
maybe_uninstall_lfs_filters(root)
|
|
178
|
+
|
|
179
|
+
click.echo("protonfs setup complete.")
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import Counter
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from protonfs.context import RepoContext
|
|
7
|
+
from protonfs.diff import classify
|
|
8
|
+
from protonfs.ignore import IgnoreMatcher
|
|
9
|
+
from protonfs.localscan import scan
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def compute_status(ctx: RepoContext, subpath: str | None) -> Counter:
|
|
13
|
+
ignore = IgnoreMatcher.from_file(ctx.root)
|
|
14
|
+
scan_root = Path(subpath) if subpath else Path(".")
|
|
15
|
+
local = scan(ctx.root, scan_root, ignore, ctx.index, low_io=ctx.config.defaults.low_io)
|
|
16
|
+
entries = classify(local, ctx.index)
|
|
17
|
+
return Counter(entry.state.value for entry in entries)
|
protonfs/config.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import uuid
|
|
5
|
+
from dataclasses import asdict, dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
CONFIG_DIR_NAME = ".protonfs"
|
|
9
|
+
CONFIG_FILE_NAME = "config.json"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Defaults:
|
|
14
|
+
on_conflict: str = "skip"
|
|
15
|
+
low_io: bool = False
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Config:
|
|
20
|
+
remote_root: str
|
|
21
|
+
device_id: str
|
|
22
|
+
defaults: Defaults = field(default_factory=Defaults)
|
|
23
|
+
|
|
24
|
+
def to_dict(self) -> dict:
|
|
25
|
+
return {
|
|
26
|
+
"remote_root": self.remote_root,
|
|
27
|
+
"device_id": self.device_id,
|
|
28
|
+
"defaults": asdict(self.defaults),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_dict(cls, data: dict) -> Config:
|
|
33
|
+
defaults_data = data.get("defaults", {})
|
|
34
|
+
return cls(
|
|
35
|
+
remote_root=data["remote_root"],
|
|
36
|
+
device_id=data["device_id"],
|
|
37
|
+
defaults=Defaults(
|
|
38
|
+
on_conflict=defaults_data.get("on_conflict", "skip"),
|
|
39
|
+
low_io=defaults_data.get("low_io", False),
|
|
40
|
+
),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def config_dir(repo_root: Path) -> Path:
|
|
45
|
+
return repo_root / CONFIG_DIR_NAME
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def config_path(repo_root: Path) -> Path:
|
|
49
|
+
return config_dir(repo_root) / CONFIG_FILE_NAME
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load_config(repo_root: Path) -> Config | None:
|
|
53
|
+
path = config_path(repo_root)
|
|
54
|
+
if not path.exists():
|
|
55
|
+
return None
|
|
56
|
+
return Config.from_dict(json.loads(path.read_text()))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def save_config(repo_root: Path, config: Config) -> None:
|
|
60
|
+
config_dir(repo_root).mkdir(parents=True, exist_ok=True)
|
|
61
|
+
config_path(repo_root).write_text(json.dumps(config.to_dict(), indent=2) + "\n")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def init_config(repo_root: Path, remote_root: str) -> Config:
|
|
65
|
+
config = Config(remote_root=remote_root, device_id=str(uuid.uuid4()))
|
|
66
|
+
save_config(repo_root, config)
|
|
67
|
+
return config
|
protonfs/context.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from protonfs.config import Config, load_config
|
|
9
|
+
from protonfs.drive import DriveClient
|
|
10
|
+
from protonfs.index import IndexStore
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class RepoContext:
|
|
15
|
+
root: Path
|
|
16
|
+
config: Config
|
|
17
|
+
index: IndexStore
|
|
18
|
+
drive: DriveClient
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_context(start: Path | None = None) -> RepoContext:
|
|
22
|
+
root = (start or Path.cwd()).resolve()
|
|
23
|
+
config = load_config(root)
|
|
24
|
+
if config is None:
|
|
25
|
+
raise click.ClickException(
|
|
26
|
+
"protonfs is not set up in this directory. Run `protonfs setup` first."
|
|
27
|
+
)
|
|
28
|
+
return RepoContext(root=root, config=config, index=IndexStore(root), drive=DriveClient())
|
protonfs/diff.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# src/protonfs/diff.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
from protonfs.index import IndexStore
|
|
8
|
+
from protonfs.localscan import ScanEntry
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SyncState(str, Enum):
|
|
12
|
+
SYNCED = "synced"
|
|
13
|
+
LOCAL_ONLY = "local-only"
|
|
14
|
+
REMOTE_ONLY = "remote-only"
|
|
15
|
+
METADATA_ONLY = "metadata-only"
|
|
16
|
+
CONFLICT = "conflict"
|
|
17
|
+
REMOTE_CHANGED = "remote-changed"
|
|
18
|
+
REMOTE_DELETED = "remote-deleted"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class DiffEntry:
|
|
23
|
+
rel_path: str
|
|
24
|
+
state: SyncState
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def within_subpath(rel_path: str, subpath: str | None) -> bool:
|
|
28
|
+
"""True when rel_path lies inside `subpath` (or when there is no subpath).
|
|
29
|
+
|
|
30
|
+
`classify` reasons over the whole repo-wide index, so a caller that scoped its
|
|
31
|
+
local scan and remote walk to a subpath MUST filter classify's output with this
|
|
32
|
+
before acting on it -- otherwise index entries outside the subpath (never
|
|
33
|
+
scanned, never walked) are misread as remote-deleted / remote-only.
|
|
34
|
+
"""
|
|
35
|
+
if not subpath:
|
|
36
|
+
return True
|
|
37
|
+
return rel_path == subpath or rel_path.startswith(f"{subpath}/")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def classify(
|
|
41
|
+
local: dict[str, ScanEntry],
|
|
42
|
+
index: IndexStore,
|
|
43
|
+
remote: dict[str, int] | None = None,
|
|
44
|
+
) -> list[DiffEntry]:
|
|
45
|
+
known_paths = set(local) | set(index.all())
|
|
46
|
+
if remote is not None:
|
|
47
|
+
known_paths |= set(remote)
|
|
48
|
+
|
|
49
|
+
results: list[DiffEntry] = []
|
|
50
|
+
for rel_path in sorted(known_paths):
|
|
51
|
+
local_entry = local.get(rel_path)
|
|
52
|
+
index_entry = index.get(rel_path)
|
|
53
|
+
|
|
54
|
+
if local_entry is not None and index_entry is None:
|
|
55
|
+
state = SyncState.LOCAL_ONLY
|
|
56
|
+
elif local_entry is not None and index_entry is not None:
|
|
57
|
+
state = (
|
|
58
|
+
SyncState.SYNCED
|
|
59
|
+
if local_entry.sha256 == index_entry.sha256
|
|
60
|
+
else SyncState.CONFLICT
|
|
61
|
+
)
|
|
62
|
+
elif local_entry is None and index_entry is not None:
|
|
63
|
+
if remote is not None:
|
|
64
|
+
if rel_path not in remote:
|
|
65
|
+
state = SyncState.REMOTE_DELETED
|
|
66
|
+
elif remote[rel_path] != index_entry.size:
|
|
67
|
+
state = SyncState.REMOTE_CHANGED
|
|
68
|
+
else:
|
|
69
|
+
state = (
|
|
70
|
+
SyncState.METADATA_ONLY
|
|
71
|
+
if index_entry.local_state == "metadata-only"
|
|
72
|
+
else SyncState.REMOTE_ONLY
|
|
73
|
+
)
|
|
74
|
+
else:
|
|
75
|
+
state = (
|
|
76
|
+
SyncState.METADATA_ONLY
|
|
77
|
+
if index_entry.local_state == "metadata-only"
|
|
78
|
+
else SyncState.REMOTE_ONLY
|
|
79
|
+
)
|
|
80
|
+
else:
|
|
81
|
+
state = SyncState.REMOTE_ONLY
|
|
82
|
+
results.append(DiffEntry(rel_path, state))
|
|
83
|
+
return results
|
protonfs/drive.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# src/protonfs/drive.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from collections import deque
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
BINARY_ENV_VAR = "PROTONFS_DRIVE_BIN"
|
|
16
|
+
DEFAULT_BINARY = "proton-drive"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DriveError(RuntimeError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DriveAuthError(DriveError):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class TransferResult:
|
|
29
|
+
transferred_items: int
|
|
30
|
+
skipped_items: int
|
|
31
|
+
failed_items: int
|
|
32
|
+
failures: list[dict]
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_json(cls, data: dict) -> TransferResult:
|
|
36
|
+
return cls(
|
|
37
|
+
transferred_items=data.get("transferredItems", 0),
|
|
38
|
+
skipped_items=data.get("skippedItems", 0),
|
|
39
|
+
failed_items=data.get("failedItems", 0),
|
|
40
|
+
failures=data.get("failures", []),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class RemoteEntry:
|
|
46
|
+
rel_path: str
|
|
47
|
+
is_dir: bool
|
|
48
|
+
size: int
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def binary_path() -> str:
|
|
52
|
+
return os.environ.get(BINARY_ENV_VAR, DEFAULT_BINARY)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# Specific phrases that signal a genuine auth failure. Tightened from the v0.1 broad
|
|
56
|
+
# `"auth" in message` check, which false-positived on unrelated words ("author",
|
|
57
|
+
# "unauthorized"/permission errors). D5.1: we prefer a false negative (a real auth
|
|
58
|
+
# error surfaced as a generic DriveError, still an error) over a false positive
|
|
59
|
+
# (a non-auth error mislabelled as auth). If proton-drive's exact wording is later
|
|
60
|
+
# captured via a deliberate logout probe, add it here.
|
|
61
|
+
_AUTH_ERROR_SIGNALS = (
|
|
62
|
+
"unauthenticated",
|
|
63
|
+
"not authenticated",
|
|
64
|
+
"not logged in",
|
|
65
|
+
"log in first",
|
|
66
|
+
"login required",
|
|
67
|
+
"authentication required",
|
|
68
|
+
"session expired",
|
|
69
|
+
"auth required",
|
|
70
|
+
"please authenticate",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _is_auth_error(message: str) -> bool:
|
|
75
|
+
lowered = message.lower()
|
|
76
|
+
return any(signal in lowered for signal in _AUTH_ERROR_SIGNALS)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def decrypted_name(entry: dict) -> str | None:
|
|
80
|
+
"""The decrypted filename of a `filesystem list` entry, or None if its name could
|
|
81
|
+
not be decrypted (``name.ok`` is false). Central helper so every consumer parses
|
|
82
|
+
the ``{"name": {"ok": bool, "value": str}}`` shape identically."""
|
|
83
|
+
name = entry.get("name", {})
|
|
84
|
+
if not name.get("ok"):
|
|
85
|
+
return None
|
|
86
|
+
return name.get("value")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class DriveClient:
|
|
90
|
+
def __init__(self, binary: str | None = None) -> None:
|
|
91
|
+
self._binary = binary or binary_path()
|
|
92
|
+
|
|
93
|
+
def _binary_available(self) -> bool:
|
|
94
|
+
return shutil.which(self._binary) is not None or Path(self._binary).exists()
|
|
95
|
+
|
|
96
|
+
def _invoke(self, args: list[str]) -> subprocess.CompletedProcess:
|
|
97
|
+
if not self._binary_available():
|
|
98
|
+
raise DriveError(f"proton-drive binary not found: {self._binary}")
|
|
99
|
+
return subprocess.run([self._binary, *args, "--json"], capture_output=True, text=True)
|
|
100
|
+
|
|
101
|
+
def _run_json(self, args: list[str]) -> dict | list:
|
|
102
|
+
result = self._invoke(args)
|
|
103
|
+
stdout = result.stdout.strip()
|
|
104
|
+
try:
|
|
105
|
+
parsed = json.loads(stdout) if stdout else {}
|
|
106
|
+
except json.JSONDecodeError as exc:
|
|
107
|
+
raise DriveError(f"unparseable output from proton-drive: {stdout!r}") from exc
|
|
108
|
+
if result.returncode != 0:
|
|
109
|
+
message = json.dumps(parsed) if parsed else result.stderr.strip()
|
|
110
|
+
if _is_auth_error(message):
|
|
111
|
+
raise DriveAuthError(f"proton-drive auth required: {message}")
|
|
112
|
+
raise DriveError(message)
|
|
113
|
+
return parsed
|
|
114
|
+
|
|
115
|
+
def _run_transfer(self, args: list[str]) -> TransferResult:
|
|
116
|
+
result = self._invoke(args)
|
|
117
|
+
stdout = result.stdout.strip()
|
|
118
|
+
try:
|
|
119
|
+
parsed = json.loads(stdout) if stdout else None
|
|
120
|
+
except json.JSONDecodeError:
|
|
121
|
+
parsed = None
|
|
122
|
+
if not isinstance(parsed, dict) or "transferredItems" not in parsed:
|
|
123
|
+
message = result.stderr.strip() or stdout
|
|
124
|
+
if _is_auth_error(message):
|
|
125
|
+
raise DriveAuthError(f"proton-drive auth required: {message}")
|
|
126
|
+
raise DriveError(message)
|
|
127
|
+
return TransferResult.from_json(parsed)
|
|
128
|
+
|
|
129
|
+
def version(self) -> str | None:
|
|
130
|
+
if not self._binary_available():
|
|
131
|
+
return None
|
|
132
|
+
result = subprocess.run([self._binary, "version"], capture_output=True, text=True)
|
|
133
|
+
if result.returncode != 0:
|
|
134
|
+
return None
|
|
135
|
+
return result.stdout.strip()
|
|
136
|
+
|
|
137
|
+
def is_authenticated(self) -> bool:
|
|
138
|
+
try:
|
|
139
|
+
self._run_json(["filesystem", "list", "/"])
|
|
140
|
+
except DriveError:
|
|
141
|
+
return False
|
|
142
|
+
return True
|
|
143
|
+
|
|
144
|
+
def list(self, remote_path: str) -> list[dict]:
|
|
145
|
+
result = self._run_json(["filesystem", "list", remote_path])
|
|
146
|
+
return result if isinstance(result, list) else []
|
|
147
|
+
|
|
148
|
+
def walk(self, remote_root: str) -> list[RemoteEntry]:
|
|
149
|
+
root = remote_root.rstrip("/")
|
|
150
|
+
results: list[RemoteEntry] = []
|
|
151
|
+
# queue of (absolute remote path, rel prefix); deque gives O(1) popleft
|
|
152
|
+
queue: deque[tuple[str, str]] = deque([(root, "")])
|
|
153
|
+
while queue:
|
|
154
|
+
abs_path, prefix = queue.popleft()
|
|
155
|
+
for entry in self.list(abs_path):
|
|
156
|
+
value = decrypted_name(entry)
|
|
157
|
+
if value is None:
|
|
158
|
+
logger.warning(
|
|
159
|
+
"skipping remote entry with undecryptable name under %s", abs_path
|
|
160
|
+
)
|
|
161
|
+
continue
|
|
162
|
+
rel = f"{prefix}{value}" if prefix else value
|
|
163
|
+
child_abs = f"{abs_path}/{value}"
|
|
164
|
+
if entry.get("type") == "folder":
|
|
165
|
+
results.append(RemoteEntry(rel_path=rel, is_dir=True, size=0))
|
|
166
|
+
queue.append((child_abs, f"{rel}/"))
|
|
167
|
+
else:
|
|
168
|
+
results.append(
|
|
169
|
+
RemoteEntry(
|
|
170
|
+
rel_path=rel,
|
|
171
|
+
is_dir=False,
|
|
172
|
+
size=entry.get("totalStorageSize", 0),
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
return results
|
|
176
|
+
|
|
177
|
+
def upload(
|
|
178
|
+
self,
|
|
179
|
+
local_paths: list[Path],
|
|
180
|
+
remote_parent: str,
|
|
181
|
+
file_strategy: str | None = None,
|
|
182
|
+
folder_strategy: str | None = None,
|
|
183
|
+
) -> TransferResult:
|
|
184
|
+
args = ["filesystem", "upload"]
|
|
185
|
+
if file_strategy:
|
|
186
|
+
args += ["-f", file_strategy]
|
|
187
|
+
if folder_strategy:
|
|
188
|
+
args += ["-d", folder_strategy]
|
|
189
|
+
args += [str(p) for p in local_paths] + [remote_parent]
|
|
190
|
+
return self._run_transfer(args)
|
|
191
|
+
|
|
192
|
+
def download(
|
|
193
|
+
self,
|
|
194
|
+
remote_paths: list[str],
|
|
195
|
+
local_folder: Path,
|
|
196
|
+
file_strategy: str | None = None,
|
|
197
|
+
folder_strategy: str | None = None,
|
|
198
|
+
) -> TransferResult:
|
|
199
|
+
args = ["filesystem", "download"]
|
|
200
|
+
if file_strategy:
|
|
201
|
+
args += ["-f", file_strategy]
|
|
202
|
+
if folder_strategy:
|
|
203
|
+
args += ["-d", folder_strategy]
|
|
204
|
+
args += remote_paths + [str(local_folder)]
|
|
205
|
+
return self._run_transfer(args)
|
|
206
|
+
|
|
207
|
+
def trash(self, remote_paths: list[str]) -> list[dict]:
|
|
208
|
+
result = self._run_json(["filesystem", "trash", *remote_paths])
|
|
209
|
+
return result if isinstance(result, list) else []
|
|
210
|
+
|
|
211
|
+
def restore(self, remote_paths: list[str]) -> list[dict]:
|
|
212
|
+
result = self._run_json(["filesystem", "restore", *remote_paths])
|
|
213
|
+
return result if isinstance(result, list) else []
|
|
214
|
+
|
|
215
|
+
def delete(self, remote_paths: list[str]) -> list[dict]:
|
|
216
|
+
result = self._run_json(["filesystem", "delete", *remote_paths])
|
|
217
|
+
return result if isinstance(result, list) else []
|
|
218
|
+
|
|
219
|
+
def create_folder(self, parent_path: str, name: str) -> dict:
|
|
220
|
+
result = self._run_json(["filesystem", "create-folder", parent_path, name])
|
|
221
|
+
return result if isinstance(result, dict) else {}
|
protonfs/ignore.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pathspec
|
|
6
|
+
|
|
7
|
+
IGNORE_FILE_NAME = "ignore"
|
|
8
|
+
|
|
9
|
+
DEFAULT_IGNORE_TEMPLATE = """\
|
|
10
|
+
# protonfs ignore patterns (gitignore syntax)
|
|
11
|
+
# Scopes what protonfs will sync from a given directory -- independent of
|
|
12
|
+
# the repo's own .gitignore.
|
|
13
|
+
*.tmp
|
|
14
|
+
*.swp
|
|
15
|
+
core.*
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def ignore_path(repo_root: Path) -> Path:
|
|
20
|
+
return repo_root / ".protonfs" / IGNORE_FILE_NAME
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def init_ignore(repo_root: Path) -> None:
|
|
24
|
+
path = ignore_path(repo_root)
|
|
25
|
+
if path.exists():
|
|
26
|
+
return
|
|
27
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
path.write_text(DEFAULT_IGNORE_TEMPLATE)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class IgnoreMatcher:
|
|
32
|
+
def __init__(self, patterns: list[str]) -> None:
|
|
33
|
+
self._spec = pathspec.GitIgnoreSpec.from_lines(patterns)
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def from_file(cls, repo_root: Path) -> IgnoreMatcher:
|
|
37
|
+
path = ignore_path(repo_root)
|
|
38
|
+
if not path.exists():
|
|
39
|
+
return cls([])
|
|
40
|
+
return cls(path.read_text().splitlines())
|
|
41
|
+
|
|
42
|
+
def matches(self, rel_path: str) -> bool:
|
|
43
|
+
return self._spec.match_file(rel_path)
|
protonfs/index.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import asdict, dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
INDEX_FILE_NAME = "index.json"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class IndexEntry:
|
|
12
|
+
size: int
|
|
13
|
+
mtime: float
|
|
14
|
+
sha256: str
|
|
15
|
+
remote_path: str
|
|
16
|
+
origin_device: str
|
|
17
|
+
local_state: str # "present" | "metadata-only"
|
|
18
|
+
last_synced: str # ISO-8601 timestamp
|
|
19
|
+
|
|
20
|
+
def to_dict(self) -> dict:
|
|
21
|
+
return asdict(self)
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def from_dict(cls, data: dict) -> IndexEntry:
|
|
25
|
+
return cls(**data)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class IndexStore:
|
|
29
|
+
def __init__(self, repo_root: Path) -> None:
|
|
30
|
+
self._path = repo_root / ".protonfs" / INDEX_FILE_NAME
|
|
31
|
+
self._entries: dict[str, IndexEntry] = {}
|
|
32
|
+
self._load()
|
|
33
|
+
|
|
34
|
+
def _load(self) -> None:
|
|
35
|
+
if not self._path.exists():
|
|
36
|
+
return
|
|
37
|
+
raw = json.loads(self._path.read_text())
|
|
38
|
+
self._entries = {rel_path: IndexEntry.from_dict(data) for rel_path, data in raw.items()}
|
|
39
|
+
|
|
40
|
+
def save(self) -> None:
|
|
41
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
raw = {rel_path: entry.to_dict() for rel_path, entry in self._entries.items()}
|
|
43
|
+
self._path.write_text(json.dumps(raw, indent=2, sort_keys=True) + "\n")
|
|
44
|
+
|
|
45
|
+
def get(self, rel_path: str) -> IndexEntry | None:
|
|
46
|
+
return self._entries.get(rel_path)
|
|
47
|
+
|
|
48
|
+
def set(self, rel_path: str, entry: IndexEntry) -> None:
|
|
49
|
+
self._entries[rel_path] = entry
|
|
50
|
+
|
|
51
|
+
def remove(self, rel_path: str) -> None:
|
|
52
|
+
self._entries.pop(rel_path, None)
|
|
53
|
+
|
|
54
|
+
def all(self) -> dict[str, IndexEntry]:
|
|
55
|
+
return dict(self._entries)
|