study-sync 1.0.4__tar.gz → 1.0.8__tar.gz
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.
- {study_sync-1.0.4 → study_sync-1.0.8}/PKG-INFO +1 -1
- {study_sync-1.0.4 → study_sync-1.0.8}/pyproject.toml +1 -1
- {study_sync-1.0.4 → study_sync-1.0.8}/study_sync.egg-info/PKG-INFO +1 -1
- study_sync-1.0.8/studysync/sync_engine.py +670 -0
- study_sync-1.0.4/studysync/sync_engine.py +0 -587
- {study_sync-1.0.4 → study_sync-1.0.8}/README.md +0 -0
- {study_sync-1.0.4 → study_sync-1.0.8}/setup.cfg +0 -0
- {study_sync-1.0.4 → study_sync-1.0.8}/study_sync.egg-info/SOURCES.txt +0 -0
- {study_sync-1.0.4 → study_sync-1.0.8}/study_sync.egg-info/dependency_links.txt +0 -0
- {study_sync-1.0.4 → study_sync-1.0.8}/study_sync.egg-info/entry_points.txt +0 -0
- {study_sync-1.0.4 → study_sync-1.0.8}/study_sync.egg-info/requires.txt +0 -0
- {study_sync-1.0.4 → study_sync-1.0.8}/study_sync.egg-info/top_level.txt +0 -0
- {study_sync-1.0.4 → study_sync-1.0.8}/studysync/__init__.py +0 -0
- {study_sync-1.0.4 → study_sync-1.0.8}/studysync/constants.py +0 -0
- {study_sync-1.0.4 → study_sync-1.0.8}/studysync/local_state.py +0 -0
- {study_sync-1.0.4 → study_sync-1.0.8}/studysync/main.py +0 -0
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sync_engine.py — Core networking and sync logic for StudySync CLI.
|
|
3
|
+
|
|
4
|
+
Two-phase upload
|
|
5
|
+
----------------
|
|
6
|
+
1. POST /upload-request → {presigned_put_url, pending_upload_id}
|
|
7
|
+
2. PUT presigned_put_url (raw bytes)
|
|
8
|
+
3. POST /commit-upload → updated file record
|
|
9
|
+
|
|
10
|
+
Optimistic concurrency control
|
|
11
|
+
-------------------------------
|
|
12
|
+
Every push includes base_version. If that doesn't match the server's
|
|
13
|
+
latest_version the server returns HTTP 409. The client must pull first.
|
|
14
|
+
|
|
15
|
+
Alias resolution
|
|
16
|
+
-----------------
|
|
17
|
+
Users can join with a short workspace alias (name) instead of a raw UUID.
|
|
18
|
+
SyncEngine.resolve_alias() calls GET /alias/{alias} on the backend, which
|
|
19
|
+
returns the workspace's access_token.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
import shutil
|
|
26
|
+
import sys
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
import requests
|
|
31
|
+
from requests import HTTPError, Session
|
|
32
|
+
from rich.console import Console
|
|
33
|
+
from rich.panel import Panel
|
|
34
|
+
from rich.progress import (
|
|
35
|
+
BarColumn,
|
|
36
|
+
DownloadColumn,
|
|
37
|
+
Progress,
|
|
38
|
+
TextColumn,
|
|
39
|
+
TimeRemainingColumn,
|
|
40
|
+
TransferSpeedColumn,
|
|
41
|
+
)
|
|
42
|
+
from rich.table import Table
|
|
43
|
+
|
|
44
|
+
from .constants import PRODUCTION_SERVER_URL
|
|
45
|
+
from .local_state import (
|
|
46
|
+
all_local_files,
|
|
47
|
+
ensure_dirs,
|
|
48
|
+
load_config,
|
|
49
|
+
load_manifest,
|
|
50
|
+
local_file_path,
|
|
51
|
+
save_manifest,
|
|
52
|
+
sha256_file,
|
|
53
|
+
update_manifest_entry,
|
|
54
|
+
workspace_root,
|
|
55
|
+
WORKSPACES_DIR,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
console = Console()
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# Progress-aware file reader
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
class _ProgressReader:
|
|
65
|
+
"""Wrap a file so requests streams it while updating a Rich progress bar."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, path: Path, task_id: Any, progress: Progress) -> None:
|
|
68
|
+
self._fh = open(path, "rb")
|
|
69
|
+
self._task_id = task_id
|
|
70
|
+
self._progress = progress
|
|
71
|
+
self._size = path.stat().st_size
|
|
72
|
+
|
|
73
|
+
def read(self, size: int = -1) -> bytes:
|
|
74
|
+
chunk = self._fh.read(size)
|
|
75
|
+
if chunk:
|
|
76
|
+
self._progress.update(self._task_id, advance=len(chunk))
|
|
77
|
+
return chunk
|
|
78
|
+
|
|
79
|
+
def __len__(self) -> int:
|
|
80
|
+
return self._size
|
|
81
|
+
|
|
82
|
+
def close(self) -> None:
|
|
83
|
+
self._fh.close()
|
|
84
|
+
|
|
85
|
+
def __enter__(self) -> "_ProgressReader":
|
|
86
|
+
return self
|
|
87
|
+
|
|
88
|
+
def __exit__(self, *_: Any) -> None:
|
|
89
|
+
self.close()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
# SyncEngine
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
class SyncEngine:
|
|
97
|
+
"""Handles all communication with the StudySync backend."""
|
|
98
|
+
|
|
99
|
+
def __init__(self, server_url: str | None = None) -> None:
|
|
100
|
+
ensure_dirs()
|
|
101
|
+
config = load_config()
|
|
102
|
+
raw_url = server_url or config.get("server_url", PRODUCTION_SERVER_URL)
|
|
103
|
+
self.base_url: str = raw_url.rstrip("/")
|
|
104
|
+
self.workspace_id: str = config.get("workspace_id", "")
|
|
105
|
+
self.workspace_name: str = config.get("workspace_name", "")
|
|
106
|
+
self.token: str = config.get("workspace_token", "")
|
|
107
|
+
|
|
108
|
+
self.session: Session = requests.Session()
|
|
109
|
+
if self.token:
|
|
110
|
+
self.session.headers["Authorization"] = f"Bearer {self.token}"
|
|
111
|
+
self.session.headers["User-Agent"] = "studysync-cli/1.0"
|
|
112
|
+
|
|
113
|
+
# ------------------------------------------------------------------
|
|
114
|
+
# Internal helpers
|
|
115
|
+
# ------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
def _raise_for_status(self, resp: requests.Response) -> None:
|
|
118
|
+
"""Raise HTTPError with the backend's JSON detail if present."""
|
|
119
|
+
if resp.ok:
|
|
120
|
+
return
|
|
121
|
+
try:
|
|
122
|
+
detail = resp.json().get("detail", resp.text)
|
|
123
|
+
except Exception:
|
|
124
|
+
detail = resp.text
|
|
125
|
+
raise HTTPError(
|
|
126
|
+
f"HTTP {resp.status_code}: {detail}",
|
|
127
|
+
response=resp,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def _get(self, url: str, **kwargs: Any) -> requests.Response:
|
|
131
|
+
"""GET with automatic cold-start retry."""
|
|
132
|
+
return self._request_with_retry("GET", url, **kwargs)
|
|
133
|
+
|
|
134
|
+
def _post(self, url: str, **kwargs: Any) -> requests.Response:
|
|
135
|
+
"""POST with automatic cold-start retry."""
|
|
136
|
+
return self._request_with_retry("POST", url, **kwargs)
|
|
137
|
+
|
|
138
|
+
def _put(self, url: str, **kwargs: Any) -> requests.Response:
|
|
139
|
+
"""PUT with automatic cold-start retry (no cold-start retry for binary uploads)."""
|
|
140
|
+
kwargs.setdefault("timeout", 90)
|
|
141
|
+
return self.session.put(url, **kwargs)
|
|
142
|
+
|
|
143
|
+
def _request_with_retry(self, method: str, url: str, **kwargs: Any) -> requests.Response:
|
|
144
|
+
"""Send a request; on ReadTimeout show a wakeup message and retry once."""
|
|
145
|
+
kwargs.setdefault("timeout", 90)
|
|
146
|
+
try:
|
|
147
|
+
return self.session.request(method, url, **kwargs)
|
|
148
|
+
except requests.ReadTimeout:
|
|
149
|
+
console.print(
|
|
150
|
+
"[yellow]⏳ Server is waking up (Render cold start) — retrying, please wait…[/yellow]"
|
|
151
|
+
)
|
|
152
|
+
kwargs["timeout"] = 120
|
|
153
|
+
return self.session.request(method, url, **kwargs)
|
|
154
|
+
|
|
155
|
+
def _require_workspace(self) -> None:
|
|
156
|
+
"""Abort with a friendly message if no workspace is configured."""
|
|
157
|
+
if not self.workspace_id or not self.token:
|
|
158
|
+
console.print(
|
|
159
|
+
Panel(
|
|
160
|
+
"No workspace is configured.\n\n"
|
|
161
|
+
"Create one: [cyan]study workspace create <name>[/cyan]\n"
|
|
162
|
+
"Or join one: [cyan]study join <token-or-alias>[/cyan]",
|
|
163
|
+
title="[bold red]✗ Not configured[/bold red]",
|
|
164
|
+
border_style="red",
|
|
165
|
+
padding=(1, 2),
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
sys.exit(1)
|
|
169
|
+
|
|
170
|
+
# ------------------------------------------------------------------
|
|
171
|
+
# Workspace management
|
|
172
|
+
# ------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
def create_workspace(self, name: str) -> dict[str, Any]:
|
|
175
|
+
"""POST /workspaces — create a new workspace; returns {workspace_id, access_token, name}."""
|
|
176
|
+
resp = self._post(
|
|
177
|
+
f"{self.base_url}/workspaces",
|
|
178
|
+
json={"name": name},
|
|
179
|
+
)
|
|
180
|
+
self._raise_for_status(resp)
|
|
181
|
+
data = resp.json()
|
|
182
|
+
return {
|
|
183
|
+
"workspace_id": data["workspace_id"],
|
|
184
|
+
"access_token": data["access_token"],
|
|
185
|
+
"name": data["name"],
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
def join_workspace(self, token: str) -> dict[str, Any]:
|
|
189
|
+
"""GET /workspaces/{token} — validate token; returns {workspace_id, name}."""
|
|
190
|
+
resp = self._get(
|
|
191
|
+
f"{self.base_url}/workspaces/{token}",
|
|
192
|
+
)
|
|
193
|
+
self._raise_for_status(resp)
|
|
194
|
+
data = resp.json()
|
|
195
|
+
return {
|
|
196
|
+
"workspace_id": data["workspace_id"],
|
|
197
|
+
"name": data["name"],
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
def resolve_input(self, token_or_alias: str) -> dict:
|
|
201
|
+
"""
|
|
202
|
+
GET /resolve/{input} — resolve a token or alias to workspace info.
|
|
203
|
+
|
|
204
|
+
The backend checks its Aliases table first. If the input is not a
|
|
205
|
+
known alias it tries to interpret it as a raw UUID access_token.
|
|
206
|
+
|
|
207
|
+
Returns
|
|
208
|
+
-------
|
|
209
|
+
dict
|
|
210
|
+
Keys: ``resolved_from_alias`` (bool), ``alias`` (str | None),
|
|
211
|
+
``workspace_id``, ``name``, ``access_token``.
|
|
212
|
+
|
|
213
|
+
Raises
|
|
214
|
+
------
|
|
215
|
+
SystemExit
|
|
216
|
+
Prints a red error panel and exits 1 on HTTP 404 or connection
|
|
217
|
+
failure.
|
|
218
|
+
"""
|
|
219
|
+
try:
|
|
220
|
+
resp = self._get(
|
|
221
|
+
f"{self.base_url}/resolve/{token_or_alias}",
|
|
222
|
+
)
|
|
223
|
+
self._raise_for_status(resp)
|
|
224
|
+
return resp.json()
|
|
225
|
+
except HTTPError as exc:
|
|
226
|
+
code = exc.response.status_code if exc.response is not None else "?"
|
|
227
|
+
try:
|
|
228
|
+
detail = exc.response.json().get("detail", str(exc))
|
|
229
|
+
except Exception:
|
|
230
|
+
detail = str(exc)
|
|
231
|
+
console.print(
|
|
232
|
+
Panel(
|
|
233
|
+
f"{detail}",
|
|
234
|
+
title=f"[bold red]✗ Could not resolve '{token_or_alias}'[/bold red]",
|
|
235
|
+
border_style="red",
|
|
236
|
+
padding=(1, 2),
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
sys.exit(1)
|
|
240
|
+
except requests.ConnectionError:
|
|
241
|
+
console.print(
|
|
242
|
+
Panel(
|
|
243
|
+
f"Could not reach [cyan]{self.base_url}[/cyan].\n"
|
|
244
|
+
"Check your network connection or --server URL.",
|
|
245
|
+
title="[bold red]✗ Connection error[/bold red]",
|
|
246
|
+
border_style="red",
|
|
247
|
+
padding=(1, 2),
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
sys.exit(1)
|
|
251
|
+
|
|
252
|
+
# ------------------------------------------------------------------
|
|
253
|
+
# Pull
|
|
254
|
+
# ------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
# ------------------------------------------------------------------
|
|
257
|
+
# Pull helpers
|
|
258
|
+
# ------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
# ===========================================================================
|
|
261
|
+
# pull — server is always source of truth
|
|
262
|
+
# ===========================================================================
|
|
263
|
+
|
|
264
|
+
def pull(self) -> None:
|
|
265
|
+
"""
|
|
266
|
+
Download every file from the remote workspace that is missing or
|
|
267
|
+
out-of-date locally.
|
|
268
|
+
|
|
269
|
+
"Missing" means either:
|
|
270
|
+
- the file is not in the local manifest at all (first pull after join), OR
|
|
271
|
+
- the file IS in the manifest but the physical file is gone from disk.
|
|
272
|
+
|
|
273
|
+
"Out-of-date" means:
|
|
274
|
+
- the server has a higher version number.
|
|
275
|
+
|
|
276
|
+
Both conditions force a download so the local directory always matches
|
|
277
|
+
the server without any user intervention.
|
|
278
|
+
"""
|
|
279
|
+
self._require_workspace()
|
|
280
|
+
|
|
281
|
+
# ── 1. Fetch server state ─────────────────────────────────────────
|
|
282
|
+
with console.status("[blue]Fetching workspace state…[/blue]", spinner="dots"):
|
|
283
|
+
try:
|
|
284
|
+
resp = self._get(
|
|
285
|
+
f"{self.base_url}/sync/state/{self.token}",
|
|
286
|
+
)
|
|
287
|
+
self._raise_for_status(resp)
|
|
288
|
+
except (HTTPError, requests.ConnectionError) as exc:
|
|
289
|
+
console.print(
|
|
290
|
+
Panel(str(exc),
|
|
291
|
+
title="[bold red]✗ Pull failed[/bold red]",
|
|
292
|
+
border_style="red", padding=(1, 2))
|
|
293
|
+
)
|
|
294
|
+
sys.exit(1)
|
|
295
|
+
|
|
296
|
+
remote_files: list[dict] = resp.json().get("files", [])
|
|
297
|
+
if not remote_files:
|
|
298
|
+
console.print("[dim]Remote workspace is empty — nothing to pull.[/dim]")
|
|
299
|
+
return
|
|
300
|
+
|
|
301
|
+
# ── 2. Decide what to download ────────────────────────────────────
|
|
302
|
+
manifest = load_manifest()
|
|
303
|
+
cwd = Path(os.getcwd())
|
|
304
|
+
|
|
305
|
+
to_download: list[dict] = []
|
|
306
|
+
for rf in remote_files:
|
|
307
|
+
fp = rf["file_path"]
|
|
308
|
+
remote_ver = rf["latest_version"]
|
|
309
|
+
local_ver = manifest.get(fp, {}).get("version", 0)
|
|
310
|
+
file_on_disk = (cwd / fp).exists()
|
|
311
|
+
|
|
312
|
+
if not file_on_disk or local_ver < remote_ver:
|
|
313
|
+
to_download.append(rf)
|
|
314
|
+
|
|
315
|
+
if not to_download:
|
|
316
|
+
console.print(
|
|
317
|
+
Panel(
|
|
318
|
+
f"All {len(remote_files)} file(s) are up to date.",
|
|
319
|
+
title="[bold green]✓ Already in sync[/bold green]",
|
|
320
|
+
border_style="green", padding=(0, 2),
|
|
321
|
+
)
|
|
322
|
+
)
|
|
323
|
+
return
|
|
324
|
+
|
|
325
|
+
# ── 3. Download each file ─────────────────────────────────────────
|
|
326
|
+
updated: list[str] = []
|
|
327
|
+
errors: list[str] = []
|
|
328
|
+
|
|
329
|
+
progress = Progress(
|
|
330
|
+
TextColumn("[bold blue]{task.fields[filename]}"),
|
|
331
|
+
BarColumn(), DownloadColumn(),
|
|
332
|
+
TransferSpeedColumn(), TimeRemainingColumn(),
|
|
333
|
+
console=console,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
with progress:
|
|
337
|
+
for rf in to_download:
|
|
338
|
+
fp = rf["file_path"]
|
|
339
|
+
version = rf["latest_version"]
|
|
340
|
+
checksum = rf.get("latest_checksum") or ""
|
|
341
|
+
size = rf.get("size_bytes") or 0
|
|
342
|
+
|
|
343
|
+
task_id = progress.add_task(
|
|
344
|
+
"dl", filename=fp,
|
|
345
|
+
total=size if size > 0 else None,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
# Request presigned GET URL
|
|
349
|
+
try:
|
|
350
|
+
dl = self._get(
|
|
351
|
+
f"{self.base_url}/sync/download-request",
|
|
352
|
+
params={"workspace_token": self.token, "file_path": fp},
|
|
353
|
+
)
|
|
354
|
+
self._raise_for_status(dl)
|
|
355
|
+
except (HTTPError, requests.ConnectionError) as exc:
|
|
356
|
+
errors.append(f"{fp}: {exc}")
|
|
357
|
+
progress.update(task_id, visible=False)
|
|
358
|
+
continue
|
|
359
|
+
|
|
360
|
+
presigned_url = dl.json()["presigned_url"]
|
|
361
|
+
|
|
362
|
+
# Stream to vault
|
|
363
|
+
vault_path = local_file_path(self.workspace_name, fp)
|
|
364
|
+
vault_path.parent.mkdir(parents=True, exist_ok=True)
|
|
365
|
+
try:
|
|
366
|
+
with requests.get(presigned_url, stream=True, timeout=120) as r:
|
|
367
|
+
self._raise_for_status(r)
|
|
368
|
+
with open(vault_path, "wb") as fh:
|
|
369
|
+
for chunk in r.iter_content(65_536):
|
|
370
|
+
fh.write(chunk)
|
|
371
|
+
progress.update(task_id, advance=len(chunk))
|
|
372
|
+
except Exception as exc:
|
|
373
|
+
errors.append(f"{fp}: {exc}")
|
|
374
|
+
progress.update(task_id, visible=False)
|
|
375
|
+
continue
|
|
376
|
+
|
|
377
|
+
# Verify checksum
|
|
378
|
+
if checksum and sha256_file(vault_path) != checksum:
|
|
379
|
+
vault_path.unlink(missing_ok=True)
|
|
380
|
+
errors.append(f"{fp}: checksum mismatch — file discarded, try again")
|
|
381
|
+
progress.update(task_id, visible=False)
|
|
382
|
+
continue
|
|
383
|
+
|
|
384
|
+
# Write to working directory
|
|
385
|
+
dest = cwd / fp
|
|
386
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
387
|
+
shutil.copy2(vault_path, dest)
|
|
388
|
+
|
|
389
|
+
update_manifest_entry(fp, version, checksum)
|
|
390
|
+
updated.append(fp)
|
|
391
|
+
|
|
392
|
+
# ── 4. Report ─────────────────────────────────────────────────────
|
|
393
|
+
if updated:
|
|
394
|
+
console.print(
|
|
395
|
+
Panel(
|
|
396
|
+
"\n".join(f" [green]✓[/green] {f}" for f in updated),
|
|
397
|
+
title=f"[bold green]✓ Pulled {len(updated)} file(s)[/bold green]",
|
|
398
|
+
border_style="green", padding=(1, 2),
|
|
399
|
+
)
|
|
400
|
+
)
|
|
401
|
+
if errors:
|
|
402
|
+
console.print(
|
|
403
|
+
Panel(
|
|
404
|
+
"\n".join(f" [red]✗[/red] {e}" for e in errors),
|
|
405
|
+
title="[bold red]✗ Some files failed[/bold red]",
|
|
406
|
+
border_style="red", padding=(1, 2),
|
|
407
|
+
)
|
|
408
|
+
)
|
|
409
|
+
sys.exit(1)
|
|
410
|
+
|
|
411
|
+
# ===========================================================================
|
|
412
|
+
# push — two-phase upload with OCC conflict detection
|
|
413
|
+
# ===========================================================================
|
|
414
|
+
|
|
415
|
+
def push(self, file_path: str) -> None:
|
|
416
|
+
"""
|
|
417
|
+
Upload a local file to the remote workspace.
|
|
418
|
+
|
|
419
|
+
Uses Optimistic Concurrency Control (OCC): the client sends its
|
|
420
|
+
`base_version` (last known server version). If the server's current
|
|
421
|
+
version is higher, it returns HTTP 409 — the user must pull first.
|
|
422
|
+
"""
|
|
423
|
+
self._require_workspace()
|
|
424
|
+
|
|
425
|
+
local_path = Path(file_path)
|
|
426
|
+
if not local_path.exists():
|
|
427
|
+
console.print(
|
|
428
|
+
Panel(f"[bold]{file_path}[/bold] does not exist.",
|
|
429
|
+
title="[bold red]✗ File not found[/bold red]",
|
|
430
|
+
border_style="red", padding=(1, 2))
|
|
431
|
+
)
|
|
432
|
+
sys.exit(1)
|
|
433
|
+
|
|
434
|
+
with console.status(f"[blue]Hashing [bold]{file_path}[/bold]…[/blue]", spinner="dots"):
|
|
435
|
+
checksum = sha256_file(local_path)
|
|
436
|
+
size = local_path.stat().st_size
|
|
437
|
+
|
|
438
|
+
manifest = load_manifest()
|
|
439
|
+
base_version = manifest.get(file_path, {}).get("version", 0)
|
|
440
|
+
|
|
441
|
+
# ── Step 1: request upload slot ───────────────────────────────────
|
|
442
|
+
with console.status("[blue]Requesting upload slot…[/blue]", spinner="dots"):
|
|
443
|
+
try:
|
|
444
|
+
req = self._post(
|
|
445
|
+
f"{self.base_url}/sync/upload-request",
|
|
446
|
+
json={
|
|
447
|
+
"workspace_token": self.token,
|
|
448
|
+
"file_path": file_path,
|
|
449
|
+
"checksum": checksum,
|
|
450
|
+
"size_bytes": size,
|
|
451
|
+
"base_version": base_version,
|
|
452
|
+
},
|
|
453
|
+
)
|
|
454
|
+
self._raise_for_status(req)
|
|
455
|
+
|
|
456
|
+
except HTTPError as exc:
|
|
457
|
+
if exc.response is not None and exc.response.status_code == 409:
|
|
458
|
+
# ── OCC conflict: server has a newer version ──────────
|
|
459
|
+
try:
|
|
460
|
+
remote_ver = exc.response.json().get("detail", "")
|
|
461
|
+
except Exception:
|
|
462
|
+
remote_ver = ""
|
|
463
|
+
console.print(
|
|
464
|
+
Panel(
|
|
465
|
+
f"[bold]{file_path}[/bold] has been updated on the server "
|
|
466
|
+
f"since your last pull.\n\n"
|
|
467
|
+
f"Your local base version : [cyan]{base_version}[/cyan]\n"
|
|
468
|
+
f"Run [cyan]study pull[/cyan] to get the latest version, "
|
|
469
|
+
f"then push again.",
|
|
470
|
+
title="[bold red]✗ Conflict — pull required[/bold red]",
|
|
471
|
+
border_style="red", padding=(1, 2),
|
|
472
|
+
)
|
|
473
|
+
)
|
|
474
|
+
else:
|
|
475
|
+
console.print(
|
|
476
|
+
Panel(str(exc),
|
|
477
|
+
title="[bold red]✗ Upload request failed[/bold red]",
|
|
478
|
+
border_style="red", padding=(1, 2))
|
|
479
|
+
)
|
|
480
|
+
sys.exit(1)
|
|
481
|
+
except requests.ConnectionError as exc:
|
|
482
|
+
console.print(
|
|
483
|
+
Panel(str(exc),
|
|
484
|
+
title="[bold red]✗ Connection error[/bold red]",
|
|
485
|
+
border_style="red", padding=(1, 2))
|
|
486
|
+
)
|
|
487
|
+
sys.exit(1)
|
|
488
|
+
|
|
489
|
+
data = req.json()
|
|
490
|
+
presigned_url = data["presigned_url"]
|
|
491
|
+
upload_id = data["upload_id"]
|
|
492
|
+
new_version = data["new_version"]
|
|
493
|
+
|
|
494
|
+
# ── Step 2: PUT bytes to mock-S3 ─────────────────────────────────
|
|
495
|
+
progress = Progress(
|
|
496
|
+
TextColumn("[bold blue]{task.fields[filename]}"),
|
|
497
|
+
BarColumn(), DownloadColumn(),
|
|
498
|
+
TransferSpeedColumn(), TimeRemainingColumn(),
|
|
499
|
+
console=console,
|
|
500
|
+
)
|
|
501
|
+
with progress:
|
|
502
|
+
task_id = progress.add_task("upload", filename=local_path.name, total=size)
|
|
503
|
+
with _ProgressReader(local_path, task_id, progress) as reader:
|
|
504
|
+
try:
|
|
505
|
+
put = requests.put(
|
|
506
|
+
presigned_url,
|
|
507
|
+
data=reader,
|
|
508
|
+
headers={"Content-Length": str(size)},
|
|
509
|
+
timeout=300,
|
|
510
|
+
)
|
|
511
|
+
put.raise_for_status()
|
|
512
|
+
except Exception as exc:
|
|
513
|
+
console.print(
|
|
514
|
+
Panel(str(exc),
|
|
515
|
+
title="[bold red]✗ Upload failed[/bold red]",
|
|
516
|
+
border_style="red", padding=(1, 2))
|
|
517
|
+
)
|
|
518
|
+
sys.exit(1)
|
|
519
|
+
|
|
520
|
+
# ── Step 3: commit ────────────────────────────────────────────────
|
|
521
|
+
with console.status("[blue]Committing…[/blue]", spinner="dots"):
|
|
522
|
+
try:
|
|
523
|
+
commit = self._post(
|
|
524
|
+
f"{self.base_url}/sync/commit-upload",
|
|
525
|
+
json={"upload_id": upload_id},
|
|
526
|
+
)
|
|
527
|
+
self._raise_for_status(commit)
|
|
528
|
+
except (HTTPError, requests.ConnectionError) as exc:
|
|
529
|
+
console.print(
|
|
530
|
+
Panel(str(exc),
|
|
531
|
+
title="[bold red]✗ Commit failed[/bold red]",
|
|
532
|
+
border_style="red", padding=(1, 2))
|
|
533
|
+
)
|
|
534
|
+
sys.exit(1)
|
|
535
|
+
|
|
536
|
+
# Update vault + manifest
|
|
537
|
+
vault_path = local_file_path(self.workspace_name, file_path)
|
|
538
|
+
vault_path.parent.mkdir(parents=True, exist_ok=True)
|
|
539
|
+
shutil.copy2(local_path, vault_path)
|
|
540
|
+
update_manifest_entry(file_path, new_version, checksum)
|
|
541
|
+
|
|
542
|
+
console.print(
|
|
543
|
+
Panel(
|
|
544
|
+
f"[bold]{file_path}[/bold]\n"
|
|
545
|
+
f"Version [cyan]v{new_version}[/cyan] "
|
|
546
|
+
f"SHA-256 [dim]{checksum[:16]}…[/dim] "
|
|
547
|
+
f"Size [dim]{size:,} B[/dim]",
|
|
548
|
+
title="[bold green]✓ Push complete[/bold green]",
|
|
549
|
+
border_style="green", padding=(1, 2),
|
|
550
|
+
)
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
# ===========================================================================
|
|
554
|
+
# status — shows both local and server state
|
|
555
|
+
# ===========================================================================
|
|
556
|
+
|
|
557
|
+
def status(self) -> None:
|
|
558
|
+
"""
|
|
559
|
+
Compare the remote workspace against local state and display a table.
|
|
560
|
+
|
|
561
|
+
Fetches the server's file list so that files pushed by other users
|
|
562
|
+
show up immediately — even before a pull.
|
|
563
|
+
|
|
564
|
+
Status values
|
|
565
|
+
-------------
|
|
566
|
+
SYNCED on disk and matching the server version
|
|
567
|
+
MODIFIED on disk but changed since the last push/pull
|
|
568
|
+
NOT PULLED exists on server, not yet downloaded
|
|
569
|
+
MISSING in manifest but physically deleted from disk
|
|
570
|
+
LOCAL on disk but never pushed (untracked)
|
|
571
|
+
"""
|
|
572
|
+
self._require_workspace()
|
|
573
|
+
|
|
574
|
+
# Fetch server state so we can show files pushed by other users
|
|
575
|
+
with console.status("[blue]Fetching server state…[/blue]", spinner="dots"):
|
|
576
|
+
try:
|
|
577
|
+
resp = self._get(
|
|
578
|
+
f"{self.base_url}/sync/state/{self.token}",
|
|
579
|
+
)
|
|
580
|
+
self._raise_for_status(resp)
|
|
581
|
+
remote_files = {
|
|
582
|
+
f["file_path"]: f
|
|
583
|
+
for f in resp.json().get("files", [])
|
|
584
|
+
}
|
|
585
|
+
except (HTTPError, requests.ConnectionError):
|
|
586
|
+
remote_files = {}
|
|
587
|
+
console.print(
|
|
588
|
+
"[yellow]⚠ Could not reach server — showing local state only.[/yellow]"
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
manifest = load_manifest()
|
|
592
|
+
cwd = Path(os.getcwd())
|
|
593
|
+
|
|
594
|
+
# Collect every file path we know about from any source
|
|
595
|
+
all_paths: set[str] = set()
|
|
596
|
+
all_paths.update(remote_files.keys())
|
|
597
|
+
all_paths.update(manifest.keys())
|
|
598
|
+
# Add untracked files in cwd (skip hidden directories)
|
|
599
|
+
for p in cwd.rglob("*"):
|
|
600
|
+
if p.is_file() and not any(part.startswith(".") for part in p.parts):
|
|
601
|
+
all_paths.add(str(p.relative_to(cwd)).replace("\\", "/"))
|
|
602
|
+
|
|
603
|
+
if not all_paths:
|
|
604
|
+
console.print(
|
|
605
|
+
"[dim]No files yet. Run [cyan]study push <file>[/cyan] to get started.[/dim]"
|
|
606
|
+
)
|
|
607
|
+
return
|
|
608
|
+
|
|
609
|
+
table = Table(
|
|
610
|
+
title=f"Workspace: [bold]{self.workspace_name}[/bold]",
|
|
611
|
+
show_lines=False, show_header=True,
|
|
612
|
+
)
|
|
613
|
+
table.add_column("File", style="bold", no_wrap=True)
|
|
614
|
+
table.add_column("Status", justify="center")
|
|
615
|
+
table.add_column("Local ver", justify="right", style="dim")
|
|
616
|
+
table.add_column("Server ver", justify="right", style="dim")
|
|
617
|
+
|
|
618
|
+
for fp in sorted(all_paths):
|
|
619
|
+
local_entry = manifest.get(fp, {})
|
|
620
|
+
local_ver = local_entry.get("version", 0)
|
|
621
|
+
local_cksum = local_entry.get("checksum", "")
|
|
622
|
+
remote_info = remote_files.get(fp)
|
|
623
|
+
remote_ver = remote_info["latest_version"] if remote_info else None
|
|
624
|
+
file_on_disk = (cwd / fp).exists()
|
|
625
|
+
|
|
626
|
+
if remote_info and not file_on_disk and not local_entry:
|
|
627
|
+
# Other user pushed this; we've never seen it
|
|
628
|
+
status_cell = "[cyan]NOT PULLED[/cyan]"
|
|
629
|
+
l_ver_str = "—"
|
|
630
|
+
r_ver_str = f"v{remote_ver}"
|
|
631
|
+
|
|
632
|
+
elif remote_info and not file_on_disk and local_entry:
|
|
633
|
+
# Was here, now gone from disk
|
|
634
|
+
status_cell = "[red]MISSING[/red]"
|
|
635
|
+
l_ver_str = f"v{local_ver}"
|
|
636
|
+
r_ver_str = f"v{remote_ver}"
|
|
637
|
+
|
|
638
|
+
elif remote_info and file_on_disk:
|
|
639
|
+
disk_cksum = sha256_file(cwd / fp)
|
|
640
|
+
if disk_cksum == remote_info.get("latest_checksum", ""):
|
|
641
|
+
status_cell = "[green]SYNCED[/green]"
|
|
642
|
+
elif disk_cksum == local_cksum:
|
|
643
|
+
# Disk matches our last push but server moved ahead
|
|
644
|
+
status_cell = "[cyan]NOT PULLED[/cyan]"
|
|
645
|
+
else:
|
|
646
|
+
status_cell = "[yellow]MODIFIED[/yellow]"
|
|
647
|
+
l_ver_str = f"v{local_ver}" if local_ver else "—"
|
|
648
|
+
r_ver_str = f"v{remote_ver}"
|
|
649
|
+
|
|
650
|
+
elif not remote_info and file_on_disk:
|
|
651
|
+
# Local file not known to server
|
|
652
|
+
status_cell = "[dim]LOCAL[/dim]"
|
|
653
|
+
l_ver_str = "—"
|
|
654
|
+
r_ver_str = "—"
|
|
655
|
+
|
|
656
|
+
else:
|
|
657
|
+
continue # ghost entry, skip
|
|
658
|
+
|
|
659
|
+
table.add_row(fp, status_cell, l_ver_str, r_ver_str)
|
|
660
|
+
|
|
661
|
+
console.print(table)
|
|
662
|
+
console.print(
|
|
663
|
+
"[dim]"
|
|
664
|
+
"[green]SYNCED[/green]=up to date "
|
|
665
|
+
"[yellow]MODIFIED[/yellow]=local changes "
|
|
666
|
+
"[cyan]NOT PULLED[/cyan]=pull to get "
|
|
667
|
+
"[red]MISSING[/red]=deleted locally "
|
|
668
|
+
"[dim]LOCAL[/dim]=not yet pushed"
|
|
669
|
+
"[/dim]"
|
|
670
|
+
)
|
|
@@ -1,587 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
sync_engine.py — Core networking and sync logic for StudySync CLI.
|
|
3
|
-
|
|
4
|
-
Two-phase upload
|
|
5
|
-
----------------
|
|
6
|
-
1. POST /upload-request → {presigned_put_url, pending_upload_id}
|
|
7
|
-
2. PUT presigned_put_url (raw bytes)
|
|
8
|
-
3. POST /commit-upload → updated file record
|
|
9
|
-
|
|
10
|
-
Optimistic concurrency control
|
|
11
|
-
-------------------------------
|
|
12
|
-
Every push includes base_version. If that doesn't match the server's
|
|
13
|
-
latest_version the server returns HTTP 409. The client must pull first.
|
|
14
|
-
|
|
15
|
-
Alias resolution
|
|
16
|
-
-----------------
|
|
17
|
-
Users can join with a short workspace alias (name) instead of a raw UUID.
|
|
18
|
-
SyncEngine.resolve_alias() calls GET /alias/{alias} on the backend, which
|
|
19
|
-
returns the workspace's access_token.
|
|
20
|
-
"""
|
|
21
|
-
|
|
22
|
-
from __future__ import annotations
|
|
23
|
-
|
|
24
|
-
import os
|
|
25
|
-
import shutil
|
|
26
|
-
import sys
|
|
27
|
-
from pathlib import Path
|
|
28
|
-
from typing import Any
|
|
29
|
-
|
|
30
|
-
import requests
|
|
31
|
-
from requests import HTTPError, Session
|
|
32
|
-
from rich.console import Console
|
|
33
|
-
from rich.panel import Panel
|
|
34
|
-
from rich.progress import (
|
|
35
|
-
BarColumn,
|
|
36
|
-
DownloadColumn,
|
|
37
|
-
Progress,
|
|
38
|
-
TextColumn,
|
|
39
|
-
TimeRemainingColumn,
|
|
40
|
-
TransferSpeedColumn,
|
|
41
|
-
)
|
|
42
|
-
from rich.table import Table
|
|
43
|
-
|
|
44
|
-
from .constants import PRODUCTION_SERVER_URL
|
|
45
|
-
from .local_state import (
|
|
46
|
-
all_local_files,
|
|
47
|
-
ensure_dirs,
|
|
48
|
-
load_config,
|
|
49
|
-
load_manifest,
|
|
50
|
-
local_file_path,
|
|
51
|
-
save_manifest,
|
|
52
|
-
sha256_file,
|
|
53
|
-
update_manifest_entry,
|
|
54
|
-
workspace_root,
|
|
55
|
-
WORKSPACES_DIR,
|
|
56
|
-
)
|
|
57
|
-
|
|
58
|
-
console = Console()
|
|
59
|
-
|
|
60
|
-
# ---------------------------------------------------------------------------
|
|
61
|
-
# Progress-aware file reader
|
|
62
|
-
# ---------------------------------------------------------------------------
|
|
63
|
-
|
|
64
|
-
class _ProgressReader:
|
|
65
|
-
"""Wrap a file so requests streams it while updating a Rich progress bar."""
|
|
66
|
-
|
|
67
|
-
def __init__(self, path: Path, task_id: Any, progress: Progress) -> None:
|
|
68
|
-
self._fh = open(path, "rb")
|
|
69
|
-
self._task_id = task_id
|
|
70
|
-
self._progress = progress
|
|
71
|
-
self._size = path.stat().st_size
|
|
72
|
-
|
|
73
|
-
def read(self, size: int = -1) -> bytes:
|
|
74
|
-
chunk = self._fh.read(size)
|
|
75
|
-
if chunk:
|
|
76
|
-
self._progress.update(self._task_id, advance=len(chunk))
|
|
77
|
-
return chunk
|
|
78
|
-
|
|
79
|
-
def __len__(self) -> int:
|
|
80
|
-
return self._size
|
|
81
|
-
|
|
82
|
-
def close(self) -> None:
|
|
83
|
-
self._fh.close()
|
|
84
|
-
|
|
85
|
-
def __enter__(self) -> "_ProgressReader":
|
|
86
|
-
return self
|
|
87
|
-
|
|
88
|
-
def __exit__(self, *_: Any) -> None:
|
|
89
|
-
self.close()
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
# ---------------------------------------------------------------------------
|
|
93
|
-
# SyncEngine
|
|
94
|
-
# ---------------------------------------------------------------------------
|
|
95
|
-
|
|
96
|
-
class SyncEngine:
|
|
97
|
-
"""Handles all communication with the StudySync backend."""
|
|
98
|
-
|
|
99
|
-
def __init__(self, server_url: str | None = None) -> None:
|
|
100
|
-
ensure_dirs()
|
|
101
|
-
config = load_config()
|
|
102
|
-
raw_url = server_url or config.get("server_url", PRODUCTION_SERVER_URL)
|
|
103
|
-
self.base_url: str = raw_url.rstrip("/")
|
|
104
|
-
self.workspace_id: str = config.get("workspace_id", "")
|
|
105
|
-
self.workspace_name: str = config.get("workspace_name", "")
|
|
106
|
-
self.token: str = config.get("workspace_token", "")
|
|
107
|
-
|
|
108
|
-
self.session: Session = requests.Session()
|
|
109
|
-
if self.token:
|
|
110
|
-
self.session.headers["Authorization"] = f"Bearer {self.token}"
|
|
111
|
-
self.session.headers["User-Agent"] = "studysync-cli/1.0"
|
|
112
|
-
|
|
113
|
-
# ------------------------------------------------------------------
|
|
114
|
-
# Internal helpers
|
|
115
|
-
# ------------------------------------------------------------------
|
|
116
|
-
|
|
117
|
-
def _raise_for_status(self, resp: requests.Response) -> None:
|
|
118
|
-
"""Raise HTTPError with the backend's JSON detail if present."""
|
|
119
|
-
if resp.ok:
|
|
120
|
-
return
|
|
121
|
-
try:
|
|
122
|
-
detail = resp.json().get("detail", resp.text)
|
|
123
|
-
except Exception:
|
|
124
|
-
detail = resp.text
|
|
125
|
-
raise HTTPError(
|
|
126
|
-
f"HTTP {resp.status_code}: {detail}",
|
|
127
|
-
response=resp,
|
|
128
|
-
)
|
|
129
|
-
|
|
130
|
-
def _require_workspace(self) -> None:
|
|
131
|
-
"""Abort with a friendly message if no workspace is configured."""
|
|
132
|
-
if not self.workspace_id or not self.token:
|
|
133
|
-
console.print(
|
|
134
|
-
Panel(
|
|
135
|
-
"No workspace is configured.\n\n"
|
|
136
|
-
"Create one: [cyan]study workspace create <name>[/cyan]\n"
|
|
137
|
-
"Or join one: [cyan]study join <token-or-alias>[/cyan]",
|
|
138
|
-
title="[bold red]✗ Not configured[/bold red]",
|
|
139
|
-
border_style="red",
|
|
140
|
-
padding=(1, 2),
|
|
141
|
-
)
|
|
142
|
-
)
|
|
143
|
-
sys.exit(1)
|
|
144
|
-
|
|
145
|
-
# ------------------------------------------------------------------
|
|
146
|
-
# Workspace management
|
|
147
|
-
# ------------------------------------------------------------------
|
|
148
|
-
|
|
149
|
-
def create_workspace(self, name: str) -> dict[str, Any]:
|
|
150
|
-
"""POST /workspaces — create a new workspace; returns {workspace_id, access_token, name}."""
|
|
151
|
-
resp = self.session.post(
|
|
152
|
-
f"{self.base_url}/workspaces",
|
|
153
|
-
json={"name": name},
|
|
154
|
-
timeout=30,
|
|
155
|
-
)
|
|
156
|
-
self._raise_for_status(resp)
|
|
157
|
-
data = resp.json()
|
|
158
|
-
return {
|
|
159
|
-
"workspace_id": data["workspace_id"],
|
|
160
|
-
"access_token": data["access_token"],
|
|
161
|
-
"name": data["name"],
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
def join_workspace(self, token: str) -> dict[str, Any]:
|
|
165
|
-
"""GET /workspaces/{token} — validate token; returns {workspace_id, name}."""
|
|
166
|
-
resp = self.session.get(
|
|
167
|
-
f"{self.base_url}/workspaces/{token}",
|
|
168
|
-
timeout=30,
|
|
169
|
-
)
|
|
170
|
-
self._raise_for_status(resp)
|
|
171
|
-
data = resp.json()
|
|
172
|
-
return {
|
|
173
|
-
"workspace_id": data["workspace_id"],
|
|
174
|
-
"name": data["name"],
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
def resolve_input(self, token_or_alias: str) -> dict:
|
|
178
|
-
"""
|
|
179
|
-
GET /resolve/{input} — resolve a token or alias to workspace info.
|
|
180
|
-
|
|
181
|
-
The backend checks its Aliases table first. If the input is not a
|
|
182
|
-
known alias it tries to interpret it as a raw UUID access_token.
|
|
183
|
-
|
|
184
|
-
Returns
|
|
185
|
-
-------
|
|
186
|
-
dict
|
|
187
|
-
Keys: ``resolved_from_alias`` (bool), ``alias`` (str | None),
|
|
188
|
-
``workspace_id``, ``name``, ``access_token``.
|
|
189
|
-
|
|
190
|
-
Raises
|
|
191
|
-
------
|
|
192
|
-
SystemExit
|
|
193
|
-
Prints a red error panel and exits 1 on HTTP 404 or connection
|
|
194
|
-
failure.
|
|
195
|
-
"""
|
|
196
|
-
try:
|
|
197
|
-
resp = self.session.get(
|
|
198
|
-
f"{self.base_url}/resolve/{token_or_alias}",
|
|
199
|
-
timeout=15,
|
|
200
|
-
)
|
|
201
|
-
self._raise_for_status(resp)
|
|
202
|
-
return resp.json()
|
|
203
|
-
except HTTPError as exc:
|
|
204
|
-
code = exc.response.status_code if exc.response is not None else "?"
|
|
205
|
-
try:
|
|
206
|
-
detail = exc.response.json().get("detail", str(exc))
|
|
207
|
-
except Exception:
|
|
208
|
-
detail = str(exc)
|
|
209
|
-
console.print(
|
|
210
|
-
Panel(
|
|
211
|
-
f"{detail}",
|
|
212
|
-
title=f"[bold red]✗ Could not resolve '{token_or_alias}'[/bold red]",
|
|
213
|
-
border_style="red",
|
|
214
|
-
padding=(1, 2),
|
|
215
|
-
)
|
|
216
|
-
)
|
|
217
|
-
sys.exit(1)
|
|
218
|
-
except requests.ConnectionError:
|
|
219
|
-
console.print(
|
|
220
|
-
Panel(
|
|
221
|
-
f"Could not reach [cyan]{self.base_url}[/cyan].\n"
|
|
222
|
-
"Check your network connection or --server URL.",
|
|
223
|
-
title="[bold red]✗ Connection error[/bold red]",
|
|
224
|
-
border_style="red",
|
|
225
|
-
padding=(1, 2),
|
|
226
|
-
)
|
|
227
|
-
)
|
|
228
|
-
sys.exit(1)
|
|
229
|
-
|
|
230
|
-
# ------------------------------------------------------------------
|
|
231
|
-
# Pull
|
|
232
|
-
# ------------------------------------------------------------------
|
|
233
|
-
|
|
234
|
-
def pull(self) -> None:
|
|
235
|
-
"""Download all new/changed files from the remote workspace."""
|
|
236
|
-
self._require_workspace()
|
|
237
|
-
|
|
238
|
-
# 1. Fetch remote state
|
|
239
|
-
with console.status("[blue]Fetching remote file list…[/blue]", spinner="dots"):
|
|
240
|
-
try:
|
|
241
|
-
resp = self.session.get(
|
|
242
|
-
f"{self.base_url}/workspaces/{self.token}/state",
|
|
243
|
-
timeout=30,
|
|
244
|
-
)
|
|
245
|
-
self._raise_for_status(resp)
|
|
246
|
-
except (HTTPError, requests.ConnectionError) as exc:
|
|
247
|
-
console.print(
|
|
248
|
-
Panel(
|
|
249
|
-
str(exc),
|
|
250
|
-
title="[bold red]✗ Pull failed[/bold red]",
|
|
251
|
-
border_style="red",
|
|
252
|
-
padding=(1, 2),
|
|
253
|
-
)
|
|
254
|
-
)
|
|
255
|
-
sys.exit(1)
|
|
256
|
-
|
|
257
|
-
remote_files: list[dict] = resp.json().get("files", [])
|
|
258
|
-
manifest = load_manifest()
|
|
259
|
-
|
|
260
|
-
if not remote_files:
|
|
261
|
-
console.print("[dim]No files in remote workspace.[/dim]")
|
|
262
|
-
return
|
|
263
|
-
|
|
264
|
-
# 2. Determine what to download
|
|
265
|
-
to_download = []
|
|
266
|
-
for rf in remote_files:
|
|
267
|
-
fp = rf["file_path"]
|
|
268
|
-
local_entry = manifest.get(fp, {})
|
|
269
|
-
if local_entry.get("version", -1) < rf["latest_version"]:
|
|
270
|
-
to_download.append(rf)
|
|
271
|
-
|
|
272
|
-
if not to_download:
|
|
273
|
-
console.print(
|
|
274
|
-
Panel(
|
|
275
|
-
"Everything is up to date — no files to download.",
|
|
276
|
-
title="[bold green]✓ Already in sync[/bold green]",
|
|
277
|
-
border_style="green",
|
|
278
|
-
padding=(0, 2),
|
|
279
|
-
)
|
|
280
|
-
)
|
|
281
|
-
return
|
|
282
|
-
|
|
283
|
-
# 3. Download each file with a progress bar
|
|
284
|
-
cwd = Path(os.getcwd())
|
|
285
|
-
updated: list[str] = []
|
|
286
|
-
errors: list[str] = []
|
|
287
|
-
|
|
288
|
-
progress = Progress(
|
|
289
|
-
TextColumn("[bold blue]{task.fields[filename]}"),
|
|
290
|
-
BarColumn(),
|
|
291
|
-
DownloadColumn(),
|
|
292
|
-
TransferSpeedColumn(),
|
|
293
|
-
TimeRemainingColumn(),
|
|
294
|
-
console=console,
|
|
295
|
-
)
|
|
296
|
-
|
|
297
|
-
with progress:
|
|
298
|
-
for rf in to_download:
|
|
299
|
-
fp: str = rf["file_path"]
|
|
300
|
-
version: int = rf["latest_version"]
|
|
301
|
-
checksum: str = rf["latest_checksum"]
|
|
302
|
-
size: int = rf.get("size_bytes", 0)
|
|
303
|
-
|
|
304
|
-
task_id = progress.add_task(
|
|
305
|
-
"download",
|
|
306
|
-
filename=fp,
|
|
307
|
-
total=size if size > 0 else None,
|
|
308
|
-
)
|
|
309
|
-
|
|
310
|
-
# Request a presigned GET URL
|
|
311
|
-
try:
|
|
312
|
-
dl_resp = self.session.post(
|
|
313
|
-
f"{self.base_url}/download-request",
|
|
314
|
-
json={"file_path": fp, "workspace_id": self.workspace_id},
|
|
315
|
-
headers={"Authorization": f"Bearer {self.token}"},
|
|
316
|
-
timeout=30,
|
|
317
|
-
)
|
|
318
|
-
self._raise_for_status(dl_resp)
|
|
319
|
-
except (HTTPError, requests.ConnectionError) as exc:
|
|
320
|
-
errors.append(f"{fp}: {exc}")
|
|
321
|
-
progress.update(task_id, visible=False)
|
|
322
|
-
continue
|
|
323
|
-
|
|
324
|
-
presigned_get = dl_resp.json()["presigned_get_url"]
|
|
325
|
-
|
|
326
|
-
# Stream the file to the vault
|
|
327
|
-
vault_path = local_file_path(self.workspace_name, fp)
|
|
328
|
-
vault_path.parent.mkdir(parents=True, exist_ok=True)
|
|
329
|
-
|
|
330
|
-
try:
|
|
331
|
-
with self.session.get(presigned_get, stream=True, timeout=120) as dr:
|
|
332
|
-
self._raise_for_status(dr)
|
|
333
|
-
with open(vault_path, "wb") as out:
|
|
334
|
-
for chunk in dr.iter_content(chunk_size=65_536):
|
|
335
|
-
out.write(chunk)
|
|
336
|
-
progress.update(task_id, advance=len(chunk))
|
|
337
|
-
except Exception as exc:
|
|
338
|
-
errors.append(f"{fp}: {exc}")
|
|
339
|
-
progress.update(task_id, visible=False)
|
|
340
|
-
continue
|
|
341
|
-
|
|
342
|
-
# Verify SHA-256
|
|
343
|
-
actual = sha256_file(vault_path)
|
|
344
|
-
if actual != checksum:
|
|
345
|
-
errors.append(
|
|
346
|
-
f"{fp}: checksum mismatch "
|
|
347
|
-
f"(expected {checksum[:8]}…, got {actual[:8]}…)"
|
|
348
|
-
)
|
|
349
|
-
continue
|
|
350
|
-
|
|
351
|
-
# Checkout to cwd
|
|
352
|
-
checkout_dest = cwd / fp
|
|
353
|
-
checkout_dest.parent.mkdir(parents=True, exist_ok=True)
|
|
354
|
-
shutil.copy2(vault_path, checkout_dest)
|
|
355
|
-
|
|
356
|
-
update_manifest_entry(fp, version, checksum)
|
|
357
|
-
updated.append(fp)
|
|
358
|
-
|
|
359
|
-
# 4. Report
|
|
360
|
-
if updated:
|
|
361
|
-
file_list = "\n".join(f" [green]✓[/green] {f}" for f in updated)
|
|
362
|
-
console.print(
|
|
363
|
-
Panel(
|
|
364
|
-
file_list,
|
|
365
|
-
title=f"[bold green]✓ Pulled {len(updated)} file(s)[/bold green]",
|
|
366
|
-
border_style="green",
|
|
367
|
-
padding=(1, 2),
|
|
368
|
-
)
|
|
369
|
-
)
|
|
370
|
-
if errors:
|
|
371
|
-
err_list = "\n".join(f" [red]✗[/red] {e}" for e in errors)
|
|
372
|
-
console.print(
|
|
373
|
-
Panel(
|
|
374
|
-
err_list,
|
|
375
|
-
title="[bold red]✗ Some files failed[/bold red]",
|
|
376
|
-
border_style="red",
|
|
377
|
-
padding=(1, 2),
|
|
378
|
-
)
|
|
379
|
-
)
|
|
380
|
-
sys.exit(1)
|
|
381
|
-
|
|
382
|
-
# ------------------------------------------------------------------
|
|
383
|
-
# Push
|
|
384
|
-
# ------------------------------------------------------------------
|
|
385
|
-
|
|
386
|
-
def push(self, file_path: str) -> None:
|
|
387
|
-
"""Upload *file_path* to the remote workspace."""
|
|
388
|
-
self._require_workspace()
|
|
389
|
-
|
|
390
|
-
local_path = Path(file_path)
|
|
391
|
-
if not local_path.exists():
|
|
392
|
-
console.print(
|
|
393
|
-
Panel(
|
|
394
|
-
f"[bold]{file_path}[/bold] does not exist.",
|
|
395
|
-
title="[bold red]✗ File not found[/bold red]",
|
|
396
|
-
border_style="red",
|
|
397
|
-
padding=(1, 2),
|
|
398
|
-
)
|
|
399
|
-
)
|
|
400
|
-
sys.exit(1)
|
|
401
|
-
|
|
402
|
-
with console.status(f"[blue]Hashing [bold]{file_path}[/bold]…[/blue]", spinner="dots"):
|
|
403
|
-
checksum = sha256_file(local_path)
|
|
404
|
-
size = local_path.stat().st_size
|
|
405
|
-
|
|
406
|
-
manifest = load_manifest()
|
|
407
|
-
base_version = manifest.get(file_path, {}).get("version", 0)
|
|
408
|
-
|
|
409
|
-
# Step 1 — upload-request
|
|
410
|
-
with console.status("[blue]Requesting upload slot…[/blue]", spinner="dots"):
|
|
411
|
-
try:
|
|
412
|
-
req_resp = self.session.post(
|
|
413
|
-
f"{self.base_url}/upload-request",
|
|
414
|
-
json={
|
|
415
|
-
"file_path": file_path,
|
|
416
|
-
"workspace_id": self.workspace_id,
|
|
417
|
-
"checksum": checksum,
|
|
418
|
-
"size_bytes": size,
|
|
419
|
-
"base_version": base_version,
|
|
420
|
-
},
|
|
421
|
-
headers={"Authorization": f"Bearer {self.token}"},
|
|
422
|
-
timeout=30,
|
|
423
|
-
)
|
|
424
|
-
self._raise_for_status(req_resp)
|
|
425
|
-
except HTTPError as exc:
|
|
426
|
-
if exc.response is not None and exc.response.status_code == 409:
|
|
427
|
-
console.print(
|
|
428
|
-
Panel(
|
|
429
|
-
f"[bold]{file_path}[/bold] was modified on the server since your "
|
|
430
|
-
f"last pull (local base version: {base_version}).\n\n"
|
|
431
|
-
"Run [cyan]study pull[/cyan] to sync, then push again.",
|
|
432
|
-
title="[bold red]✗ Conflict (HTTP 409)[/bold red]",
|
|
433
|
-
border_style="red",
|
|
434
|
-
padding=(1, 2),
|
|
435
|
-
)
|
|
436
|
-
)
|
|
437
|
-
else:
|
|
438
|
-
console.print(
|
|
439
|
-
Panel(
|
|
440
|
-
str(exc),
|
|
441
|
-
title="[bold red]✗ Upload request failed[/bold red]",
|
|
442
|
-
border_style="red",
|
|
443
|
-
padding=(1, 2),
|
|
444
|
-
)
|
|
445
|
-
)
|
|
446
|
-
sys.exit(1)
|
|
447
|
-
except requests.ConnectionError as exc:
|
|
448
|
-
console.print(
|
|
449
|
-
Panel(
|
|
450
|
-
str(exc),
|
|
451
|
-
title="[bold red]✗ Connection error[/bold red]",
|
|
452
|
-
border_style="red",
|
|
453
|
-
padding=(1, 2),
|
|
454
|
-
)
|
|
455
|
-
)
|
|
456
|
-
sys.exit(1)
|
|
457
|
-
|
|
458
|
-
upload_data = req_resp.json()
|
|
459
|
-
presigned_put = upload_data["presigned_put_url"]
|
|
460
|
-
pending_id = upload_data["pending_upload_id"]
|
|
461
|
-
new_version = upload_data["new_version"]
|
|
462
|
-
|
|
463
|
-
# Step 2 — PUT to mock S3 with progress bar
|
|
464
|
-
progress = Progress(
|
|
465
|
-
TextColumn("[bold blue]{task.fields[filename]}"),
|
|
466
|
-
BarColumn(),
|
|
467
|
-
DownloadColumn(),
|
|
468
|
-
TransferSpeedColumn(),
|
|
469
|
-
TimeRemainingColumn(),
|
|
470
|
-
console=console,
|
|
471
|
-
)
|
|
472
|
-
|
|
473
|
-
with progress:
|
|
474
|
-
task_id = progress.add_task("upload", filename=local_path.name, total=size)
|
|
475
|
-
with _ProgressReader(local_path, task_id, progress) as reader:
|
|
476
|
-
try:
|
|
477
|
-
put_resp = requests.put(
|
|
478
|
-
presigned_put,
|
|
479
|
-
data=reader,
|
|
480
|
-
headers={"Content-Length": str(size)},
|
|
481
|
-
timeout=300,
|
|
482
|
-
)
|
|
483
|
-
put_resp.raise_for_status()
|
|
484
|
-
except Exception as exc:
|
|
485
|
-
console.print(
|
|
486
|
-
Panel(
|
|
487
|
-
str(exc),
|
|
488
|
-
title="[bold red]✗ Upload failed[/bold red]",
|
|
489
|
-
border_style="red",
|
|
490
|
-
padding=(1, 2),
|
|
491
|
-
)
|
|
492
|
-
)
|
|
493
|
-
sys.exit(1)
|
|
494
|
-
|
|
495
|
-
# Step 3 — commit
|
|
496
|
-
with console.status("[blue]Committing…[/blue]", spinner="dots"):
|
|
497
|
-
try:
|
|
498
|
-
commit_resp = self.session.post(
|
|
499
|
-
f"{self.base_url}/commit-upload",
|
|
500
|
-
json={"pending_upload_id": pending_id},
|
|
501
|
-
headers={"Authorization": f"Bearer {self.token}"},
|
|
502
|
-
timeout=30,
|
|
503
|
-
)
|
|
504
|
-
self._raise_for_status(commit_resp)
|
|
505
|
-
except (HTTPError, requests.ConnectionError) as exc:
|
|
506
|
-
console.print(
|
|
507
|
-
Panel(
|
|
508
|
-
str(exc),
|
|
509
|
-
title="[bold red]✗ Commit failed[/bold red]",
|
|
510
|
-
border_style="red",
|
|
511
|
-
padding=(1, 2),
|
|
512
|
-
)
|
|
513
|
-
)
|
|
514
|
-
sys.exit(1)
|
|
515
|
-
|
|
516
|
-
# Update local vault + manifest
|
|
517
|
-
vault_path = local_file_path(self.workspace_name, file_path)
|
|
518
|
-
vault_path.parent.mkdir(parents=True, exist_ok=True)
|
|
519
|
-
shutil.copy2(local_path, vault_path)
|
|
520
|
-
update_manifest_entry(file_path, new_version, checksum)
|
|
521
|
-
|
|
522
|
-
console.print(
|
|
523
|
-
Panel(
|
|
524
|
-
f"[bold]{file_path}[/bold]\n"
|
|
525
|
-
f"Version [cyan]{new_version}[/cyan] "
|
|
526
|
-
f"SHA-256 [dim]{checksum[:16]}…[/dim] "
|
|
527
|
-
f"Size [dim]{size:,} B[/dim]",
|
|
528
|
-
title="[bold green]✓ Push complete[/bold green]",
|
|
529
|
-
border_style="green",
|
|
530
|
-
padding=(1, 2),
|
|
531
|
-
)
|
|
532
|
-
)
|
|
533
|
-
|
|
534
|
-
# ------------------------------------------------------------------
|
|
535
|
-
# Status
|
|
536
|
-
# ------------------------------------------------------------------
|
|
537
|
-
|
|
538
|
-
def status(self) -> None:
|
|
539
|
-
"""Print a Rich table showing the sync state of every tracked file."""
|
|
540
|
-
self._require_workspace()
|
|
541
|
-
|
|
542
|
-
manifest = load_manifest()
|
|
543
|
-
vault_files = {
|
|
544
|
-
str(p.relative_to(WORKSPACES_DIR / self.workspace_name))
|
|
545
|
-
for p in all_local_files(self.workspace_name)
|
|
546
|
-
}
|
|
547
|
-
all_keys = set(manifest.keys()) | vault_files
|
|
548
|
-
|
|
549
|
-
if not all_keys:
|
|
550
|
-
console.print("[dim]No tracked files yet. Run [cyan]study push <file>[/cyan] to start.[/dim]")
|
|
551
|
-
return
|
|
552
|
-
|
|
553
|
-
table = Table(title=f"Workspace: {self.workspace_name}", show_lines=False)
|
|
554
|
-
table.add_column("File", style="bold", no_wrap=True)
|
|
555
|
-
table.add_column("Status", justify="center")
|
|
556
|
-
table.add_column("Version", justify="right")
|
|
557
|
-
table.add_column("Checksum", style="dim")
|
|
558
|
-
|
|
559
|
-
cwd = Path(os.getcwd())
|
|
560
|
-
|
|
561
|
-
for fp in sorted(all_keys):
|
|
562
|
-
entry = manifest.get(fp, {})
|
|
563
|
-
m_version = entry.get("version", "—")
|
|
564
|
-
m_checksum = entry.get("checksum", "")
|
|
565
|
-
|
|
566
|
-
cwd_path = cwd / fp
|
|
567
|
-
|
|
568
|
-
if fp not in manifest:
|
|
569
|
-
status_cell = "[blue]UNTRACKED[/blue]"
|
|
570
|
-
checksum_display = "—"
|
|
571
|
-
version_display = "—"
|
|
572
|
-
elif not cwd_path.exists():
|
|
573
|
-
status_cell = "[red]DELETED[/red]"
|
|
574
|
-
checksum_display = m_checksum[:12] + "…" if m_checksum else "—"
|
|
575
|
-
version_display = str(m_version)
|
|
576
|
-
else:
|
|
577
|
-
current_checksum = sha256_file(cwd_path)
|
|
578
|
-
if current_checksum == m_checksum:
|
|
579
|
-
status_cell = "[green]CLEAN[/green]"
|
|
580
|
-
else:
|
|
581
|
-
status_cell = "[yellow]MODIFIED[/yellow]"
|
|
582
|
-
checksum_display = current_checksum[:12] + "…"
|
|
583
|
-
version_display = str(m_version)
|
|
584
|
-
|
|
585
|
-
table.add_row(fp, status_cell, version_display, checksum_display)
|
|
586
|
-
|
|
587
|
-
console.print(table)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|