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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: study_sync
3
- Version: 1.0.4
3
+ Version: 1.0.8
4
4
  Summary: Offline-first, distributed workspace synchronisation CLI for developers.
5
5
  Author-email: Malatesh <malateshbsunkad03@gmail.com>, Adinath <adinarayan.is23@bmsce.ac.in>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "study_sync"
7
- version = "1.0.4"
7
+ version = "1.0.8"
8
8
  description = "Offline-first, distributed workspace synchronisation CLI for developers."
9
9
  license = { text = "MIT" }
10
10
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: study_sync
3
- Version: 1.0.4
3
+ Version: 1.0.8
4
4
  Summary: Offline-first, distributed workspace synchronisation CLI for developers.
5
5
  Author-email: Malatesh <malateshbsunkad03@gmail.com>, Adinath <adinarayan.is23@bmsce.ac.in>
6
6
  License: MIT
@@ -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