obsideo-cli 0.2.4__tar.gz → 0.2.6__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.
Files changed (24) hide show
  1. {obsideo_cli-0.2.4 → obsideo_cli-0.2.6}/PKG-INFO +1 -1
  2. {obsideo_cli-0.2.4 → obsideo_cli-0.2.6}/obsideo/cli.py +106 -14
  3. {obsideo_cli-0.2.4 → obsideo_cli-0.2.6}/obsideo/sync.py +17 -10
  4. {obsideo_cli-0.2.4 → obsideo_cli-0.2.6}/obsideo_cli.egg-info/PKG-INFO +1 -1
  5. {obsideo_cli-0.2.4 → obsideo_cli-0.2.6}/pyproject.toml +1 -1
  6. {obsideo_cli-0.2.4 → obsideo_cli-0.2.6}/tests/test_cli.py +55 -1
  7. {obsideo_cli-0.2.4 → obsideo_cli-0.2.6}/README.md +0 -0
  8. {obsideo_cli-0.2.4 → obsideo_cli-0.2.6}/obsideo/__init__.py +0 -0
  9. {obsideo_cli-0.2.4 → obsideo_cli-0.2.6}/obsideo/__main__.py +0 -0
  10. {obsideo_cli-0.2.4 → obsideo_cli-0.2.6}/obsideo/manifest.py +0 -0
  11. {obsideo_cli-0.2.4 → obsideo_cli-0.2.6}/obsideo_cli.egg-info/SOURCES.txt +0 -0
  12. {obsideo_cli-0.2.4 → obsideo_cli-0.2.6}/obsideo_cli.egg-info/dependency_links.txt +0 -0
  13. {obsideo_cli-0.2.4 → obsideo_cli-0.2.6}/obsideo_cli.egg-info/entry_points.txt +0 -0
  14. {obsideo_cli-0.2.4 → obsideo_cli-0.2.6}/obsideo_cli.egg-info/requires.txt +0 -0
  15. {obsideo_cli-0.2.4 → obsideo_cli-0.2.6}/obsideo_cli.egg-info/top_level.txt +0 -0
  16. {obsideo_cli-0.2.4 → obsideo_cli-0.2.6}/obsideo_core/__init__.py +0 -0
  17. {obsideo_cli-0.2.4 → obsideo_cli-0.2.6}/obsideo_core/config.py +0 -0
  18. {obsideo_cli-0.2.4 → obsideo_cli-0.2.6}/obsideo_core/crypto.py +0 -0
  19. {obsideo_cli-0.2.4 → obsideo_cli-0.2.6}/obsideo_core/identity.py +0 -0
  20. {obsideo_cli-0.2.4 → obsideo_cli-0.2.6}/obsideo_core/login.py +0 -0
  21. {obsideo_cli-0.2.4 → obsideo_cli-0.2.6}/obsideo_core/names.py +0 -0
  22. {obsideo_cli-0.2.4 → obsideo_cli-0.2.6}/obsideo_core/storage.py +0 -0
  23. {obsideo_cli-0.2.4 → obsideo_cli-0.2.6}/setup.cfg +0 -0
  24. {obsideo_cli-0.2.4 → obsideo_cli-0.2.6}/tests/test_core.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: obsideo-cli
3
- Version: 0.2.4
3
+ Version: 0.2.6
4
4
  Summary: Obsideo Cloud - encrypted storage we can't read. Save, browse, and sync whatever you want, from your terminal.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://obsideo.io
@@ -200,8 +200,15 @@ def check_for_update() -> None:
200
200
  except (EOFError, KeyboardInterrupt):
201
201
  print(file=sys.stderr)
202
202
  return
203
+ manual = f"pip install -U --no-cache-dir {config.PACKAGE}"
203
204
  if ans not in ("", "y", "yes"):
204
- print(f"Skipped. Update later with: pip install -U {config.PACKAGE}", file=sys.stderr)
205
+ print(f"Skipped. Update later with: {manual}", file=sys.stderr)
206
+ return
207
+ if sys.platform == "win32":
208
+ # Windows file-locks the running obsideo.exe, so pip can't replace it from
209
+ # inside a live session (WinError 32). Hand over the one command to run.
210
+ print(f"\nWindows can't replace the CLI while it's running. To finish, close\n"
211
+ f"obsideo and run:\n {manual}", file=sys.stderr)
205
212
  return
206
213
  print(f"Updating to {latest}...", file=sys.stderr)
207
214
  try:
@@ -209,7 +216,7 @@ def check_for_update() -> None:
209
216
  [sys.executable, "-m", "pip", "install", "-U", "--no-cache-dir", config.PACKAGE]
210
217
  ).returncode
211
218
  except Exception as e:
