pockit 0.2.0__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.
- pockit-0.2.0/PKG-INFO +8 -0
- pockit-0.2.0/pockit/__init__.py +5 -0
- pockit-0.2.0/pockit/main.py +788 -0
- pockit-0.2.0/pockit.egg-info/PKG-INFO +8 -0
- pockit-0.2.0/pockit.egg-info/SOURCES.txt +9 -0
- pockit-0.2.0/pockit.egg-info/dependency_links.txt +1 -0
- pockit-0.2.0/pockit.egg-info/entry_points.txt +2 -0
- pockit-0.2.0/pockit.egg-info/requires.txt +3 -0
- pockit-0.2.0/pockit.egg-info/top_level.txt +1 -0
- pockit-0.2.0/pyproject.toml +21 -0
- pockit-0.2.0/setup.cfg +4 -0
pockit-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pockit — a modern, interactive CLI for pockit-cloud storage.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import platform
|
|
9
|
+
import shutil
|
|
10
|
+
import signal
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
import questionary
|
|
16
|
+
import typer
|
|
17
|
+
from gradio_client import Client, handle_file
|
|
18
|
+
from rich import box
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
from rich.panel import Panel
|
|
21
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
|
|
22
|
+
from rich.table import Table
|
|
23
|
+
from rich.theme import Theme
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Theme & console
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
THEME = Theme(
|
|
30
|
+
{
|
|
31
|
+
"primary": "bold cyan",
|
|
32
|
+
"success": "bold green",
|
|
33
|
+
"error": "bold red",
|
|
34
|
+
"warning": "bold yellow",
|
|
35
|
+
"muted": "dim white",
|
|
36
|
+
"filename": "bold magenta",
|
|
37
|
+
"header": "bold white on #1a1a2e",
|
|
38
|
+
"label": "cyan",
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
console = Console(theme=THEME, highlight=False)
|
|
43
|
+
|
|
44
|
+
LOGO = """[bold cyan]
|
|
45
|
+
██████╗ ██████╗ ██████╗██╗ ██╗██╗████████╗
|
|
46
|
+
██╔══██╗██╔═══██╗██╔════╝██║ ██╔╝██║╚══██╔══╝
|
|
47
|
+
██████╔╝██║ ██║██║ █████╔╝ ██║ ██║
|
|
48
|
+
██╔═══╝ ██║ ██║██║ ██╔═██╗ ██║ ██║
|
|
49
|
+
██║ ╚██████╔╝╚██████╗██║ ██╗██║ ██║
|
|
50
|
+
╚═╝ ╚═════╝ ╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝
|
|
51
|
+
[/bold cyan][muted] Cloud storage for developers[/muted]
|
|
52
|
+
(Don't have an account? Sign up at [underline]https://pockit-cloud.github.io/home/[/underline])
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# Credential cache (~/.pockit/session.json)
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
CACHE_DIR = Path.home() / ".pockit"
|
|
60
|
+
CACHE_FILE = CACHE_DIR / "session.json"
|
|
61
|
+
|
|
62
|
+
QSTYLE = questionary.Style([
|
|
63
|
+
("qmark", "fg:#00d7ff bold"),
|
|
64
|
+
("question", "bold"),
|
|
65
|
+
("answer", "fg:#ff79c6 bold"),
|
|
66
|
+
("pointer", "fg:#00d7ff bold"),
|
|
67
|
+
("highlighted", "fg:#00d7ff bold"),
|
|
68
|
+
("selected", "fg:#50fa7b"),
|
|
69
|
+
])
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _save_creds(user_id: str, password: str) -> None:
|
|
73
|
+
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
CACHE_FILE.write_text(json.dumps({"user_id": user_id, "password": password}))
|
|
75
|
+
CACHE_FILE.chmod(0o600)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _load_creds() -> Optional[dict]:
|
|
79
|
+
if CACHE_FILE.exists():
|
|
80
|
+
try:
|
|
81
|
+
return json.loads(CACHE_FILE.read_text())
|
|
82
|
+
except Exception:
|
|
83
|
+
pass
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _clear_creds() -> None:
|
|
88
|
+
if CACHE_FILE.exists():
|
|
89
|
+
CACHE_FILE.unlink()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def ensure_logged_in() -> dict:
|
|
93
|
+
"""Return cached credentials, or interactively prompt + validate them."""
|
|
94
|
+
creds = _load_creds()
|
|
95
|
+
if creds:
|
|
96
|
+
return creds
|
|
97
|
+
|
|
98
|
+
console.print(LOGO)
|
|
99
|
+
console.print(" [muted]Please log in to continue.[/muted]\n")
|
|
100
|
+
|
|
101
|
+
user_id = questionary.text("👤 User ID:", style=QSTYLE).ask()
|
|
102
|
+
if not user_id:
|
|
103
|
+
raise typer.Exit()
|
|
104
|
+
|
|
105
|
+
password = questionary.password("🔑 Password:", style=QSTYLE).ask()
|
|
106
|
+
if not password:
|
|
107
|
+
raise typer.Exit()
|
|
108
|
+
|
|
109
|
+
user_id = user_id.strip()
|
|
110
|
+
password = password.strip()
|
|
111
|
+
|
|
112
|
+
with _spinner("Authenticating…"):
|
|
113
|
+
client = get_client()
|
|
114
|
+
result = client.predict(user_id, password, api_name="/login")
|
|
115
|
+
|
|
116
|
+
status = extract_status(result, 0)
|
|
117
|
+
|
|
118
|
+
# Allow-list: backend returns "Found N files." on success, "Access Denied" on failure.
|
|
119
|
+
SUCCESS_KEYWORDS = ("success", "welcome", "logged in", "login ok",
|
|
120
|
+
"authenticated", "found")
|
|
121
|
+
if not any(kw in status.lower() for kw in SUCCESS_KEYWORDS):
|
|
122
|
+
console.print(f"\n [error]✗ {status}[/error]\n")
|
|
123
|
+
raise typer.Exit(code=1)
|
|
124
|
+
|
|
125
|
+
_save_creds(user_id, password)
|
|
126
|
+
console.print(f"\n [success]✓ Logged in as [bold]{user_id}[/bold][/success]\n")
|
|
127
|
+
return {"user_id": user_id, "password": password}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
# Gradio client factory
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
def get_client() -> Client:
|
|
135
|
+
"""Return a fresh Gradio client on every call."""
|
|
136
|
+
try:
|
|
137
|
+
return Client("pockit-cloud/cli-backend", verbose=False)
|
|
138
|
+
except Exception as exc:
|
|
139
|
+
console.print(f"\n [error]✗ Could not reach the backend: {exc}[/error]\n")
|
|
140
|
+
raise typer.Exit(code=1)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
# Response parsers
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
def extract_status(result, index: int = 0) -> str:
|
|
148
|
+
raw = result[index] if isinstance(result, (list, tuple)) else result
|
|
149
|
+
if isinstance(raw, dict):
|
|
150
|
+
for key in ("value", "choices"):
|
|
151
|
+
if key in raw:
|
|
152
|
+
inner = raw[key]
|
|
153
|
+
if isinstance(inner, list):
|
|
154
|
+
return ", ".join(str(v) for v in inner) if inner else "(empty)"
|
|
155
|
+
return str(inner)
|
|
156
|
+
return str(raw)
|
|
157
|
+
return str(raw) if raw is not None else "(no response)"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def extract_files(raw) -> list[str]:
|
|
161
|
+
# Unwrap Gradio metadata dict e.g. {'choices': [...], '__type__': 'update'}
|
|
162
|
+
if isinstance(raw, dict):
|
|
163
|
+
choices = raw.get("choices", raw.get("value", []))
|
|
164
|
+
raw = choices if isinstance(choices, list) else [str(choices)]
|
|
165
|
+
if not isinstance(raw, list):
|
|
166
|
+
return []
|
|
167
|
+
# Flatten one wrapper level: [[f1, f2, ...]] → [f1, f2, ...]
|
|
168
|
+
if len(raw) == 1 and isinstance(raw[0], list) and len(raw[0]) > 2:
|
|
169
|
+
raw = raw[0]
|
|
170
|
+
seen: set = set()
|
|
171
|
+
result: list[str] = []
|
|
172
|
+
for item in raw:
|
|
173
|
+
# Gradio [value, label] pair → take value at index 0
|
|
174
|
+
if isinstance(item, (list, tuple)) and len(item) >= 1:
|
|
175
|
+
s = str(item[0]).strip()
|
|
176
|
+
elif isinstance(item, dict):
|
|
177
|
+
s = str(item.get("value", item.get("label", ""))).strip()
|
|
178
|
+
else:
|
|
179
|
+
s = str(item).strip()
|
|
180
|
+
if s and s not in seen:
|
|
181
|
+
seen.add(s)
|
|
182
|
+
result.append(s)
|
|
183
|
+
return result
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ---------------------------------------------------------------------------
|
|
187
|
+
# UI helpers
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
def _spinner(label: str) -> Progress:
|
|
191
|
+
return Progress(
|
|
192
|
+
SpinnerColumn(spinner_name="dots", style="primary"),
|
|
193
|
+
TextColumn(f"[muted]{label}[/muted]"),
|
|
194
|
+
transient=True,
|
|
195
|
+
console=console,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _render_file_table(files: list[str], title: str = "Your Files") -> None:
|
|
200
|
+
table = Table(
|
|
201
|
+
box=box.ROUNDED,
|
|
202
|
+
border_style="cyan",
|
|
203
|
+
header_style="bold cyan",
|
|
204
|
+
title=f"[bold white]{title}[/bold white]",
|
|
205
|
+
title_justify="left",
|
|
206
|
+
show_lines=False,
|
|
207
|
+
padding=(0, 1),
|
|
208
|
+
)
|
|
209
|
+
table.add_column("#", style="muted", width=4, justify="right")
|
|
210
|
+
table.add_column("Filename", style="filename", min_width=30)
|
|
211
|
+
for i, name in enumerate(files, 1):
|
|
212
|
+
table.add_row(str(i), name)
|
|
213
|
+
console.print()
|
|
214
|
+
console.print(table)
|
|
215
|
+
console.print()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
# Typer app
|
|
220
|
+
# ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
app = typer.Typer(
|
|
223
|
+
name="pockit",
|
|
224
|
+
help="☁ pockit-cloud — your files, anywhere.",
|
|
225
|
+
add_completion=False,
|
|
226
|
+
rich_markup_mode="rich",
|
|
227
|
+
pretty_exceptions_enable=False,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ---------------------------------------------------------------------------
|
|
232
|
+
# Commands
|
|
233
|
+
# ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
@app.command()
|
|
236
|
+
def login():
|
|
237
|
+
"""🔑 Log in to pockit-cloud (saves session locally)."""
|
|
238
|
+
_clear_creds() # force re-auth even if already cached
|
|
239
|
+
ensure_logged_in()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@app.command()
|
|
243
|
+
def logout():
|
|
244
|
+
"""🚪 Clear your saved session."""
|
|
245
|
+
_clear_creds()
|
|
246
|
+
console.print("\n [success]✓ Logged out.[/success]\n")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@app.command()
|
|
250
|
+
def ls():
|
|
251
|
+
"""📂 List all files in your cloud storage."""
|
|
252
|
+
creds = ensure_logged_in()
|
|
253
|
+
client = get_client()
|
|
254
|
+
|
|
255
|
+
with _spinner("Fetching file list…"):
|
|
256
|
+
result = client.predict(creds["user_id"], creds["password"], api_name="/list_files")
|
|
257
|
+
|
|
258
|
+
status = extract_status(result, 0)
|
|
259
|
+
files = extract_files(result[1])
|
|
260
|
+
|
|
261
|
+
if "invalid" in status.lower():
|
|
262
|
+
console.print(f"\n [error]✗ {status}[/error]\n")
|
|
263
|
+
_clear_creds()
|
|
264
|
+
raise typer.Exit(code=1)
|
|
265
|
+
|
|
266
|
+
if not files:
|
|
267
|
+
console.print(Panel(
|
|
268
|
+
"[muted]Your cloud storage is empty.\nUpload something with [bold]pockit upload[/bold].[/muted]",
|
|
269
|
+
title="[header] ☁ Your Files [/header]",
|
|
270
|
+
border_style="cyan",
|
|
271
|
+
padding=(1, 2),
|
|
272
|
+
))
|
|
273
|
+
return
|
|
274
|
+
|
|
275
|
+
_render_file_table(files, title=f"☁ Your Files ({len(files)} item{'s' if len(files) != 1 else ''})")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@app.command()
|
|
279
|
+
def download():
|
|
280
|
+
"""📥 Interactively select and download a file."""
|
|
281
|
+
creds = ensure_logged_in()
|
|
282
|
+
client = get_client()
|
|
283
|
+
|
|
284
|
+
# 1. Fetch file list
|
|
285
|
+
with _spinner("Fetching file list…"):
|
|
286
|
+
response = client.predict(creds["user_id"], creds["password"], api_name="/list_files")
|
|
287
|
+
|
|
288
|
+
files = extract_files(response[1])
|
|
289
|
+
|
|
290
|
+
if not files:
|
|
291
|
+
console.print("\n [error]✗ No files found in your account.[/error]\n")
|
|
292
|
+
return
|
|
293
|
+
|
|
294
|
+
# 2. Interactive autocomplete picker
|
|
295
|
+
console.print()
|
|
296
|
+
selected_file = questionary.autocomplete(
|
|
297
|
+
"🔍 Select file to download:",
|
|
298
|
+
choices=files,
|
|
299
|
+
style=QSTYLE,
|
|
300
|
+
).ask()
|
|
301
|
+
|
|
302
|
+
if not selected_file:
|
|
303
|
+
return
|
|
304
|
+
|
|
305
|
+
# 3. Ask where to save
|
|
306
|
+
default_dest = str(Path.cwd() / selected_file)
|
|
307
|
+
dest_str = questionary.text(
|
|
308
|
+
"💾 Save as:",
|
|
309
|
+
default=default_dest,
|
|
310
|
+
style=QSTYLE,
|
|
311
|
+
).ask()
|
|
312
|
+
|
|
313
|
+
if not dest_str:
|
|
314
|
+
return
|
|
315
|
+
|
|
316
|
+
destination = Path(dest_str.strip())
|
|
317
|
+
|
|
318
|
+
# 4. Download
|
|
319
|
+
console.print(f"\n [muted]Requesting[/muted] [filename]{selected_file}[/filename][muted]…[/muted]")
|
|
320
|
+
|
|
321
|
+
with _spinner(f"Downloading {selected_file}…"):
|
|
322
|
+
result = client.predict(
|
|
323
|
+
creds["user_id"],
|
|
324
|
+
creds["password"],
|
|
325
|
+
selected_file,
|
|
326
|
+
api_name="/download_file",
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
# /download_file now returns a filepath (temp file on the Gradio server)
|
|
330
|
+
tmp_path = result[0] if isinstance(result, (list, tuple)) else result
|
|
331
|
+
tmp_path = str(tmp_path).strip() if tmp_path else ""
|
|
332
|
+
|
|
333
|
+
# 5. Validate the returned path
|
|
334
|
+
if not tmp_path:
|
|
335
|
+
console.print(f"\n [error]✗ Server did not return a file.[/error]\n")
|
|
336
|
+
return
|
|
337
|
+
|
|
338
|
+
tmp = Path(tmp_path)
|
|
339
|
+
if not tmp.exists():
|
|
340
|
+
console.print(f"\n [error]✗ Downloaded temp file not found: {tmp_path!r}[/error]\n")
|
|
341
|
+
return
|
|
342
|
+
|
|
343
|
+
file_size = tmp.stat().st_size
|
|
344
|
+
|
|
345
|
+
if file_size == 0:
|
|
346
|
+
console.print(
|
|
347
|
+
f"\n [error]✗ Download aborted — server returned an empty file.\n"
|
|
348
|
+
f" Check your credentials or that [filename]{selected_file}[/filename] exists.[/error]\n"
|
|
349
|
+
)
|
|
350
|
+
return
|
|
351
|
+
|
|
352
|
+
# 6. Copy temp file to destination
|
|
353
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
354
|
+
shutil.copy2(tmp_path, destination)
|
|
355
|
+
|
|
356
|
+
console.print(Panel(
|
|
357
|
+
f"[label]File:[/label] [filename]{selected_file}[/filename]\n"
|
|
358
|
+
f"[label]Saved to:[/label] [bold white]{destination}[/bold white]\n"
|
|
359
|
+
f"[label]Size:[/label] {file_size / 1024:.1f} KB",
|
|
360
|
+
title="[header] ⬇ Download Complete [/header]",
|
|
361
|
+
border_style="green",
|
|
362
|
+
padding=(1, 2),
|
|
363
|
+
))
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@app.command()
|
|
367
|
+
def upload():
|
|
368
|
+
"""⬆ Interactively select and upload a local file."""
|
|
369
|
+
creds = ensure_logged_in()
|
|
370
|
+
|
|
371
|
+
# 1. File path with tab-completion
|
|
372
|
+
console.print()
|
|
373
|
+
filepath_str = questionary.path(
|
|
374
|
+
"📁 Select file to upload:",
|
|
375
|
+
style=QSTYLE,
|
|
376
|
+
).ask()
|
|
377
|
+
|
|
378
|
+
if not filepath_str:
|
|
379
|
+
return
|
|
380
|
+
|
|
381
|
+
filepath = Path(filepath_str.strip())
|
|
382
|
+
if not filepath.is_file():
|
|
383
|
+
console.print(f"\n [error]✗ File not found: {filepath}[/error]\n")
|
|
384
|
+
return
|
|
385
|
+
|
|
386
|
+
# 2. Optional custom cloud name
|
|
387
|
+
custom_name = questionary.text(
|
|
388
|
+
"✏️ Custom cloud name (leave blank to keep original):",
|
|
389
|
+
default="",
|
|
390
|
+
style=QSTYLE,
|
|
391
|
+
).ask()
|
|
392
|
+
|
|
393
|
+
if custom_name is None:
|
|
394
|
+
return
|
|
395
|
+
|
|
396
|
+
custom_name = custom_name.strip()
|
|
397
|
+
display_name = custom_name or filepath.name
|
|
398
|
+
file_size_kb = filepath.stat().st_size / 1024
|
|
399
|
+
|
|
400
|
+
# 3. Upload with progress bar
|
|
401
|
+
with Progress(
|
|
402
|
+
SpinnerColumn(spinner_name="dots", style="primary"),
|
|
403
|
+
TextColumn(f"[muted]Uploading[/muted] [filename]{display_name}[/filename]…"),
|
|
404
|
+
BarColumn(bar_width=28, style="cyan", complete_style="green"),
|
|
405
|
+
transient=True,
|
|
406
|
+
console=console,
|
|
407
|
+
) as progress:
|
|
408
|
+
progress.add_task("upload", total=None)
|
|
409
|
+
client = get_client()
|
|
410
|
+
result = client.predict(
|
|
411
|
+
creds["user_id"],
|
|
412
|
+
creds["password"],
|
|
413
|
+
handle_file(str(filepath)),
|
|
414
|
+
custom_name,
|
|
415
|
+
api_name="/upload_file",
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
status = extract_status(result, 0)
|
|
419
|
+
|
|
420
|
+
console.print(Panel(
|
|
421
|
+
f"[label]Local:[/label] [bold white]{filepath}[/bold white]\n"
|
|
422
|
+
f"[label]Name:[/label] [filename]{display_name}[/filename]\n"
|
|
423
|
+
f"[label]Size:[/label] {file_size_kb:.1f} KB\n"
|
|
424
|
+
f"[label]Status:[/label] {status}",
|
|
425
|
+
title="[header] ⬆ Upload Complete [/header]",
|
|
426
|
+
border_style="green",
|
|
427
|
+
padding=(1, 2),
|
|
428
|
+
))
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
@app.command()
|
|
432
|
+
def chpasswd():
|
|
433
|
+
"""🔒 Change your account password."""
|
|
434
|
+
creds = ensure_logged_in()
|
|
435
|
+
|
|
436
|
+
console.print()
|
|
437
|
+
new_password = questionary.password("🔑 New password:", style=QSTYLE).ask()
|
|
438
|
+
if not new_password:
|
|
439
|
+
return
|
|
440
|
+
|
|
441
|
+
confirm = questionary.password("🔑 Confirm new password:", style=QSTYLE).ask()
|
|
442
|
+
if not confirm:
|
|
443
|
+
return
|
|
444
|
+
|
|
445
|
+
if new_password.strip() != confirm.strip():
|
|
446
|
+
console.print("\n [error]✗ Passwords do not match.[/error]\n")
|
|
447
|
+
return
|
|
448
|
+
|
|
449
|
+
new_password = new_password.strip()
|
|
450
|
+
|
|
451
|
+
with _spinner("Updating password…"):
|
|
452
|
+
client = get_client()
|
|
453
|
+
result = client.predict(
|
|
454
|
+
creds["user_id"],
|
|
455
|
+
creds["password"],
|
|
456
|
+
new_password,
|
|
457
|
+
api_name="/change_password",
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
status = extract_status(result, 0)
|
|
461
|
+
ok = "invalid" not in status.lower() and "error" not in status.lower()
|
|
462
|
+
|
|
463
|
+
console.print(Panel(
|
|
464
|
+
f"[label]User:[/label] {creds['user_id']}\n[label]Status:[/label] {status}",
|
|
465
|
+
title=f"[header] {'✓' if ok else '✗'} Password Change [/header]",
|
|
466
|
+
border_style="green" if ok else "red",
|
|
467
|
+
padding=(1, 2),
|
|
468
|
+
))
|
|
469
|
+
|
|
470
|
+
if ok:
|
|
471
|
+
_save_creds(creds["user_id"], new_password)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
@app.command()
|
|
475
|
+
def delete():
|
|
476
|
+
"""🗑 Interactively select and delete a file."""
|
|
477
|
+
creds = ensure_logged_in()
|
|
478
|
+
client = get_client()
|
|
479
|
+
|
|
480
|
+
# 1. Fetch file list
|
|
481
|
+
with _spinner("Fetching file list…"):
|
|
482
|
+
response = client.predict(creds["user_id"], creds["password"], api_name="/list_files")
|
|
483
|
+
|
|
484
|
+
files = extract_files(response[1])
|
|
485
|
+
|
|
486
|
+
if not files:
|
|
487
|
+
console.print("\n [error]✗ No files found in your account.[/error]\n")
|
|
488
|
+
return
|
|
489
|
+
|
|
490
|
+
# 2. Select file
|
|
491
|
+
console.print()
|
|
492
|
+
selected_file = questionary.select(
|
|
493
|
+
"🗑 Select file to delete:",
|
|
494
|
+
choices=files,
|
|
495
|
+
style=QSTYLE,
|
|
496
|
+
).ask()
|
|
497
|
+
|
|
498
|
+
if not selected_file:
|
|
499
|
+
return
|
|
500
|
+
|
|
501
|
+
# 3. Confirm — shown in red so it feels serious
|
|
502
|
+
console.print()
|
|
503
|
+
confirmed = questionary.confirm(
|
|
504
|
+
f"⚠ Permanently delete '{selected_file}'? This cannot be undone.",
|
|
505
|
+
default=False,
|
|
506
|
+
style=questionary.Style([
|
|
507
|
+
("qmark", "fg:#ff5555 bold"),
|
|
508
|
+
("question", "bold"),
|
|
509
|
+
("answer", "fg:#ff5555 bold"),
|
|
510
|
+
]),
|
|
511
|
+
).ask()
|
|
512
|
+
|
|
513
|
+
if not confirmed:
|
|
514
|
+
console.print("\n [muted]Cancelled.[/muted]\n")
|
|
515
|
+
return
|
|
516
|
+
|
|
517
|
+
# 4. Delete
|
|
518
|
+
with _spinner(f"Deleting {selected_file}…"):
|
|
519
|
+
result = client.predict(
|
|
520
|
+
creds["user_id"],
|
|
521
|
+
creds["password"],
|
|
522
|
+
selected_file,
|
|
523
|
+
api_name="/delete_file",
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
status = extract_status(result, 0)
|
|
527
|
+
ok = any(w in status.lower() for w in ("delet", "success", "removed"))
|
|
528
|
+
|
|
529
|
+
console.print(Panel(
|
|
530
|
+
f"[label]File:[/label] [filename]{selected_file}[/filename]\n"
|
|
531
|
+
f"[label]Status:[/label] {status}",
|
|
532
|
+
title=f"[header] {'🗑 Deleted' if ok else '✗ Delete Failed'} [/header]",
|
|
533
|
+
border_style="green" if ok else "red",
|
|
534
|
+
padding=(1, 2),
|
|
535
|
+
))
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
# ---------------------------------------------------------------------------
|
|
539
|
+
# Magic Sync helpers
|
|
540
|
+
# ---------------------------------------------------------------------------
|
|
541
|
+
|
|
542
|
+
SYNC_INTERVAL = 10 # seconds between cloud polls
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _get_sync_folder() -> Path:
|
|
546
|
+
"""Return the platform-appropriate Magic Sync folder, creating it if needed."""
|
|
547
|
+
system = platform.system()
|
|
548
|
+
if system == "Darwin":
|
|
549
|
+
folder = Path.home() / "Desktop" / "Pockit-magicSync"
|
|
550
|
+
elif system == "Windows":
|
|
551
|
+
folder = Path.home() / "Desktop" / "Pockit-magicSync"
|
|
552
|
+
else:
|
|
553
|
+
# Linux: respect XDG_DESKTOP_DIR, fall back to ~/Desktop, then ~/Pockit-magicSync
|
|
554
|
+
xdg = os.environ.get("XDG_DESKTOP_DIR", "")
|
|
555
|
+
if xdg and Path(xdg).is_dir():
|
|
556
|
+
folder = Path(xdg) / "Pockit-magicSync"
|
|
557
|
+
elif (Path.home() / "Desktop").is_dir():
|
|
558
|
+
folder = Path.home() / "Desktop" / "Pockit-magicSync"
|
|
559
|
+
else:
|
|
560
|
+
folder = Path.home() / "Pockit-magicSync"
|
|
561
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
562
|
+
return folder
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _file_hash(path: Path) -> str:
|
|
566
|
+
"""MD5 hash of a local file's contents."""
|
|
567
|
+
h = hashlib.md5()
|
|
568
|
+
with open(path, "rb") as f:
|
|
569
|
+
for chunk in iter(lambda: f.read(65536), b""):
|
|
570
|
+
h.update(chunk)
|
|
571
|
+
return h.hexdigest()
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _local_snapshot(folder: Path) -> dict:
|
|
575
|
+
"""Return {filename: md5_hash} for every file directly inside folder."""
|
|
576
|
+
snap = {}
|
|
577
|
+
for p in folder.iterdir():
|
|
578
|
+
if p.is_file() and not p.name.startswith(".pockit_tmp_"):
|
|
579
|
+
try:
|
|
580
|
+
snap[p.name] = _file_hash(p)
|
|
581
|
+
except OSError:
|
|
582
|
+
pass
|
|
583
|
+
return snap
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _cloud_list_sync(client: Client, creds: dict) -> list:
|
|
587
|
+
"""Return list of filenames currently in the cloud (sync version)."""
|
|
588
|
+
try:
|
|
589
|
+
result = client.predict(creds["user_id"], creds["password"], api_name="/list_files")
|
|
590
|
+
return extract_files(result[1])
|
|
591
|
+
except Exception:
|
|
592
|
+
return []
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _cloud_download_sync(client: Client, creds: dict, filename: str, dest: Path) -> bool:
|
|
596
|
+
"""Download a single cloud file to dest. Returns True on success."""
|
|
597
|
+
try:
|
|
598
|
+
result = client.predict(
|
|
599
|
+
creds["user_id"], creds["password"], filename,
|
|
600
|
+
api_name="/download_file",
|
|
601
|
+
)
|
|
602
|
+
tmp_path = result[0] if isinstance(result, (list, tuple)) else result
|
|
603
|
+
tmp_path = str(tmp_path).strip() if tmp_path else ""
|
|
604
|
+
if not tmp_path or not Path(tmp_path).exists():
|
|
605
|
+
return False
|
|
606
|
+
shutil.copy2(tmp_path, dest)
|
|
607
|
+
return True
|
|
608
|
+
except Exception:
|
|
609
|
+
return False
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _cloud_upload_sync(client: Client, creds: dict, filepath: Path) -> bool:
|
|
613
|
+
"""Upload a local file to the cloud. Returns True on success."""
|
|
614
|
+
try:
|
|
615
|
+
result = client.predict(
|
|
616
|
+
creds["user_id"], creds["password"],
|
|
617
|
+
handle_file(str(filepath)),
|
|
618
|
+
filepath.name,
|
|
619
|
+
api_name="/upload_file",
|
|
620
|
+
)
|
|
621
|
+
status = extract_status(result, 0)
|
|
622
|
+
return "error" not in status.lower() and "invalid" not in status.lower()
|
|
623
|
+
except Exception:
|
|
624
|
+
return False
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _cloud_delete_sync(client: Client, creds: dict, filename: str) -> bool:
|
|
628
|
+
"""Delete a file from the cloud. Returns True on success."""
|
|
629
|
+
try:
|
|
630
|
+
result = client.predict(
|
|
631
|
+
creds["user_id"], creds["password"], filename,
|
|
632
|
+
api_name="/delete_file",
|
|
633
|
+
)
|
|
634
|
+
status = extract_status(result, 0)
|
|
635
|
+
return any(w in status.lower() for w in ("delet", "success", "removed"))
|
|
636
|
+
except Exception:
|
|
637
|
+
return False
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _sync_log(icon: str, style: str, msg: str) -> None:
|
|
641
|
+
ts = time.strftime("%H:%M:%S")
|
|
642
|
+
console.print(f" [muted]{ts}[/muted] {icon} [{style}]{msg}[/{style}]")
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
# ---------------------------------------------------------------------------
|
|
646
|
+
# Magic Sync command
|
|
647
|
+
# ---------------------------------------------------------------------------
|
|
648
|
+
|
|
649
|
+
@app.command()
|
|
650
|
+
def magicsync():
|
|
651
|
+
"""✨ Magic Sync — keep a local folder in sync with your cloud 24/7."""
|
|
652
|
+
creds = ensure_logged_in()
|
|
653
|
+
folder = _get_sync_folder()
|
|
654
|
+
|
|
655
|
+
console.print(Panel(
|
|
656
|
+
f"[label]Folder:[/label] [bold white]{folder}[/bold white]\n"
|
|
657
|
+
f"[label]User:[/label] [bold white]{creds['user_id']}[/bold white]\n\n"
|
|
658
|
+
f"[muted]Changes in the folder sync to the cloud, and vice versa.\n"
|
|
659
|
+
f"Press [bold]Ctrl+C[/bold] to stop.[/muted]",
|
|
660
|
+
title="[header] ✨ Magic Sync Active [/header]",
|
|
661
|
+
border_style="cyan",
|
|
662
|
+
padding=(1, 2),
|
|
663
|
+
))
|
|
664
|
+
|
|
665
|
+
# Graceful Ctrl+C
|
|
666
|
+
stop = {"flag": False}
|
|
667
|
+
|
|
668
|
+
def _handle_sigint(sig, frame):
|
|
669
|
+
stop["flag"] = True
|
|
670
|
+
|
|
671
|
+
signal.signal(signal.SIGINT, _handle_sigint)
|
|
672
|
+
|
|
673
|
+
# ── Initial pull: download everything from cloud into the folder ─────────
|
|
674
|
+
client = get_client()
|
|
675
|
+
initial_cloud = _cloud_list_sync(client, creds)
|
|
676
|
+
for fname in initial_cloud:
|
|
677
|
+
dest = folder / fname
|
|
678
|
+
if not dest.exists():
|
|
679
|
+
ok = _cloud_download_sync(client, creds, fname, dest)
|
|
680
|
+
if ok:
|
|
681
|
+
_sync_log("⬇", "success", f"Initial pull: {fname}")
|
|
682
|
+
else:
|
|
683
|
+
_sync_log("✗", "error", f"Failed initial pull: {fname}")
|
|
684
|
+
|
|
685
|
+
# Seed state after initial pull
|
|
686
|
+
prev_local = _local_snapshot(folder)
|
|
687
|
+
prev_cloud = list(initial_cloud)
|
|
688
|
+
|
|
689
|
+
_sync_log("✓", "success", "Initial sync complete — watching for changes…")
|
|
690
|
+
console.print()
|
|
691
|
+
|
|
692
|
+
# ── Main loop ────────────────────────────────────────────────────────────
|
|
693
|
+
while not stop["flag"]:
|
|
694
|
+
# Sleep in small increments so Ctrl+C is responsive
|
|
695
|
+
for _ in range(SYNC_INTERVAL * 10):
|
|
696
|
+
if stop["flag"]:
|
|
697
|
+
break
|
|
698
|
+
time.sleep(0.1)
|
|
699
|
+
|
|
700
|
+
if stop["flag"]:
|
|
701
|
+
break
|
|
702
|
+
|
|
703
|
+
try:
|
|
704
|
+
client = get_client()
|
|
705
|
+
except Exception:
|
|
706
|
+
_sync_log("✗", "error", "Could not reach backend, retrying…")
|
|
707
|
+
continue
|
|
708
|
+
|
|
709
|
+
curr_local = _local_snapshot(folder)
|
|
710
|
+
curr_cloud = _cloud_list_sync(client, creds)
|
|
711
|
+
curr_cloud_set = set(curr_cloud)
|
|
712
|
+
prev_cloud_set = set(prev_cloud)
|
|
713
|
+
prev_local_set = set(prev_local)
|
|
714
|
+
curr_local_set = set(curr_local)
|
|
715
|
+
|
|
716
|
+
# Track files uploaded this cycle to avoid re-downloading them
|
|
717
|
+
uploaded_this_cycle: set = set()
|
|
718
|
+
|
|
719
|
+
# ── LOCAL → CLOUD ──────────────────────────────────────────────────
|
|
720
|
+
|
|
721
|
+
# New local files → upload
|
|
722
|
+
for fname in curr_local_set - prev_local_set:
|
|
723
|
+
ok = _cloud_upload_sync(client, creds, folder / fname)
|
|
724
|
+
uploaded_this_cycle.add(fname)
|
|
725
|
+
_sync_log("⬆", "success" if ok else "error",
|
|
726
|
+
f"{'Uploaded' if ok else 'Upload failed'}: {fname}")
|
|
727
|
+
|
|
728
|
+
# Modified local files → re-upload
|
|
729
|
+
for fname in curr_local_set & prev_local_set:
|
|
730
|
+
if curr_local[fname] != prev_local[fname]:
|
|
731
|
+
ok = _cloud_upload_sync(client, creds, folder / fname)
|
|
732
|
+
uploaded_this_cycle.add(fname)
|
|
733
|
+
_sync_log("⬆", "success" if ok else "error",
|
|
734
|
+
f"{'Updated cloud' if ok else 'Update failed'}: {fname}")
|
|
735
|
+
|
|
736
|
+
# Locally deleted files → delete from cloud
|
|
737
|
+
for fname in prev_local_set - curr_local_set:
|
|
738
|
+
if fname in curr_cloud_set:
|
|
739
|
+
ok = _cloud_delete_sync(client, creds, fname)
|
|
740
|
+
_sync_log("🗑", "warning" if ok else "error",
|
|
741
|
+
f"{'Deleted from cloud' if ok else 'Cloud delete failed'}: {fname}")
|
|
742
|
+
|
|
743
|
+
# ── CLOUD → LOCAL ──────────────────────────────────────────────────
|
|
744
|
+
|
|
745
|
+
# New cloud files → download
|
|
746
|
+
for fname in curr_cloud_set - prev_cloud_set:
|
|
747
|
+
if fname not in curr_local_set:
|
|
748
|
+
ok = _cloud_download_sync(client, creds, fname, folder / fname)
|
|
749
|
+
_sync_log("⬇", "success" if ok else "error",
|
|
750
|
+
f"{'Downloaded' if ok else 'Download failed'}: {fname}")
|
|
751
|
+
|
|
752
|
+
# Cloud-deleted files → delete locally
|
|
753
|
+
for fname in prev_cloud_set - curr_cloud_set:
|
|
754
|
+
local_path = folder / fname
|
|
755
|
+
if local_path.exists() and fname not in uploaded_this_cycle:
|
|
756
|
+
local_path.unlink()
|
|
757
|
+
_sync_log("🗑", "warning", f"Removed locally (deleted from cloud): {fname}")
|
|
758
|
+
|
|
759
|
+
# Files present in both cloud and local — check if cloud version changed
|
|
760
|
+
for fname in curr_cloud_set & curr_local_set - uploaded_this_cycle:
|
|
761
|
+
tmp_dest = folder / f".pockit_tmp_{fname}"
|
|
762
|
+
ok = _cloud_download_sync(client, creds, fname, tmp_dest)
|
|
763
|
+
if ok and tmp_dest.exists():
|
|
764
|
+
try:
|
|
765
|
+
remote_hash = _file_hash(tmp_dest)
|
|
766
|
+
local_hash = curr_local.get(fname, "")
|
|
767
|
+
if remote_hash != local_hash:
|
|
768
|
+
shutil.move(str(tmp_dest), str(folder / fname))
|
|
769
|
+
_sync_log("⬇", "success", f"Updated locally (cloud changed): {fname}")
|
|
770
|
+
else:
|
|
771
|
+
tmp_dest.unlink(missing_ok=True)
|
|
772
|
+
except OSError:
|
|
773
|
+
tmp_dest.unlink(missing_ok=True)
|
|
774
|
+
elif tmp_dest.exists():
|
|
775
|
+
tmp_dest.unlink(missing_ok=True)
|
|
776
|
+
|
|
777
|
+
# ── Update state ───────────────────────────────────────────────────
|
|
778
|
+
prev_local = _local_snapshot(folder)
|
|
779
|
+
prev_cloud = curr_cloud
|
|
780
|
+
|
|
781
|
+
console.print("\n [success]✓ Magic Sync stopped.[/success]\n")
|
|
782
|
+
|
|
783
|
+
# ---------------------------------------------------------------------------
|
|
784
|
+
# Entry point
|
|
785
|
+
# ---------------------------------------------------------------------------
|
|
786
|
+
|
|
787
|
+
if __name__ == "__main__":
|
|
788
|
+
app()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pockit
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pockit"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "A modern CLI for pockit-cloud storage"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"typer>=0.9",
|
|
12
|
+
"rich>=13",
|
|
13
|
+
"gradio-client>=1.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
pockit = "pockit.main:app"
|
|
18
|
+
|
|
19
|
+
[tool.setuptools.packages.find]
|
|
20
|
+
where = ["."]
|
|
21
|
+
include = ["pockit*"]
|
pockit-0.2.0/setup.cfg
ADDED