study-sync 1.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- study_sync-1.0.2.dist-info/METADATA +33 -0
- study_sync-1.0.2.dist-info/RECORD +10 -0
- study_sync-1.0.2.dist-info/WHEEL +5 -0
- study_sync-1.0.2.dist-info/entry_points.txt +2 -0
- study_sync-1.0.2.dist-info/top_level.txt +1 -0
- studysync/__init__.py +3 -0
- studysync/constants.py +18 -0
- studysync/local_state.py +183 -0
- studysync/main.py +261 -0
- studysync/sync_engine.py +595 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: study_sync
|
|
3
|
+
Version: 1.0.2
|
|
4
|
+
Summary: Offline-first, distributed workspace synchronisation CLI for developers.
|
|
5
|
+
Author-email: Adinath <adinarayan.is23@bmsce.ac.in>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/yourusername/studysync
|
|
8
|
+
Project-URL: Documentation, https://github.com/yourusername/studysync#readme
|
|
9
|
+
Project-URL: Bug Tracker, https://github.com/yourusername/studysync/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/yourusername/studysync/releases
|
|
11
|
+
Keywords: sync,cli,workspace,distributed,version-control,developer-tools
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Classifier: Topic :: System :: Filesystems
|
|
23
|
+
Classifier: Topic :: Utilities
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Requires-Dist: typer[all]>=0.12.0
|
|
26
|
+
Requires-Dist: rich>=13.7.0
|
|
27
|
+
Requires-Dist: requests>=2.31.0
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest-mock>=3.14.0; extra == "dev"
|
|
31
|
+
Requires-Dist: responses>=0.25.0; extra == "dev"
|
|
32
|
+
Requires-Dist: build>=1.2.0; extra == "dev"
|
|
33
|
+
Requires-Dist: twine>=5.0.0; extra == "dev"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
studysync/__init__.py,sha256=WQzSeHGy6cXWOZB3LggOyYU0_REVbByfThkGpArhTa4,52
|
|
2
|
+
studysync/constants.py,sha256=plWVYDCAgYjmxvIybxYyZmyjjzedj_IButm0wrA-KRM,865
|
|
3
|
+
studysync/local_state.py,sha256=hp8gKCmENs3pPONnp6np5hWNGbjlMfhD1hz7TE9gIyk,5750
|
|
4
|
+
studysync/main.py,sha256=A6OwZkohasoPIPPbXxlMgoldkgKFXJUiGabB9oNkjUM,7829
|
|
5
|
+
studysync/sync_engine.py,sha256=MVsuUOucIuiXQNc3zHy7f6OuNbvnXudXWSxgLqY5oMQ,21555
|
|
6
|
+
study_sync-1.0.2.dist-info/METADATA,sha256=T-sYR24ciWwdMi4ZBVivSyq9mNHfqzioe7WvbiDrq1A,1538
|
|
7
|
+
study_sync-1.0.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
+
study_sync-1.0.2.dist-info/entry_points.txt,sha256=DvLnzQ-Gyy5mxQWLikJDLrg-UYeKVJBoRb6FxlIw0fQ,45
|
|
9
|
+
study_sync-1.0.2.dist-info/top_level.txt,sha256=0LsVKlsecuVpDNNp5xCLfkGnvEwjrNXH4hjVuFEZLZY,10
|
|
10
|
+
study_sync-1.0.2.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
studysync
|
studysync/__init__.py
ADDED
studysync/constants.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""
|
|
2
|
+
constants.py — Package-wide constants for StudySync CLI.
|
|
3
|
+
|
|
4
|
+
PRODUCTION_SERVER_URL is the single source of truth for the default backend.
|
|
5
|
+
It is used as the fallback in every network call so users who install via
|
|
6
|
+
`pip install studysync` can run `study join <TOKEN>` immediately — no
|
|
7
|
+
--server flag required.
|
|
8
|
+
|
|
9
|
+
Advanced users hosting their own backend can override this at any point:
|
|
10
|
+
study workspace create my-ws --server http://192.168.1.42:8000
|
|
11
|
+
study join <TOKEN> --server https://my-own-instance.com
|
|
12
|
+
The chosen URL is persisted to ~/.study/config.json after the first use,
|
|
13
|
+
so subsequent commands pick it up automatically.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
# The public StudySync backend. Update this constant when the production
|
|
17
|
+
# deployment URL changes and bump the package version accordingly.
|
|
18
|
+
PRODUCTION_SERVER_URL: str = "https://studysync-backend-pfft.onrender.com"
|
studysync/local_state.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""
|
|
2
|
+
local_state.py — filesystem layout, hashing, manifest, and config I/O.
|
|
3
|
+
|
|
4
|
+
Directory structure
|
|
5
|
+
-------------------
|
|
6
|
+
~/.study/
|
|
7
|
+
config.json — active workspace config (token, server URL, …)
|
|
8
|
+
manifest.json — local state: {file_path: {version, checksum}}
|
|
9
|
+
workspaces/
|
|
10
|
+
<workspace_name>/
|
|
11
|
+
… — local copies of synced files (mirror of remote)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Root paths
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
STUDY_DIR: Path = Path.home() / ".study"
|
|
28
|
+
CONFIG_PATH: Path = STUDY_DIR / "config.json"
|
|
29
|
+
MANIFEST_PATH: Path = STUDY_DIR / "manifest.json"
|
|
30
|
+
WORKSPACES_DIR: Path = STUDY_DIR / "workspaces"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def ensure_dirs() -> None:
|
|
34
|
+
"""Create the ~/.study/ directory tree if it does not already exist."""
|
|
35
|
+
STUDY_DIR.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
WORKSPACES_DIR.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Hashing
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
HASH_CHUNK_SIZE = 65_536 # 64 KiB
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def sha256_file(path: Path) -> str:
|
|
47
|
+
"""
|
|
48
|
+
Return the lowercase SHA-256 hex digest of the file at *path*.
|
|
49
|
+
|
|
50
|
+
Reads in chunks to handle arbitrarily large files without loading
|
|
51
|
+
everything into memory.
|
|
52
|
+
"""
|
|
53
|
+
h = hashlib.sha256()
|
|
54
|
+
with open(path, "rb") as fh:
|
|
55
|
+
for chunk in iter(lambda: fh.read(HASH_CHUNK_SIZE), b""):
|
|
56
|
+
h.update(chunk)
|
|
57
|
+
return h.hexdigest()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# Config (~/.study/config.json)
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
def load_config() -> dict:
|
|
65
|
+
"""Return the config dict, or {} if it has not been created yet."""
|
|
66
|
+
if not CONFIG_PATH.exists():
|
|
67
|
+
return {}
|
|
68
|
+
try:
|
|
69
|
+
return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
|
70
|
+
except json.JSONDecodeError:
|
|
71
|
+
return {}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def save_config(config: dict) -> None:
|
|
75
|
+
ensure_dirs()
|
|
76
|
+
CONFIG_PATH.write_text(json.dumps(config, indent=2), encoding="utf-8")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_config_value(key: str) -> Optional[str]:
|
|
80
|
+
return load_config().get(key)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# Manifest (~/.study/manifest.json)
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
#
|
|
87
|
+
# Schema:
|
|
88
|
+
# {
|
|
89
|
+
# "src/main.py": {"version": 3, "checksum": "abcdef..."},
|
|
90
|
+
# "docs/README.md": {"version": 1, "checksum": "123456..."}
|
|
91
|
+
# }
|
|
92
|
+
|
|
93
|
+
def load_manifest() -> dict:
|
|
94
|
+
"""Return the manifest dict, or {} if not yet initialised."""
|
|
95
|
+
if not MANIFEST_PATH.exists():
|
|
96
|
+
return {}
|
|
97
|
+
try:
|
|
98
|
+
return json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
|
|
99
|
+
except json.JSONDecodeError:
|
|
100
|
+
return {}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def save_manifest(manifest: dict) -> None:
|
|
104
|
+
ensure_dirs()
|
|
105
|
+
MANIFEST_PATH.write_text(json.dumps(manifest, indent=2, sort_keys=True), encoding="utf-8")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get_manifest_entry(file_path: str) -> Optional[dict]:
|
|
109
|
+
"""Return the manifest entry for *file_path*, or None if untracked."""
|
|
110
|
+
return load_manifest().get(file_path)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def update_manifest_entry(file_path: str, version: int, checksum: str) -> None:
|
|
114
|
+
"""
|
|
115
|
+
Upsert a single manifest entry after a successful push or pull.
|
|
116
|
+
Thread-safety note: for a single-user CLI this is fine; no locking needed.
|
|
117
|
+
"""
|
|
118
|
+
manifest = load_manifest()
|
|
119
|
+
manifest[file_path] = {"version": version, "checksum": checksum}
|
|
120
|
+
save_manifest(manifest)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def remove_manifest_entry(file_path: str) -> None:
|
|
124
|
+
"""Remove a file from the manifest (e.g. after a local delete)."""
|
|
125
|
+
manifest = load_manifest()
|
|
126
|
+
manifest.pop(file_path, None)
|
|
127
|
+
save_manifest(manifest)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
# Workspace file paths
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
def workspace_root(workspace_name: str) -> Path:
|
|
135
|
+
"""Return (and create) the local directory for the given workspace."""
|
|
136
|
+
p = WORKSPACES_DIR / workspace_name
|
|
137
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
return p
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def local_file_path(workspace_name: str, file_path: str) -> Path:
|
|
142
|
+
"""
|
|
143
|
+
Translate a workspace-relative *file_path* (e.g. "src/main.py") to its
|
|
144
|
+
absolute path on disk (e.g. ~/.study/workspaces/my-ws/src/main.py).
|
|
145
|
+
"""
|
|
146
|
+
return workspace_root(workspace_name) / file_path
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def to_relative_path(workspace_name: str, absolute_path: Path) -> str:
|
|
150
|
+
"""
|
|
151
|
+
Convert an absolute path inside the workspace directory to a
|
|
152
|
+
workspace-relative POSIX string (e.g. "src/main.py").
|
|
153
|
+
"""
|
|
154
|
+
root = workspace_root(workspace_name)
|
|
155
|
+
return absolute_path.relative_to(root).as_posix()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def all_local_files(workspace_name: str) -> list[Path]:
|
|
159
|
+
"""Return absolute Paths for every file currently in the local workspace."""
|
|
160
|
+
root = workspace_root(workspace_name)
|
|
161
|
+
if not root.exists():
|
|
162
|
+
return []
|
|
163
|
+
return [p for p in root.rglob("*") if p.is_file()]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def normalize_file_path(raw: str) -> str:
|
|
167
|
+
"""
|
|
168
|
+
Normalise a user-supplied path into a workspace-relative POSIX string.
|
|
169
|
+
|
|
170
|
+
Strips leading slashes, collapses '..' (no escapes above root), and
|
|
171
|
+
converts backslashes to forward slashes.
|
|
172
|
+
"""
|
|
173
|
+
p = Path(raw.replace("\\", "/"))
|
|
174
|
+
parts: list[str] = []
|
|
175
|
+
for seg in p.parts:
|
|
176
|
+
if seg in ("", ".", "/"):
|
|
177
|
+
continue
|
|
178
|
+
if seg == "..":
|
|
179
|
+
if parts:
|
|
180
|
+
parts.pop()
|
|
181
|
+
else:
|
|
182
|
+
parts.append(seg)
|
|
183
|
+
return "/".join(parts)
|
studysync/main.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""
|
|
2
|
+
main.py — Typer CLI entry point for the StudySync `study` command.
|
|
3
|
+
|
|
4
|
+
Install:
|
|
5
|
+
cd cli && pip install -e .
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
study workspace create <name> [--server <url>]
|
|
9
|
+
study join <token> [--server <url>]
|
|
10
|
+
study pull
|
|
11
|
+
study push <file_path>
|
|
12
|
+
study status
|
|
13
|
+
study config
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
import typer
|
|
23
|
+
from rich.console import Console
|
|
24
|
+
from rich.panel import Panel
|
|
25
|
+
|
|
26
|
+
from .constants import PRODUCTION_SERVER_URL
|
|
27
|
+
from .local_state import (
|
|
28
|
+
WORKSPACES_DIR,
|
|
29
|
+
ensure_dirs,
|
|
30
|
+
load_config,
|
|
31
|
+
save_config,
|
|
32
|
+
workspace_root,
|
|
33
|
+
)
|
|
34
|
+
from .sync_engine import SyncEngine
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# App skeleton
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
app = typer.Typer(
|
|
41
|
+
name="study",
|
|
42
|
+
help="[bold]StudySync[/bold] — offline-first CLI workspace synchronisation.",
|
|
43
|
+
rich_markup_mode="rich",
|
|
44
|
+
no_args_is_help=True,
|
|
45
|
+
add_completion=True,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
workspace_app = typer.Typer(
|
|
49
|
+
help="Manage workspaces (create, list).",
|
|
50
|
+
no_args_is_help=True,
|
|
51
|
+
)
|
|
52
|
+
app.add_typer(workspace_app, name="workspace")
|
|
53
|
+
|
|
54
|
+
console = Console()
|
|
55
|
+
|
|
56
|
+
# Default gateway — points at the public production backend so users who
|
|
57
|
+
# install via `pip install studysync` work immediately without --server.
|
|
58
|
+
# Override with --server for self-hosted deployments.
|
|
59
|
+
DEFAULT_SERVER = PRODUCTION_SERVER_URL
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ===========================================================================
|
|
63
|
+
# study workspace create <name>
|
|
64
|
+
# ===========================================================================
|
|
65
|
+
|
|
66
|
+
@workspace_app.command("create")
|
|
67
|
+
def workspace_create(
|
|
68
|
+
name: str = typer.Argument(..., help="Unique workspace name to create on the server."),
|
|
69
|
+
server: str = typer.Option(
|
|
70
|
+
DEFAULT_SERVER,
|
|
71
|
+
"--server",
|
|
72
|
+
"-s",
|
|
73
|
+
help="Base URL of the StudySync server.",
|
|
74
|
+
envvar="STUDYSYNC_SERVER",
|
|
75
|
+
),
|
|
76
|
+
) -> None:
|
|
77
|
+
"""
|
|
78
|
+
Create a new workspace on the server and save the returned token locally.
|
|
79
|
+
|
|
80
|
+
The printed token is the only credential for joining this workspace —
|
|
81
|
+
share it with collaborators.
|
|
82
|
+
"""
|
|
83
|
+
ensure_dirs()
|
|
84
|
+
engine = SyncEngine(server_url=server)
|
|
85
|
+
|
|
86
|
+
with console.status(f"[blue]Creating workspace '[bold]{name}[/bold]'…[/blue]"):
|
|
87
|
+
result = engine.create_workspace(name)
|
|
88
|
+
|
|
89
|
+
token: str = result["access_token"]
|
|
90
|
+
workspace_id: str = result["workspace_id"]
|
|
91
|
+
|
|
92
|
+
save_config(
|
|
93
|
+
{
|
|
94
|
+
"server_url": server,
|
|
95
|
+
"workspace_name": name,
|
|
96
|
+
"workspace_id": workspace_id,
|
|
97
|
+
"workspace_token": token,
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
workspace_root(name) # ensure local directory exists
|
|
101
|
+
|
|
102
|
+
console.print(
|
|
103
|
+
Panel(
|
|
104
|
+
f"[green]Workspace '[bold]{name}[/bold]' created.[/green]\n\n"
|
|
105
|
+
f"[bold]Token[/bold] [yellow]{token}[/yellow]\n\n"
|
|
106
|
+
f"Share this token with collaborators:\n"
|
|
107
|
+
f" [cyan]study join {token}[/cyan]",
|
|
108
|
+
title="✓ Workspace Created",
|
|
109
|
+
border_style="green",
|
|
110
|
+
padding=(1, 2),
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ===========================================================================
|
|
116
|
+
# study join <token>
|
|
117
|
+
# ===========================================================================
|
|
118
|
+
|
|
119
|
+
@app.command()
|
|
120
|
+
def join(
|
|
121
|
+
token: str = typer.Argument(..., help="Workspace access token (UUID)."),
|
|
122
|
+
server: str = typer.Option(
|
|
123
|
+
DEFAULT_SERVER,
|
|
124
|
+
"--server",
|
|
125
|
+
"-s",
|
|
126
|
+
help="Base URL of the StudySync server.",
|
|
127
|
+
envvar="STUDYSYNC_SERVER",
|
|
128
|
+
),
|
|
129
|
+
) -> None:
|
|
130
|
+
"""
|
|
131
|
+
Validate a workspace token and set it as the active workspace.
|
|
132
|
+
|
|
133
|
+
Run [cyan]study pull[/cyan] afterwards to download existing files.
|
|
134
|
+
"""
|
|
135
|
+
ensure_dirs()
|
|
136
|
+
engine = SyncEngine(server_url=server)
|
|
137
|
+
|
|
138
|
+
with console.status("[blue]Validating token…[/blue]"):
|
|
139
|
+
result = engine.join_workspace(token)
|
|
140
|
+
|
|
141
|
+
workspace_name: str = result["name"]
|
|
142
|
+
workspace_id: str = result["workspace_id"]
|
|
143
|
+
|
|
144
|
+
save_config(
|
|
145
|
+
{
|
|
146
|
+
"server_url": server,
|
|
147
|
+
"workspace_name": workspace_name,
|
|
148
|
+
"workspace_id": workspace_id,
|
|
149
|
+
"workspace_token": token,
|
|
150
|
+
}
|
|
151
|
+
)
|
|
152
|
+
workspace_root(workspace_name)
|
|
153
|
+
|
|
154
|
+
console.print(
|
|
155
|
+
Panel(
|
|
156
|
+
f"[green]Joined workspace '[bold]{workspace_name}[/bold]'.[/green]\n\n"
|
|
157
|
+
f"Run [cyan]study pull[/cyan] to download all files.",
|
|
158
|
+
title="✓ Joined",
|
|
159
|
+
border_style="green",
|
|
160
|
+
padding=(1, 2),
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ===========================================================================
|
|
166
|
+
# study pull
|
|
167
|
+
# ===========================================================================
|
|
168
|
+
|
|
169
|
+
@app.command()
|
|
170
|
+
def pull() -> None:
|
|
171
|
+
"""
|
|
172
|
+
Pull all new or updated files from the remote workspace.
|
|
173
|
+
|
|
174
|
+
Compares the remote file tree (versions + checksums) against the local
|
|
175
|
+
manifest and downloads only what has changed. Local file hashes are
|
|
176
|
+
verified after download.
|
|
177
|
+
"""
|
|
178
|
+
SyncEngine().pull()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ===========================================================================
|
|
182
|
+
# study push <file_path>
|
|
183
|
+
# ===========================================================================
|
|
184
|
+
|
|
185
|
+
@app.command()
|
|
186
|
+
def push(
|
|
187
|
+
file_path: str = typer.Argument(
|
|
188
|
+
...,
|
|
189
|
+
help=(
|
|
190
|
+
"Path to the file to push. "
|
|
191
|
+
"Can be workspace-relative ('src/main.py') "
|
|
192
|
+
"or an OS path ('/home/user/project/main.py')."
|
|
193
|
+
),
|
|
194
|
+
),
|
|
195
|
+
) -> None:
|
|
196
|
+
"""
|
|
197
|
+
Push a local file to the remote workspace.
|
|
198
|
+
|
|
199
|
+
Enforces Optimistic Concurrency Control — if the remote has a newer
|
|
200
|
+
version than your local base, the push is rejected with a [red]CONFLICT[/red]
|
|
201
|
+
warning and you must run [cyan]study pull[/cyan] first.
|
|
202
|
+
"""
|
|
203
|
+
SyncEngine().push(file_path)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ===========================================================================
|
|
207
|
+
# study status
|
|
208
|
+
# ===========================================================================
|
|
209
|
+
|
|
210
|
+
@app.command()
|
|
211
|
+
def status() -> None:
|
|
212
|
+
"""
|
|
213
|
+
Show the sync status of every file in the local workspace.
|
|
214
|
+
|
|
215
|
+
[green]CLEAN[/green] — matches the last-known server version.
|
|
216
|
+
[yellow]MODIFIED[/yellow] — changed locally since last push/pull.
|
|
217
|
+
[red]DELETED[/red] — tracked but missing on disk.
|
|
218
|
+
[blue]UNTRACKED[/blue] — on disk but not yet pushed.
|
|
219
|
+
"""
|
|
220
|
+
SyncEngine().status()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# ===========================================================================
|
|
224
|
+
# study config
|
|
225
|
+
# ===========================================================================
|
|
226
|
+
|
|
227
|
+
@app.command()
|
|
228
|
+
def config() -> None:
|
|
229
|
+
"""Show the current workspace configuration stored in ~/.study/config.json."""
|
|
230
|
+
cfg = load_config()
|
|
231
|
+
if not cfg:
|
|
232
|
+
console.print(
|
|
233
|
+
"[yellow]No workspace configured. "
|
|
234
|
+
"Run [cyan]study workspace create <name>[/cyan] or "
|
|
235
|
+
"[cyan]study join <token>[/cyan].[/yellow]"
|
|
236
|
+
)
|
|
237
|
+
raise typer.Exit(1)
|
|
238
|
+
|
|
239
|
+
lines = [
|
|
240
|
+
f"[bold]Workspace [/bold] {cfg.get('workspace_name', 'N/A')}",
|
|
241
|
+
f"[bold]Server [/bold] {cfg.get('server_url', 'N/A')}",
|
|
242
|
+
f"[bold]Token [/bold] [yellow]{cfg.get('workspace_token', 'N/A')}[/yellow]",
|
|
243
|
+
f"[bold]Workspace ID[/bold] {cfg.get('workspace_id', 'N/A')}",
|
|
244
|
+
f"[bold]Local path [/bold] {WORKSPACES_DIR / cfg.get('workspace_name', '')}",
|
|
245
|
+
]
|
|
246
|
+
console.print(
|
|
247
|
+
Panel(
|
|
248
|
+
"\n".join(lines),
|
|
249
|
+
title="StudySync Config (~/.study/config.json)",
|
|
250
|
+
border_style="blue",
|
|
251
|
+
padding=(1, 2),
|
|
252
|
+
)
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ---------------------------------------------------------------------------
|
|
257
|
+
# Entry point (for `python -m studysync.main`)
|
|
258
|
+
# ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
if __name__ == "__main__":
|
|
261
|
+
app()
|
studysync/sync_engine.py
ADDED
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sync_engine.py — Networking, OCC logic, and S3 streaming.
|
|
3
|
+
|
|
4
|
+
All HTTP calls to the StudySync server and all direct S3 streaming live here.
|
|
5
|
+
The Typer commands in main.py are thin wrappers that call into SyncEngine.
|
|
6
|
+
|
|
7
|
+
Key design decisions
|
|
8
|
+
--------------------
|
|
9
|
+
* Streaming uploads: We open the local file as a binary file object and wrap
|
|
10
|
+
it in a progress-tracking shim. `requests` reads the wrapper's `.read()`
|
|
11
|
+
method and forwards the Content-Length header we set explicitly, which is
|
|
12
|
+
required for S3 presigned PUT URLs.
|
|
13
|
+
* Streaming downloads: `requests` streaming GET + chunk-writing gives us a
|
|
14
|
+
constant memory footprint regardless of file size.
|
|
15
|
+
* No retries: This is v1. Production systems should add exponential backoff
|
|
16
|
+
with `tenacity` or `urllib3.Retry`.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import shutil
|
|
23
|
+
import sys
|
|
24
|
+
|
|
25
|
+
from .constants import PRODUCTION_SERVER_URL
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Optional
|
|
28
|
+
|
|
29
|
+
import requests
|
|
30
|
+
from rich.console import Console
|
|
31
|
+
from rich.progress import (
|
|
32
|
+
BarColumn,
|
|
33
|
+
DownloadColumn,
|
|
34
|
+
Progress,
|
|
35
|
+
SpinnerColumn,
|
|
36
|
+
TextColumn,
|
|
37
|
+
TimeRemainingColumn,
|
|
38
|
+
TransferSpeedColumn,
|
|
39
|
+
)
|
|
40
|
+
from rich.table import Table
|
|
41
|
+
|
|
42
|
+
from .local_state import (
|
|
43
|
+
all_local_files,
|
|
44
|
+
load_config,
|
|
45
|
+
load_manifest,
|
|
46
|
+
local_file_path,
|
|
47
|
+
sha256_file,
|
|
48
|
+
to_relative_path,
|
|
49
|
+
update_manifest_entry,
|
|
50
|
+
workspace_root,
|
|
51
|
+
WORKSPACES_DIR,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
console = Console(stderr=False)
|
|
55
|
+
|
|
56
|
+
UPLOAD_CHUNK_SIZE = 8 * 1024 * 1024 # 8 MiB
|
|
57
|
+
DOWNLOAD_CHUNK_SIZE = 8 * 1024 * 1024 # 8 MiB
|
|
58
|
+
HTTP_TIMEOUT = 20 # seconds for metadata calls
|
|
59
|
+
STREAM_TIMEOUT = 600 # seconds for S3 streaming
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# Progress-aware file wrapper for streaming uploads
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
class _ProgressReader:
|
|
67
|
+
"""
|
|
68
|
+
File-like wrapper that reports bytes read to a Rich Progress task.
|
|
69
|
+
|
|
70
|
+
requests calls `.read(size)` on whatever you pass as `data=`, so this
|
|
71
|
+
wrapper intercepts those reads and advances the progress bar. Exposing
|
|
72
|
+
`__len__` lets requests set the Content-Length header automatically, which
|
|
73
|
+
is mandatory for S3 presigned PUT URLs.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
fh,
|
|
79
|
+
total: int,
|
|
80
|
+
progress: Progress,
|
|
81
|
+
task_id,
|
|
82
|
+
) -> None:
|
|
83
|
+
self._fh = fh
|
|
84
|
+
self._total = total
|
|
85
|
+
self._progress = progress
|
|
86
|
+
self._task_id = task_id
|
|
87
|
+
|
|
88
|
+
def read(self, size: int = -1) -> bytes:
|
|
89
|
+
chunk = self._fh.read(size)
|
|
90
|
+
if chunk:
|
|
91
|
+
self._progress.update(self._task_id, advance=len(chunk))
|
|
92
|
+
return chunk
|
|
93
|
+
|
|
94
|
+
def __len__(self) -> int:
|
|
95
|
+
return self._total
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
# SyncEngine
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
class SyncEngine:
|
|
103
|
+
"""
|
|
104
|
+
Encapsulates all network operations for the StudySync CLI.
|
|
105
|
+
|
|
106
|
+
Parameters override config.json values — useful for workspace create/join
|
|
107
|
+
where config hasn't been saved yet.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
server_url: Optional[str] = None,
|
|
113
|
+
workspace_token: Optional[str] = None,
|
|
114
|
+
workspace_name: Optional[str] = None,
|
|
115
|
+
) -> None:
|
|
116
|
+
config = load_config()
|
|
117
|
+
raw_url = server_url or config.get("server_url", PRODUCTION_SERVER_URL)
|
|
118
|
+
self.server_url = raw_url.rstrip("/")
|
|
119
|
+
self.workspace_token = workspace_token or config.get("workspace_token")
|
|
120
|
+
self.workspace_name = workspace_name or config.get("workspace_name")
|
|
121
|
+
|
|
122
|
+
# ------------------------------------------------------------------
|
|
123
|
+
# Guard
|
|
124
|
+
# ------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
def _require_workspace(self) -> None:
|
|
127
|
+
if not self.workspace_token or not self.workspace_name:
|
|
128
|
+
console.print(
|
|
129
|
+
"[red]No active workspace. "
|
|
130
|
+
"Run [bold]study workspace create <name>[/bold] or "
|
|
131
|
+
"[bold]study join <token>[/bold] first.[/red]"
|
|
132
|
+
)
|
|
133
|
+
sys.exit(1)
|
|
134
|
+
|
|
135
|
+
# ------------------------------------------------------------------
|
|
136
|
+
# Workspace management
|
|
137
|
+
# ------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
def create_workspace(self, name: str) -> dict:
|
|
140
|
+
resp = requests.post(
|
|
141
|
+
f"{self.server_url}/workspaces",
|
|
142
|
+
json={"name": name},
|
|
143
|
+
timeout=HTTP_TIMEOUT,
|
|
144
|
+
)
|
|
145
|
+
if resp.status_code == 409:
|
|
146
|
+
console.print(
|
|
147
|
+
f"[red]A workspace named '[bold]{name}[/bold]' already exists on the server.[/red]"
|
|
148
|
+
)
|
|
149
|
+
sys.exit(1)
|
|
150
|
+
_raise_for_status(resp)
|
|
151
|
+
return resp.json()
|
|
152
|
+
|
|
153
|
+
def join_workspace(self, token: str) -> dict:
|
|
154
|
+
resp = requests.post(
|
|
155
|
+
f"{self.server_url}/workspaces/join",
|
|
156
|
+
json={"token": token},
|
|
157
|
+
timeout=HTTP_TIMEOUT,
|
|
158
|
+
)
|
|
159
|
+
if resp.status_code in (401, 404):
|
|
160
|
+
console.print("[red]Invalid token — workspace not found.[/red]")
|
|
161
|
+
sys.exit(1)
|
|
162
|
+
_raise_for_status(resp)
|
|
163
|
+
return resp.json()
|
|
164
|
+
|
|
165
|
+
# ------------------------------------------------------------------
|
|
166
|
+
# Remote state
|
|
167
|
+
# ------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
def get_remote_state(self) -> list[dict]:
|
|
170
|
+
self._require_workspace()
|
|
171
|
+
resp = requests.get(
|
|
172
|
+
f"{self.server_url}/sync/state/{self.workspace_token}",
|
|
173
|
+
timeout=HTTP_TIMEOUT,
|
|
174
|
+
)
|
|
175
|
+
_raise_for_status(resp)
|
|
176
|
+
return resp.json()["files"]
|
|
177
|
+
|
|
178
|
+
# ------------------------------------------------------------------
|
|
179
|
+
# pull
|
|
180
|
+
# ------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
def pull(self) -> None:
|
|
183
|
+
"""
|
|
184
|
+
Compare remote state to the local manifest and download every file
|
|
185
|
+
that is absent or outdated locally.
|
|
186
|
+
|
|
187
|
+
A file is considered up-to-date if:
|
|
188
|
+
1. Its manifest entry version matches the remote version, AND
|
|
189
|
+
2. The physical file on disk still matches the manifest checksum.
|
|
190
|
+
|
|
191
|
+
Condition 2 catches the edge case where someone manually edited the
|
|
192
|
+
file without going through `study push`.
|
|
193
|
+
"""
|
|
194
|
+
self._require_workspace()
|
|
195
|
+
assert self.workspace_name # guarded by _require_workspace
|
|
196
|
+
|
|
197
|
+
console.print(
|
|
198
|
+
f"[blue]Fetching remote state for workspace "
|
|
199
|
+
f"'[bold]{self.workspace_name}[/bold]'…[/blue]"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
remote_files = self.get_remote_state()
|
|
203
|
+
if not remote_files:
|
|
204
|
+
console.print("[green]Workspace is empty. Nothing to pull.[/green]")
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
manifest = load_manifest()
|
|
208
|
+
to_download: list[dict] = []
|
|
209
|
+
|
|
210
|
+
for rf in remote_files:
|
|
211
|
+
file_path: str = rf["file_path"]
|
|
212
|
+
remote_version: int = rf["latest_version"]
|
|
213
|
+
remote_checksum: str = rf.get("latest_checksum") or ""
|
|
214
|
+
|
|
215
|
+
local_entry = manifest.get(file_path)
|
|
216
|
+
dest = local_file_path(self.workspace_name, file_path)
|
|
217
|
+
|
|
218
|
+
if local_entry and local_entry["version"] == remote_version:
|
|
219
|
+
# Version matches — verify the physical file hasn't drifted.
|
|
220
|
+
if dest.exists():
|
|
221
|
+
if sha256_file(dest) == remote_checksum:
|
|
222
|
+
continue # Truly up to date
|
|
223
|
+
# File was modified locally without a push — pull wins.
|
|
224
|
+
console.print(
|
|
225
|
+
f"[yellow] Overwriting locally-modified "
|
|
226
|
+
f"'[bold]{file_path}[/bold]' with remote v{remote_version}[/yellow]"
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
to_download.append(rf)
|
|
230
|
+
|
|
231
|
+
if not to_download:
|
|
232
|
+
console.print("[green]✓ Everything is up to date.[/green]")
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
console.print(f"[blue]Downloading [bold]{len(to_download)}[/bold] file(s)…[/blue]")
|
|
236
|
+
|
|
237
|
+
success = 0
|
|
238
|
+
for rf in to_download:
|
|
239
|
+
file_path = rf["file_path"]
|
|
240
|
+
remote_version = rf["latest_version"]
|
|
241
|
+
remote_checksum = rf.get("latest_checksum") or ""
|
|
242
|
+
size_bytes: Optional[int] = rf.get("size_bytes")
|
|
243
|
+
|
|
244
|
+
console.print(f" → [cyan]{file_path}[/cyan] [dim]v{remote_version}[/dim]")
|
|
245
|
+
try:
|
|
246
|
+
dl_info = self._request_download(file_path)
|
|
247
|
+
dest = local_file_path(self.workspace_name, file_path)
|
|
248
|
+
self._stream_download(dl_info["presigned_url"], dest, size_bytes)
|
|
249
|
+
|
|
250
|
+
# Integrity check
|
|
251
|
+
actual_checksum = sha256_file(dest)
|
|
252
|
+
if actual_checksum != remote_checksum:
|
|
253
|
+
console.print(
|
|
254
|
+
f" [red]✗ Checksum mismatch for '{file_path}'. "
|
|
255
|
+
f"Expected {remote_checksum[:12]}… got {actual_checksum[:12]}… "
|
|
256
|
+
"File deleted.[/red]"
|
|
257
|
+
)
|
|
258
|
+
dest.unlink(missing_ok=True)
|
|
259
|
+
continue
|
|
260
|
+
|
|
261
|
+
update_manifest_entry(file_path, remote_version, remote_checksum)
|
|
262
|
+
|
|
263
|
+
# ----------------------------------------------------------
|
|
264
|
+
# Checkout phase: copy from vault → current working directory
|
|
265
|
+
# ----------------------------------------------------------
|
|
266
|
+
cwd = Path(os.getcwd())
|
|
267
|
+
checkout_dest = cwd / file_path
|
|
268
|
+
checkout_dest.parent.mkdir(parents=True, exist_ok=True)
|
|
269
|
+
shutil.copy2(dest, checkout_dest)
|
|
270
|
+
|
|
271
|
+
console.print(
|
|
272
|
+
f" [green]✓ {file_path}[/green] "
|
|
273
|
+
f"[dim]→ ./{Path(file_path).as_posix()}[/dim]"
|
|
274
|
+
)
|
|
275
|
+
success += 1
|
|
276
|
+
|
|
277
|
+
except Exception as exc:
|
|
278
|
+
console.print(f" [red]✗ Failed to download '{file_path}': {exc}[/red]")
|
|
279
|
+
|
|
280
|
+
console.print(
|
|
281
|
+
f"[green]Pull complete — [bold]{success}/{len(to_download)}[/bold] file(s) "
|
|
282
|
+
f"updated in vault and checked out to [bold]{os.getcwd()}[/bold].[/green]"
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# ------------------------------------------------------------------
|
|
286
|
+
# push
|
|
287
|
+
# ------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
def push(self, file_path_arg: str) -> None:
|
|
290
|
+
"""
|
|
291
|
+
Push a single file to the remote workspace.
|
|
292
|
+
|
|
293
|
+
Accepts either:
|
|
294
|
+
* A workspace-relative path ("src/main.py")
|
|
295
|
+
* An absolute/relative OS path — the file is copied into the workspace
|
|
296
|
+
directory if it lives outside it.
|
|
297
|
+
|
|
298
|
+
OCC flow:
|
|
299
|
+
1. Hash the local file.
|
|
300
|
+
2. Read base_version from the manifest (0 if untracked/new).
|
|
301
|
+
3. POST /sync/upload-request → 409 means remote has diverged → abort.
|
|
302
|
+
4. Stream PUT to the presigned S3 URL.
|
|
303
|
+
5. POST /sync/commit-upload.
|
|
304
|
+
6. Update manifest.
|
|
305
|
+
"""
|
|
306
|
+
self._require_workspace()
|
|
307
|
+
assert self.workspace_name
|
|
308
|
+
|
|
309
|
+
ws_root = workspace_root(self.workspace_name)
|
|
310
|
+
|
|
311
|
+
# Resolve the file and its workspace-relative key
|
|
312
|
+
abs_path, relative = self._resolve_push_path(file_path_arg, ws_root)
|
|
313
|
+
|
|
314
|
+
console.print(f"[blue]Hashing [bold]{relative}[/bold]…[/blue]")
|
|
315
|
+
checksum = sha256_file(abs_path)
|
|
316
|
+
size_bytes = abs_path.stat().st_size
|
|
317
|
+
|
|
318
|
+
# Check against manifest: skip if nothing changed
|
|
319
|
+
manifest = load_manifest()
|
|
320
|
+
local_entry = manifest.get(relative)
|
|
321
|
+
if local_entry and local_entry["checksum"] == checksum:
|
|
322
|
+
console.print(
|
|
323
|
+
f"[yellow]No changes detected in '[bold]{relative}[/bold]'. Nothing to push.[/yellow]"
|
|
324
|
+
)
|
|
325
|
+
return
|
|
326
|
+
|
|
327
|
+
base_version = local_entry["version"] if local_entry else 0
|
|
328
|
+
|
|
329
|
+
# --- OCC gate ---
|
|
330
|
+
console.print(
|
|
331
|
+
f"[blue]Requesting upload slot "
|
|
332
|
+
f"[dim](base_version={base_version})[/dim]…[/blue]"
|
|
333
|
+
)
|
|
334
|
+
resp = requests.post(
|
|
335
|
+
f"{self.server_url}/sync/upload-request",
|
|
336
|
+
json={
|
|
337
|
+
"workspace_token": self.workspace_token,
|
|
338
|
+
"file_path": relative,
|
|
339
|
+
"base_version": base_version,
|
|
340
|
+
"checksum": checksum,
|
|
341
|
+
"size_bytes": size_bytes,
|
|
342
|
+
},
|
|
343
|
+
timeout=HTTP_TIMEOUT,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
if resp.status_code == 409:
|
|
347
|
+
detail = resp.json().get("detail", "Remote has diverged.")
|
|
348
|
+
console.print(
|
|
349
|
+
f"\n[bold red]⚠ CONFLICT — Remote has changes. Pull first.[/bold red]\n"
|
|
350
|
+
f"[red]{detail}[/red]\n"
|
|
351
|
+
f" Run: [cyan]study pull[/cyan]\n"
|
|
352
|
+
)
|
|
353
|
+
sys.exit(1)
|
|
354
|
+
|
|
355
|
+
_raise_for_status(resp)
|
|
356
|
+
upload_info = resp.json()
|
|
357
|
+
|
|
358
|
+
upload_id: str = upload_info["upload_id"]
|
|
359
|
+
presigned_url: str = upload_info["presigned_url"]
|
|
360
|
+
new_version: int = upload_info["new_version"]
|
|
361
|
+
|
|
362
|
+
# --- Stream to S3 ---
|
|
363
|
+
console.print(f"[blue]Uploading to S3 [dim]({size_bytes:,} bytes)[/dim]…[/blue]")
|
|
364
|
+
try:
|
|
365
|
+
self._stream_upload(presigned_url, abs_path, size_bytes)
|
|
366
|
+
except Exception as exc:
|
|
367
|
+
console.print(f"[red]✗ S3 upload failed: {exc}[/red]")
|
|
368
|
+
console.print(
|
|
369
|
+
"[yellow]The upload slot has been allocated but the file was not written. "
|
|
370
|
+
"You can retry — the slot will expire automatically.[/yellow]"
|
|
371
|
+
)
|
|
372
|
+
sys.exit(1)
|
|
373
|
+
|
|
374
|
+
# --- Commit ---
|
|
375
|
+
console.print("[blue]Committing…[/blue]")
|
|
376
|
+
try:
|
|
377
|
+
commit_resp = requests.post(
|
|
378
|
+
f"{self.server_url}/sync/commit-upload",
|
|
379
|
+
json={"upload_id": upload_id},
|
|
380
|
+
timeout=HTTP_TIMEOUT,
|
|
381
|
+
)
|
|
382
|
+
_raise_for_status(commit_resp)
|
|
383
|
+
except Exception as exc:
|
|
384
|
+
console.print(
|
|
385
|
+
f"[red]✗ Commit failed: {exc}\n"
|
|
386
|
+
"The file was uploaded to S3 but the server did not record it. "
|
|
387
|
+
"Contact your administrator with upload_id=[bold]{upload_id}[/bold].[/red]"
|
|
388
|
+
)
|
|
389
|
+
sys.exit(1)
|
|
390
|
+
|
|
391
|
+
# --- Update manifest ---
|
|
392
|
+
update_manifest_entry(relative, new_version, checksum)
|
|
393
|
+
console.print(
|
|
394
|
+
f"[green]✓ Pushed '[bold]{relative}[/bold]' → v{new_version}[/green]"
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
# ------------------------------------------------------------------
|
|
398
|
+
# status
|
|
399
|
+
# ------------------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
def status(self) -> None:
|
|
402
|
+
"""
|
|
403
|
+
Compare every tracked file's on-disk SHA-256 against the manifest.
|
|
404
|
+
|
|
405
|
+
States:
|
|
406
|
+
CLEAN — matches manifest checksum
|
|
407
|
+
MODIFIED — exists on disk but checksum differs
|
|
408
|
+
DELETED — tracked in manifest but missing on disk
|
|
409
|
+
UNTRACKED — present on disk but not in manifest
|
|
410
|
+
"""
|
|
411
|
+
self._require_workspace()
|
|
412
|
+
assert self.workspace_name
|
|
413
|
+
|
|
414
|
+
manifest = load_manifest()
|
|
415
|
+
ws_root = workspace_root(self.workspace_name)
|
|
416
|
+
|
|
417
|
+
table = Table(
|
|
418
|
+
title=f"Workspace: [bold]{self.workspace_name}[/bold]",
|
|
419
|
+
show_lines=False,
|
|
420
|
+
header_style="bold",
|
|
421
|
+
)
|
|
422
|
+
table.add_column("File", style="cyan", no_wrap=True)
|
|
423
|
+
table.add_column("Status", justify="center")
|
|
424
|
+
table.add_column("Ver", justify="right", style="dim")
|
|
425
|
+
table.add_column("Checksum", style="dim", no_wrap=True)
|
|
426
|
+
|
|
427
|
+
tracked_paths: set[str] = set(manifest.keys())
|
|
428
|
+
rows: list[tuple] = []
|
|
429
|
+
|
|
430
|
+
for file_path, entry in manifest.items():
|
|
431
|
+
dest = local_file_path(self.workspace_name, file_path)
|
|
432
|
+
ver = str(entry["version"])
|
|
433
|
+
if not dest.exists():
|
|
434
|
+
rows.append((file_path, "[red]DELETED[/red]", ver, entry["checksum"][:14] + "…"))
|
|
435
|
+
else:
|
|
436
|
+
current = sha256_file(dest)
|
|
437
|
+
if current == entry["checksum"]:
|
|
438
|
+
rows.append((file_path, "[green]CLEAN[/green]", ver, current[:14] + "…"))
|
|
439
|
+
else:
|
|
440
|
+
rows.append((file_path, "[yellow]MODIFIED[/yellow]", ver, current[:14] + "…"))
|
|
441
|
+
|
|
442
|
+
# Untracked files
|
|
443
|
+
for abs_path in all_local_files(self.workspace_name):
|
|
444
|
+
rel = to_relative_path(self.workspace_name, abs_path)
|
|
445
|
+
if rel not in tracked_paths:
|
|
446
|
+
rows.append((rel, "[blue]UNTRACKED[/blue]", "-", "-"))
|
|
447
|
+
|
|
448
|
+
if not rows:
|
|
449
|
+
console.print(
|
|
450
|
+
f"[yellow]Workspace '[bold]{self.workspace_name}[/bold]' is empty locally. "
|
|
451
|
+
"Run [cyan]study pull[/cyan] to download files.[/yellow]"
|
|
452
|
+
)
|
|
453
|
+
return
|
|
454
|
+
|
|
455
|
+
for row in sorted(rows, key=lambda r: r[0]):
|
|
456
|
+
table.add_row(*row)
|
|
457
|
+
|
|
458
|
+
console.print(table)
|
|
459
|
+
|
|
460
|
+
# ------------------------------------------------------------------
|
|
461
|
+
# Internal helpers
|
|
462
|
+
# ------------------------------------------------------------------
|
|
463
|
+
|
|
464
|
+
def _request_download(self, file_path: str) -> dict:
|
|
465
|
+
resp = requests.get(
|
|
466
|
+
f"{self.server_url}/sync/download-request",
|
|
467
|
+
params={
|
|
468
|
+
"workspace_token": self.workspace_token,
|
|
469
|
+
"file_path": file_path,
|
|
470
|
+
},
|
|
471
|
+
timeout=HTTP_TIMEOUT,
|
|
472
|
+
)
|
|
473
|
+
_raise_for_status(resp)
|
|
474
|
+
return resp.json()
|
|
475
|
+
|
|
476
|
+
def _stream_download(
|
|
477
|
+
self,
|
|
478
|
+
presigned_url: str,
|
|
479
|
+
dest: Path,
|
|
480
|
+
file_size: Optional[int] = None,
|
|
481
|
+
) -> None:
|
|
482
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
483
|
+
|
|
484
|
+
with requests.get(presigned_url, stream=True, timeout=STREAM_TIMEOUT) as resp:
|
|
485
|
+
resp.raise_for_status()
|
|
486
|
+
total = int(resp.headers.get("Content-Length", file_size or 0)) or None
|
|
487
|
+
|
|
488
|
+
with Progress(
|
|
489
|
+
SpinnerColumn(),
|
|
490
|
+
TextColumn("[progress.description]{task.description}"),
|
|
491
|
+
BarColumn(),
|
|
492
|
+
DownloadColumn(),
|
|
493
|
+
TransferSpeedColumn(),
|
|
494
|
+
TimeRemainingColumn(),
|
|
495
|
+
console=console,
|
|
496
|
+
transient=True,
|
|
497
|
+
) as progress:
|
|
498
|
+
task = progress.add_task(dest.name, total=total)
|
|
499
|
+
with open(dest, "wb") as fh:
|
|
500
|
+
for chunk in resp.iter_content(chunk_size=DOWNLOAD_CHUNK_SIZE):
|
|
501
|
+
if chunk:
|
|
502
|
+
fh.write(chunk)
|
|
503
|
+
progress.update(task, advance=len(chunk))
|
|
504
|
+
|
|
505
|
+
def _stream_upload(
|
|
506
|
+
self,
|
|
507
|
+
presigned_url: str,
|
|
508
|
+
local_path: Path,
|
|
509
|
+
size_bytes: int,
|
|
510
|
+
) -> None:
|
|
511
|
+
with Progress(
|
|
512
|
+
SpinnerColumn(),
|
|
513
|
+
TextColumn("[progress.description]{task.description}"),
|
|
514
|
+
BarColumn(),
|
|
515
|
+
DownloadColumn(),
|
|
516
|
+
TransferSpeedColumn(),
|
|
517
|
+
TimeRemainingColumn(),
|
|
518
|
+
console=console,
|
|
519
|
+
transient=True,
|
|
520
|
+
) as progress:
|
|
521
|
+
task = progress.add_task(local_path.name, total=size_bytes)
|
|
522
|
+
|
|
523
|
+
with open(local_path, "rb") as fh:
|
|
524
|
+
reader = _ProgressReader(fh, size_bytes, progress, task)
|
|
525
|
+
resp = requests.put(
|
|
526
|
+
presigned_url,
|
|
527
|
+
data=reader,
|
|
528
|
+
headers={
|
|
529
|
+
"Content-Length": str(size_bytes),
|
|
530
|
+
"Content-Type": "application/octet-stream",
|
|
531
|
+
},
|
|
532
|
+
timeout=STREAM_TIMEOUT,
|
|
533
|
+
)
|
|
534
|
+
resp.raise_for_status()
|
|
535
|
+
|
|
536
|
+
def _resolve_push_path(
|
|
537
|
+
self,
|
|
538
|
+
file_path_arg: str,
|
|
539
|
+
ws_root: Path,
|
|
540
|
+
) -> tuple[Path, str]:
|
|
541
|
+
"""
|
|
542
|
+
Return (absolute_path, workspace_relative_posix_key).
|
|
543
|
+
|
|
544
|
+
Tries the arg as:
|
|
545
|
+
1. A path relative to the workspace root.
|
|
546
|
+
2. An absolute OS path — if the file is inside the workspace root,
|
|
547
|
+
derive the relative key. If outside, copy it into the workspace
|
|
548
|
+
root (top-level, using the filename only).
|
|
549
|
+
"""
|
|
550
|
+
import shutil
|
|
551
|
+
|
|
552
|
+
# Attempt 1: relative to workspace root
|
|
553
|
+
candidate = ws_root / file_path_arg
|
|
554
|
+
if candidate.exists():
|
|
555
|
+
return candidate, file_path_arg.replace("\\", "/")
|
|
556
|
+
|
|
557
|
+
# Attempt 2: treat as an OS path
|
|
558
|
+
os_path = Path(file_path_arg).expanduser().resolve()
|
|
559
|
+
if not os_path.exists():
|
|
560
|
+
console.print(f"[red]File not found: {file_path_arg}[/red]")
|
|
561
|
+
sys.exit(1)
|
|
562
|
+
|
|
563
|
+
try:
|
|
564
|
+
# File is already inside the workspace directory
|
|
565
|
+
relative = os_path.relative_to(ws_root).as_posix()
|
|
566
|
+
return os_path, relative
|
|
567
|
+
except ValueError:
|
|
568
|
+
pass
|
|
569
|
+
|
|
570
|
+
# File is outside the workspace — copy it in
|
|
571
|
+
relative = os_path.name
|
|
572
|
+
dest = ws_root / relative
|
|
573
|
+
console.print(
|
|
574
|
+
f"[yellow]File is outside the workspace. "
|
|
575
|
+
f"Copying to '[bold]{relative}[/bold]' inside workspace.[/yellow]"
|
|
576
|
+
)
|
|
577
|
+
shutil.copy2(os_path, dest)
|
|
578
|
+
return dest, relative
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
# ---------------------------------------------------------------------------
|
|
582
|
+
# Utility
|
|
583
|
+
# ---------------------------------------------------------------------------
|
|
584
|
+
|
|
585
|
+
def _raise_for_status(resp: requests.Response) -> None:
|
|
586
|
+
"""Raise with a helpful message on HTTP errors."""
|
|
587
|
+
if not resp.ok:
|
|
588
|
+
try:
|
|
589
|
+
detail = resp.json().get("detail", resp.text)
|
|
590
|
+
except Exception:
|
|
591
|
+
detail = resp.text
|
|
592
|
+
raise requests.HTTPError(
|
|
593
|
+
f"HTTP {resp.status_code}: {detail}",
|
|
594
|
+
response=resp,
|
|
595
|
+
)
|