212
- print(f"Update failed: {e}\nTry manually: pip install -U {config.PACKAGE}", file=sys.stderr)
219
+ print(f"Update failed: {e}\nTry manually: {manual}", file=sys.stderr)
213
220
  return
214
221
  if rc == 0:
215
222
  print(f"\nUpdated to {latest}. Restart `obsideo` to use the new version.", file=sys.stderr)
@@ -303,6 +310,13 @@ def run_login(url: str | None = None) -> bool:
303
310
  print("Note: storage activation is finishing rollout; if an upload fails, retry shortly.")
304
311
  print("Your files are encrypted with a local key. Back it up:")
305
312
  print(f" {crypto.DATA_KEY_FILE}")
313
+ # Create the sync folder now so it's ready (never make the user mkdir it).
314
+ try:
315
+ from obsideo import sync as _sync
316
+ sd = _sync.ensure_sync_dir()
317
+ print(f"Your sync folder is ready (drop files here, then `sync push`):\n {sd}")
318
+ except Exception:
319
+ pass
306
320
  print("Type 'obsideo' to open the shell, or 'obsideo put <file>' to store something.")
307
321
  return True
308
322
 
@@ -534,27 +548,105 @@ class ObsideoShell(cmd.Cmd):
534
548
 
535
549
  # ── account ───────────────────────────────────────────────────────────────
536
550
  def do_account(self, arg):
537
- """Show your plan: storage used vs. your free quota."""
551
+ """Show your account: plan, storage used, and where your files/keys live."""
538
552
  if not self._require_login():
539
553
  return
540
- usage = _fetch_usage()
554
+ from obsideo import sync as sync_mod
541
555
  print()
542
556
  print(" -- Obsideo account --------------------------")
543
557
  print(" Plan: Free")
544
- if usage:
545
- used, quota = usage["used_bytes"], usage["quota_bytes"]
546
- pct = usage.get("percent_used", (used / quota if quota else 0))
547
- print(f" Used: {_human(used)} / {_human(quota)} ({pct*100:.1f}%)")
548
- bar_len = 30
549
- filled = int(bar_len * min(pct, 1.0))
550
- print(f" [{'#'*filled}{'-'*(bar_len-filled)}]")
551
- if pct >= 0.8:
552
- print(" You're near your limit - reply to any Obsideo email to upgrade.")
558
+ if not config.account_token():
559
+ # No shim token (e.g. creds set via env / pre-login account): we can't
560
+ # query usage. Don't claim the service is down — tell them how to link it.
561
+ print(" Usage: sign in with `obsideo login` to see usage details")
553
562
  else:
554
- print(" (usage unavailable - is the account service reachable?)")
563
+ usage = _fetch_usage()
564
+ if usage:
565
+ used, quota = usage["used_bytes"], usage["quota_bytes"]
566
+ pct = usage.get("percent_used", (used / quota if quota else 0))
567
+ print(f" Used: {_human(used)} / {_human(quota)} ({pct*100:.1f}%)")
568
+ bar_len = 30
569
+ filled = int(bar_len * min(pct, 1.0))
570
+ print(f" [{'#'*filled}{'-'*(bar_len-filled)}]")
571
+ if pct >= 0.8:
572
+ print(" You're near your limit - reply to any Obsideo email to upgrade.")
573
+ else:
574
+ print(" Usage: couldn't reach the account service - try again shortly")
575
+ print(f" Files: bucket '{storage.bucket()}' · sync folder {sync_mod.ensure_sync_dir()}")
576
+ print(f" Keys: {config.CONFIG_DIR} (back up data.key)")
555
577
  print(" ---------------------------------------------")
556
578
  print()
557
579
 
