study-sync 1.0.2__tar.gz → 1.0.3__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.2
3
+ Version: 1.0.3
4
4
  Summary: Offline-first, distributed workspace synchronisation CLI for developers.
5
5
  Author-email: Adinath <adinarayan.is23@bmsce.ac.in>
6
6
  License: MIT
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
7
7
  # ---------------------------------------------------------------------------
8
8
  [project]
9
9
  name = "study_sync"
10
- version = "1.0.2"
10
+ version = "1.0.3"
11
11
  description = "Offline-first, distributed workspace synchronisation CLI for developers."
12
12
  license = { text = "MIT" }
13
13
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: study_sync
3
- Version: 1.0.2
3
+ Version: 1.0.3
4
4
  Summary: Offline-first, distributed workspace synchronisation CLI for developers.
5
5
  Author-email: Adinath <adinarayan.is23@bmsce.ac.in>
6
6
  License: MIT
@@ -0,0 +1,373 @@
1
+ """
2
+ main.py — Rich-enhanced 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>
9
+ study join <token>
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 importlib.metadata import PackageNotFoundError
20
+ from importlib.metadata import version as _pkg_version
21
+ from pathlib import Path
22
+ from typing import Optional
23
+
24
+ import typer
25
+ from rich import box
26
+ from rich.console import Console
27
+ from rich.padding import Padding
28
+ from rich.panel import Panel
29
+ from rich.rule import Rule
30
+ from rich.table import Table
31
+ from rich.text import Text
32
+
33
+ from .constants import PRODUCTION_SERVER_URL
34
+ from .local_state import (
35
+ WORKSPACES_DIR,
36
+ ensure_dirs,
37
+ load_config,
38
+ save_config,
39
+ workspace_root,
40
+ )
41
+ from .sync_engine import SyncEngine
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Version helper
45
+ # ---------------------------------------------------------------------------
46
+
47
+ try:
48
+ __version__ = _pkg_version("study_sync")
49
+ except PackageNotFoundError:
50
+ __version__ = "dev"
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Globals
54
+ # ---------------------------------------------------------------------------
55
+
56
+ console = Console()
57
+
58
+ # Points at the public production backend so users who install via
59
+ # `pip install study_sync` work immediately without needing --server.
60
+ DEFAULT_SERVER = PRODUCTION_SERVER_URL
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Typer app skeleton
64
+ # ---------------------------------------------------------------------------
65
+
66
+ app = typer.Typer(
67
+ name="study",
68
+ help="StudySync — offline-first CLI workspace synchronisation.",
69
+ rich_markup_mode="rich",
70
+ # invoke_without_command=True so our callback can show the custom help panel
71
+ invoke_without_command=True,
72
+ add_completion=True,
73
+ )
74
+
75
+ workspace_app = typer.Typer(
76
+ help="Manage workspaces.",
77
+ invoke_without_command=True,
78
+ no_args_is_help=True,
79
+ )
80
+ app.add_typer(workspace_app, name="workspace")
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # Custom no-args help panel
85
+ # ---------------------------------------------------------------------------
86
+
87
+ def _print_help_panel() -> None:
88
+ """Render a beautiful Rich help panel when `study` is called with no args."""
89
+
90
+ # ── Header ──────────────────────────────────────────────────────────────
91
+ console.print()
92
+ console.print(
93
+ Panel(
94
+ Text.assemble(
95
+ ("StudySync", "bold cyan"),
96
+ (" v" + __version__, "dim"),
97
+ "\nOffline-first, distributed workspace sync for developers.\n",
98
+ (DEFAULT_SERVER, "dim italic"),
99
+ ),
100
+ border_style="cyan",
101
+ padding=(0, 2),
102
+ )
103
+ )
104
+
105
+ # ── Commands table ───────────────────────────────────────────────────────
106
+ tbl = Table(box=box.SIMPLE, show_header=True, header_style="bold blue", padding=(0, 2))
107
+ tbl.add_column("Command", style="cyan", no_wrap=True)
108
+ tbl.add_column("Description")
109
+
110
+ commands = [
111
+ ("study workspace create [NAME]", "Create a new workspace and print its share token"),
112
+ ("study join [TOKEN]", "Join a workspace using its share token"),
113
+ ("study pull", "Download new/changed files from the remote workspace"),
114
+ ("study push [FILE]", "Upload a local file to the remote workspace"),
115
+ ("study status", "Show the sync state of every tracked file"),
116
+ ("study config", "Display active workspace configuration"),
117
+ ]
118
+ for cmd, desc in commands:
119
+ tbl.add_row(cmd, desc)
120
+
121
+ console.print(Rule("[bold]Commands[/bold]", style="dim"))
122
+ console.print(Padding(tbl, (0, 0)))
123
+
124
+ # ── Quick start ──────────────────────────────────────────────────────────
125
+ console.print(Rule("[bold]Quick start[/bold]", style="dim"))
126
+ console.print(
127
+ Padding(
128
+ "[dim]1.[/dim] [cyan]pip install study_sync[/cyan]\n"
129
+ "[dim]2.[/dim] [cyan]study join <TOKEN>[/cyan]\n"
130
+ "[dim]3.[/dim] [cyan]study pull[/cyan]\n\n"
131
+ "Type [cyan]study <command> --help[/cyan] for per-command options.",
132
+ (0, 4, 1, 4),
133
+ )
134
+ )
135
+
136
+
137
+ @app.callback()
138
+ def _root_callback(ctx: typer.Context) -> None:
139
+ """Show the help panel when `study` is run with no subcommand."""
140
+ if ctx.invoked_subcommand is None:
141
+ _print_help_panel()
142
+ raise typer.Exit()
143
+
144
+
145
+ # ===========================================================================
146
+ # study workspace create <name>
147
+ # ===========================================================================
148
+
149
+ @workspace_app.command("create")
150
+ def workspace_create(
151
+ name: str = typer.Argument(
152
+ ...,
153
+ help="Unique workspace name to create on the server.",
154
+ ),
155
+ server: str = typer.Option(
156
+ DEFAULT_SERVER,
157
+ "--server", "-s",
158
+ help="Base URL of the StudySync server (default: production).",
159
+ envvar="STUDYSYNC_SERVER",
160
+ show_default=False,
161
+ ),
162
+ ) -> None:
163
+ """
164
+ Create a new workspace and receive a shareable access token.
165
+
166
+ The printed token is the only credential for joining this workspace —
167
+ copy it and share it with collaborators via [cyan]study join <TOKEN>[/cyan].
168
+ """
169
+ ensure_dirs()
170
+ engine = SyncEngine(server_url=server)
171
+
172
+ with console.status(
173
+ f"[blue]Creating workspace [bold]{name!r}[/bold]…[/blue]",
174
+ spinner="dots",
175
+ ):
176
+ result = engine.create_workspace(name)
177
+
178
+ token: str = result["access_token"]
179
+ workspace_id: str = result["workspace_id"]
180
+
181
+ save_config(
182
+ {
183
+ "server_url": server,
184
+ "workspace_name": name,
185
+ "workspace_id": workspace_id,
186
+ "workspace_token": token,
187
+ }
188
+ )
189
+ workspace_root(name)
190
+
191
+ console.print(
192
+ Panel(
193
+ f"[bold]Workspace[/bold] {name}\n"
194
+ f"[bold]Token [/bold] [yellow]{token}[/yellow]\n\n"
195
+ f"Share with collaborators:\n"
196
+ f" [cyan]study join {token}[/cyan]",
197
+ title="[bold green]✓ Workspace created[/bold green]",
198
+ border_style="green",
199
+ padding=(1, 2),
200
+ )
201
+ )
202
+
203
+
204
+ # ===========================================================================
205
+ # study join <token>
206
+ # ===========================================================================
207
+
208
+ @app.command()
209
+ def join(
210
+ token: str = typer.Argument(
211
+ ...,
212
+ help="Workspace access token (UUID) shared by the workspace owner.",
213
+ ),
214
+ server: str = typer.Option(
215
+ DEFAULT_SERVER,
216
+ "--server", "-s",
217
+ help="Base URL of the StudySync server (default: production).",
218
+ envvar="STUDYSYNC_SERVER",
219
+ show_default=False,
220
+ ),
221
+ ) -> None:
222
+ """
223
+ Join an existing workspace using its access token.
224
+
225
+ Validates the token against the server, then saves the workspace
226
+ credentials locally. Run [cyan]study pull[/cyan] afterwards to
227
+ download all files.
228
+ """
229
+ ensure_dirs()
230
+ engine = SyncEngine(server_url=server)
231
+
232
+ with console.status("[blue]Validating token…[/blue]", spinner="dots"):
233
+ result = engine.join_workspace(token)
234
+
235
+ workspace_name: str = result["name"]
236
+ workspace_id: str = result["workspace_id"]
237
+
238
+ save_config(
239
+ {
240
+ "server_url": server,
241
+ "workspace_name": workspace_name,
242
+ "workspace_id": workspace_id,
243
+ "workspace_token": token,
244
+ }
245
+ )
246
+ workspace_root(workspace_name)
247
+
248
+ console.print(
249
+ Panel(
250
+ f"[bold]Workspace[/bold] {workspace_name}\n"
251
+ f"[bold]Server [/bold] {server}\n\n"
252
+ f"Run [cyan]study pull[/cyan] to download all files.",
253
+ title="[bold green]✓ Joined workspace[/bold green]",
254
+ border_style="green",
255
+ padding=(1, 2),
256
+ )
257
+ )
258
+
259
+
260
+ # ===========================================================================
261
+ # study pull
262
+ # ===========================================================================
263
+
264
+ @app.command()
265
+ def pull() -> None:
266
+ """
267
+ Download all new or updated files from the remote workspace.
268
+
269
+ Compares remote file versions and checksums against the local manifest,
270
+ downloads only what has changed, and verifies each file's SHA-256 hash.
271
+ Updated files are written to the current directory.
272
+ """
273
+ console.rule("[bold blue]study pull[/bold blue]", style="dim blue")
274
+ SyncEngine().pull()
275
+
276
+
277
+ # ===========================================================================
278
+ # study push <file_path>
279
+ # ===========================================================================
280
+
281
+ @app.command()
282
+ def push(
283
+ file_path: str = typer.Argument(
284
+ ...,
285
+ help="Path to the local file to upload (relative or absolute).",
286
+ ),
287
+ server: str = typer.Option(
288
+ None,
289
+ "--server",
290
+ help="Override the server URL for this push only.",
291
+ envvar="STUDYSYNC_SERVER",
292
+ show_default=False,
293
+ ),
294
+ ) -> None:
295
+ """
296
+ Upload a local file to the remote workspace.
297
+
298
+ Computes a SHA-256 checksum and base version before uploading, so
299
+ the server can reject concurrent conflicting writes (optimistic
300
+ concurrency control). If the file has not changed since the last push,
301
+ the upload is skipped.
302
+ """
303
+ console.rule("[bold blue]study push[/bold blue]", style="dim blue")
304
+ SyncEngine(server_url=server).push(file_path)
305
+
306
+
307
+ # ===========================================================================
308
+ # study status
309
+ # ===========================================================================
310
+
311
+ @app.command()
312
+ def status() -> None:
313
+ """
314
+ Show the sync status of every file in the local workspace.
315
+
316
+ [green]CLEAN[/green] — matches the last-known server version.
317
+ [yellow]MODIFIED[/yellow] — changed locally since last push/pull.
318
+ [red]DELETED[/red] — tracked but missing from disk.
319
+ [blue]UNTRACKED[/blue] — on disk but never pushed.
320
+ """
321
+ console.rule("[bold blue]study status[/bold blue]", style="dim blue")
322
+ SyncEngine().status()
323
+
324
+
325
+ # ===========================================================================
326
+ # study config
327
+ # ===========================================================================
328
+
329
+ @app.command()
330
+ def config() -> None:
331
+ """Display the active workspace configuration stored in ~/.study/config.json."""
332
+ cfg = load_config()
333
+ if not cfg:
334
+ console.print(
335
+ Panel(
336
+ "No workspace is configured.\n\n"
337
+ "Create one: [cyan]study workspace create <name>[/cyan]\n"
338
+ "Or join one: [cyan]study join <token>[/cyan]",
339
+ title="[bold yellow]⚠ Not configured[/bold yellow]",
340
+ border_style="yellow",
341
+ padding=(1, 2),
342
+ )
343
+ )
344
+ raise typer.Exit(1)
345
+
346
+ workspace_name = cfg.get("workspace_name", "N/A")
347
+ local_path = WORKSPACES_DIR / workspace_name if workspace_name != "N/A" else "N/A"
348
+
349
+ rows = [
350
+ ("Workspace", workspace_name),
351
+ ("Server", cfg.get("server_url", "N/A")),
352
+ ("Token", cfg.get("workspace_token", "N/A")),
353
+ ("Workspace ID", cfg.get("workspace_id", "N/A")),
354
+ ("Local path", str(local_path)),
355
+ ]
356
+ body = "\n".join(f"[bold]{k:<14}[/bold] {v}" for k, v in rows)
357
+
358
+ console.print(
359
+ Panel(
360
+ body,
361
+ title="[bold blue]StudySync Config[/bold blue] (~/.study/config.json)",
362
+ border_style="blue",
363
+ padding=(1, 2),
364
+ )
365
+ )
366
+
367
+
368
+ # ---------------------------------------------------------------------------
369
+ # Entry point
370
+ # ---------------------------------------------------------------------------
371
+
372
+ if __name__ == "__main__":
373
+ app()
@@ -1,261 +0,0 @@
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()
File without changes
File without changes