580
+ # ── about / faq / messages ────────────────────────────────────────────────
581
+ def do_about(self, arg):
582
+ """What Obsideo is."""
583
+ print("""
584
+ OBSIDEO DRIVE - encrypted storage we can't read.
585
+
586
+ Your files are encrypted on your device (AES-256-GCM) before they ever leave
587
+ it, then stored across three independent providers (RF=3). Obsideo's servers
588
+ only ever see ciphertext - never your filenames, never your data.
589
+
590
+ - Free: 3 GB, no card, no expiry.
591
+ - Your keys live only on your machine (~/.obsideo). Back up data.key - lose it
592
+ and the data is unrecoverable by design. That's the point: not even we can read it.
593
+ - Install / update: pip install -U obsideo-cli More: https://obsideo.io
594
+ """)
595
+
596
+ def do_faq(self, arg):
597
+ """Frequently asked questions."""
598
+ print("""
599
+ -- Obsideo FAQ --
600
+
601
+ Q: Can Obsideo read my files?
602
+ A: No. They're encrypted on your device before upload; we only store ciphertext.
603
+
604
+ Q: What's free?
605
+ A: 3 GB, no credit card, no expiry.
606
+
607
+ Q: What if I lose my key?
608
+ A: Your key is ~/.obsideo/data.key - back it up. Without it the data can't be
609
+ decrypted by anyone, including us.
610
+
611
+ Q: Two folders - what's the difference?
612
+ A: ~/.obsideo = your keys + settings (don't touch).
613
+ ~/obsideo-sync = your SYNC folder - files you put here sync with `sync push`.
614
+
615
+ Q: What does `sync` do?
616
+ A: Mirrors your sync folder to/from the cloud. `sync push` uploads new/changed
617
+ files, `sync pull` downloads, `sync status` shows what's pending. It's manual
618
+ (you run it) and covers files in the top of the folder.
619
+
620
+ Q: How do I change settings (sync folder, encryption)?
621
+ A: `config` shows them; `config set sync_dir <path>` / `config set encrypt_names false`.
622
+
623
+ Q: More space?
624
+ A: Reply to any Obsideo email to upgrade. (A referral program is coming.)
625
+
626
+ Q: Updating?
627
+ A: pip install -U obsideo-cli - the CLI also nudges you when an update is out.
628
+ """)
629
+
630
+ def do_messages(self, arg):
631
+ """Messages from the Obsideo team."""
632
+ try:
633
+ req = urllib.request.Request(
634
+ f"{config.signup_url()}/v1/notices",
635
+ headers={"User-Agent": config.USER_AGENT},
636
+ )
637
+ with urllib.request.urlopen(req, timeout=10, context=config.ssl_context()) as resp:
638
+ notices = json.loads(resp.read().decode()).get("notices", [])
639
+ except Exception:
640
+ print("\n Couldn't reach the message service - try again shortly.\n")
641
+ return
642
+ if not notices:
643
+ print("\n No messages from the Obsideo team right now.\n")
644
+ return
645
+ print("\n -- Messages from the Obsideo team --")
646
+ for n in notices:
647
+ print(f" - {n.get('body', '').strip()}")
648
+ print()
649
+
558
650
  # ── sync ──────────────────────────────────────────────────────────────────
559
651
  def do_sync(self, arg):
560
652
  """Sync your local folder with Obsideo. Usage: sync push|pull|status"""
@@ -18,18 +18,24 @@ def _sync_dir() -> Path:
18
18
  return Path(config.load_config().get("sync_dir", str(Path.home() / "obsideo-sync")))
19
19
 
20
20
 
21
+ def ensure_sync_dir() -> Path:
22
+ """Return the sync folder, creating it if needed. Called on login and by every
23
+ sync command so a user never has to make the folder by hand — it just exists."""
24
+ sd = _sync_dir()
25
+ sd.mkdir(parents=True, exist_ok=True)
26
+ return sd
27
+
28
+
21
29
  def _remote_key(name: str) -> str:
22
30
  return f"{REMOTE_PREFIX}{name}"
23
31
 
24
32
 
25
33
  def sync_status() -> dict:
26
- sync_dir = _sync_dir()
34
+ sync_dir = ensure_sync_dir()
27
35
  entries = manifest.get_all()
28
36
  status = {"to_push": [], "to_pull": [], "synced": []}
29
37
 
30
- local_files = {}
31
- if sync_dir.exists():
32
- local_files = {f.name: f for f in sync_dir.iterdir() if f.is_file()}
38
+ local_files = {f.name: f for f in sync_dir.iterdir() if f.is_file()}
33
39
 
34
40
  for name, f in local_files.items():
35
41
  local_hash = manifest.file_sha256(f)
@@ -53,17 +59,19 @@ def sync_status() -> dict:
53
59
 
54
60
 
55
61
  def push(verbose: bool = True) -> int:
56
- sync_dir = _sync_dir()
57
- if not sync_dir.exists():
62
+ sync_dir = ensure_sync_dir()
63
+ files = [p for p in sync_dir.iterdir() if p.is_file()]
64
+ if not files:
58
65
  if verbose:
59
- print(f"Sync folder does not exist: {sync_dir}")
66
+ print(f" Your sync folder is empty:\n {sync_dir}\n"
67
+ f" Drop files in there, then run `sync push` again.")
60
68
  return 0
61
69
 
62
70
  do_encrypt = config.load_config().get("encrypt", True)
63
71
  entries = manifest.get_all()
64
72
  pushed = 0
65
73
 
66
- for f in (p for p in sync_dir.iterdir() if p.is_file()):
74
+ for f in files:
67
75
  local_hash = manifest.file_sha256(f)
68
76
  entry = entries.get(f.name)
69
77
  if entry and entry.get("local_hash") == local_hash:
@@ -87,8 +95,7 @@ def push(verbose: bool = True) -> int:
87
95
 
88
96
 
89
97
  def pull(verbose: bool = True) -> int:
90
- sync_dir = _sync_dir()
91
- sync_dir.mkdir(parents=True, exist_ok=True)
98
+ sync_dir = ensure_sync_dir()
92
99
 
93
100
  try:
94
101
  remote = storage.list_prefix(REMOTE_PREFIX)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: obsideo-cli
3
- Version: 0.2.4
3
+ Version: 0.2.6
4
4
  Summary: Obsideo Cloud - encrypted storage we can't read. Save, browse, and sync whatever you want, from your terminal.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://obsideo.io
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "obsideo-cli"
7
- version = "0.2.4"
7
+ version = "0.2.6"
8
8
  description = "Obsideo Cloud - encrypted storage we can't read. Save, browse, and sync whatever you want, from your terminal."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -213,8 +213,9 @@ def test_update_check_prompts_and_respects_no(monkeypatch, capsys):
213
213
  assert ran == [] # declined -> no pip invocation
214
214
 
215
215
 
216
- def test_update_check_runs_pip_on_yes(monkeypatch, capsys):
216
+ def test_update_check_runs_pip_on_yes_posix(monkeypatch, capsys):
217
217
  monkeypatch.setattr(cli.sys.stdout, "isatty", lambda: True, raising=False)
218
+ monkeypatch.setattr(cli.sys, "platform", "linux") # in-process upgrade path
218
219
  monkeypatch.delenv("OBSIDEO_NO_UPDATE_CHECK", raising=False)
219
220
  monkeypatch.setattr(cli.config, "VERSION", "0.2.1")
220
221
  monkeypatch.setattr(cli, "_latest_pypi_version", lambda: "0.2.2")
@@ -228,3 +229,56 @@ def test_update_check_runs_pip_on_yes(monkeypatch, capsys):
228
229
  cli.check_for_update()
229
230
  assert e.value.code == 0
230
231
  assert calls and calls[0][1:] == ["-m", "pip", "install", "-U", "--no-cache-dir", "obsideo-cli"]
232
+
233
+
234
+ def test_update_check_windows_prints_command_not_pip(monkeypatch, capsys):
235
+ # On Windows the running .exe is locked; "yes" must NOT run pip in-process,
236
+ # it prints the command instead (no WinError 32, no doomed self-replace).
237
+ monkeypatch.setattr(cli.sys.stdout, "isatty", lambda: True, raising=False)
238
+ monkeypatch.setattr(cli.sys, "platform", "win32")
239
+ monkeypatch.delenv("OBSIDEO_NO_UPDATE_CHECK", raising=False)
240
+ monkeypatch.setattr(cli.config, "VERSION", "0.2.1")
241
+ monkeypatch.setattr(cli, "_latest_pypi_version", lambda: "0.2.2")
242
+ monkeypatch.setattr("builtins.input", lambda *a: "y")
243
+ ran = []
244
+ monkeypatch.setattr(cli.subprocess, "run", lambda *a, **k: ran.append(a))
245
+ cli.check_for_update() # returns, does not SystemExit
246
+ err = capsys.readouterr().err
247
+ assert ran == [] # never attempts the locked self-replace
248
+ assert "pip install -U --no-cache-dir obsideo-cli" in err
249
+
250
+
251
+ # ── sync auto-setup + info commands (0.2.6) ───────────────────────────────────
252
+
253
+ def test_ensure_sync_dir_creates(tmp_path, monkeypatch):
254
+ from obsideo import sync
255
+ target = tmp_path / "mysync"
256
+ monkeypatch.setattr(sync, "_sync_dir", lambda: target)
257
+ assert not target.exists()
258
+ sd = sync.ensure_sync_dir()
259
+ assert sd == target and target.is_dir() # auto-created, no manual mkdir
260
+
261
+
262
+ def test_push_empty_folder_is_friendly_noop(tmp_path, monkeypatch, capsys):
263
+ from obsideo import sync
264
+ monkeypatch.setattr(sync, "_sync_dir", lambda: tmp_path / "s")
265
+ n = sync.push(verbose=True)
266
+ assert n == 0
267
+ out = capsys.readouterr().out.lower()
268
+ assert "empty" in out and "sync push" in out # guides instead of erroring
269
+
270
+
271
+ def test_about_and_faq_print(capsys):
272
+ sh = cli.ObsideoShell()
273
+ sh.do_about("")
274
+ sh.do_faq("")
275
+ out = capsys.readouterr().out
276
+ assert "OBSIDEO DRIVE" in out and "FAQ" in out and "obsideo-sync" in out
277
+
278
+
279
+ def test_messages_handles_unreachable(monkeypatch, capsys):
280
+ def boom(*a, **k):
281
+ raise OSError("network down")
282
+ monkeypatch.setattr(cli.urllib.request, "urlopen", boom)
283
+ cli.ObsideoShell().do_messages("")
284
+ assert "couldn't reach" in capsys.readouterr().out.lower()
File without changes
File without changes