obsideo-cli 0.2.5__tar.gz → 0.2.7__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.5 → obsideo_cli-0.2.7}/PKG-INFO +1 -1
  2. {obsideo_cli-0.2.5 → obsideo_cli-0.2.7}/obsideo/cli.py +107 -14
  3. {obsideo_cli-0.2.5 → obsideo_cli-0.2.7}/obsideo/sync.py +154 -122
  4. {obsideo_cli-0.2.5 → obsideo_cli-0.2.7}/obsideo_cli.egg-info/PKG-INFO +1 -1
  5. {obsideo_cli-0.2.5 → obsideo_cli-0.2.7}/pyproject.toml +1 -1
  6. {obsideo_cli-0.2.5 → obsideo_cli-0.2.7}/tests/test_cli.py +49 -0
  7. {obsideo_cli-0.2.5 → obsideo_cli-0.2.7}/README.md +0 -0
  8. {obsideo_cli-0.2.5 → obsideo_cli-0.2.7}/obsideo/__init__.py +0 -0
  9. {obsideo_cli-0.2.5 → obsideo_cli-0.2.7}/obsideo/__main__.py +0 -0
  10. {obsideo_cli-0.2.5 → obsideo_cli-0.2.7}/obsideo/manifest.py +0 -0
  11. {obsideo_cli-0.2.5 → obsideo_cli-0.2.7}/obsideo_cli.egg-info/SOURCES.txt +0 -0
  12. {obsideo_cli-0.2.5 → obsideo_cli-0.2.7}/obsideo_cli.egg-info/dependency_links.txt +0 -0
  13. {obsideo_cli-0.2.5 → obsideo_cli-0.2.7}/obsideo_cli.egg-info/entry_points.txt +0 -0
  14. {obsideo_cli-0.2.5 → obsideo_cli-0.2.7}/obsideo_cli.egg-info/requires.txt +0 -0
  15. {obsideo_cli-0.2.5 → obsideo_cli-0.2.7}/obsideo_cli.egg-info/top_level.txt +0 -0
  16. {obsideo_cli-0.2.5 → obsideo_cli-0.2.7}/obsideo_core/__init__.py +0 -0
  17. {obsideo_cli-0.2.5 → obsideo_cli-0.2.7}/obsideo_core/config.py +0 -0
  18. {obsideo_cli-0.2.5 → obsideo_cli-0.2.7}/obsideo_core/crypto.py +0 -0
  19. {obsideo_cli-0.2.5 → obsideo_cli-0.2.7}/obsideo_core/identity.py +0 -0
  20. {obsideo_cli-0.2.5 → obsideo_cli-0.2.7}/obsideo_core/login.py +0 -0
  21. {obsideo_cli-0.2.5 → obsideo_cli-0.2.7}/obsideo_core/names.py +0 -0
  22. {obsideo_cli-0.2.5 → obsideo_cli-0.2.7}/obsideo_core/storage.py +0 -0
  23. {obsideo_cli-0.2.5 → obsideo_cli-0.2.7}/setup.cfg +0 -0
  24. {obsideo_cli-0.2.5 → obsideo_cli-0.2.7}/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.5
3
+ Version: 0.2.7
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
@@ -310,13 +310,28 @@ def run_login(url: str | None = None) -> bool:
310
310
  print("Note: storage activation is finishing rollout; if an upload fails, retry shortly.")
311
311
  print("Your files are encrypted with a local key. Back it up:")
312
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
313
320
  print("Type 'obsideo' to open the shell, or 'obsideo put <file>' to store something.")
314
321
  return True
315
322
 
316
323
 
317
324
  class ObsideoShell(cmd.Cmd):
318
- intro = ("\n Obsideo - encrypted storage we can't read.\n"
319
- " Type 'help' for commands, 'exit' to quit.\n")
325
+ intro = (
326
+ "\n Obsideo - encrypted storage we can't read.\n\n"
327
+ " Common commands:\n"
328
+ " put <file> / get <name> upload / download\n"
329
+ " ls / cd / mkdir browse your files\n"
330
+ " sync push / pull / status mirror your sync folder\n"
331
+ " account your plan and usage\n"
332
+ " about / faq / messages learn more / team news\n\n"
333
+ " Type 'help <command>' for details (e.g. 'help sync'), or 'exit' to quit.\n"
334
+ )
320
335
  prompt = "obsideo:/ "
321
336
 
322
337
  def __init__(self):
@@ -541,27 +556,105 @@ class ObsideoShell(cmd.Cmd):
541
556
 
542
557
  # ── account ───────────────────────────────────────────────────────────────
543
558
  def do_account(self, arg):
544
- """Show your plan: storage used vs. your free quota."""
559
+ """Show your account: plan, storage used, and where your files/keys live."""
545
560
  if not self._require_login():
546
561
  return
547
- usage = _fetch_usage()
562
+ from obsideo import sync as sync_mod
548
563
  print()
549
564
  print(" -- Obsideo account --------------------------")
550
565
  print(" Plan: Free")
551
- if usage:
552
- used, quota = usage["used_bytes"], usage["quota_bytes"]
553
- pct = usage.get("percent_used", (used / quota if quota else 0))
554
- print(f" Used: {_human(used)} / {_human(quota)} ({pct*100:.1f}%)")
555
- bar_len = 30
556
- filled = int(bar_len * min(pct, 1.0))
557
- print(f" [{'#'*filled}{'-'*(bar_len-filled)}]")
558
- if pct >= 0.8:
559
- print(" You're near your limit - reply to any Obsideo email to upgrade.")
566
+ if not config.account_token():
567
+ # No shim token (e.g. creds set via env / pre-login account): we can't
568
+ # query usage. Don't claim the service is down — tell them how to link it.
569
+ print(" Usage: sign in with `obsideo login` to see usage details")
560
570
  else:
561
- print(" (usage unavailable - is the account service reachable?)")
571
+ usage = _fetch_usage()
572
+ if usage:
573
+ used, quota = usage["used_bytes"], usage["quota_bytes"]
574
+ pct = usage.get("percent_used", (used / quota if quota else 0))
575
+ print(f" Used: {_human(used)} / {_human(quota)} ({pct*100:.1f}%)")
576
+ bar_len = 30
577
+ filled = int(bar_len * min(pct, 1.0))
578
+ print(f" [{'#'*filled}{'-'*(bar_len-filled)}]")
579
+ if pct >= 0.8:
580
+ print(" You're near your limit - reply to any Obsideo email to upgrade.")
581
+ else:
582
+ print(" Usage: couldn't reach the account service - try again shortly")
583
+ print(f" Files: bucket '{storage.bucket()}' · sync folder {sync_mod.ensure_sync_dir()}")
584
+ print(f" Keys: {config.CONFIG_DIR} (back up data.key)")
562
585
  print(" ---------------------------------------------")
563
586
  print()
564
587
 
588
+ # ── about / faq / messages ────────────────────────────────────────────────
589
+ def do_about(self, arg):
590
+ """What Obsideo is."""
591
+ print("""
592
+ OBSIDEO DRIVE - encrypted storage we can't read.
593
+
594
+ Your files are encrypted on your device (AES-256-GCM) before they ever leave
595
+ it, then stored across three independent providers (RF=3). Obsideo's servers
596
+ only ever see ciphertext - never your filenames, never your data.
597
+
598
+ - Free: 3 GB, no card, no expiry.
599
+ - Your keys live only on your machine (~/.obsideo). Back up data.key - lose it
600
+ and the data is unrecoverable by design. That's the point: not even we can read it.
601
+ - Install / update: pip install -U obsideo-cli More: https://obsideo.io
602
+ """)
603
+
604
+ def do_faq(self, arg):
605
+ """Frequently asked questions."""
606
+ print("""
607
+ -- Obsideo FAQ --
608
+
609
+ Q: Can Obsideo read my files?
610
+ A: No. They're encrypted on your device before upload; we only store ciphertext.
611
+
612
+ Q: What's free?
613
+ A: 3 GB, no credit card, no expiry.
614
+
615
+ Q: What if I lose my key?
616
+ A: Your key is ~/.obsideo/data.key - back it up. Without it the data can't be
617
+ decrypted by anyone, including us.
618
+
619
+ Q: Two folders - what's the difference?
620
+ A: ~/.obsideo = your keys + settings (don't touch).
621
+ ~/obsideo-sync = your SYNC folder - files you put here sync with `sync push`.
622
+
623
+ Q: What does `sync` do?
624
+ A: Mirrors your sync folder to/from the cloud. `sync push` uploads new/changed
625
+ files, `sync pull` downloads, `sync status` shows what's pending. It's manual
626
+ (you run it) and covers files in the top of the folder.
627
+
628
+ Q: How do I change settings (sync folder, encryption)?
629
+ A: `config` shows them; `config set sync_dir <path>` / `config set encrypt_names false`.
630
+
631
+ Q: More space?
632
+ A: Reply to any Obsideo email to upgrade. (A referral program is coming.)
633
+
634
+ Q: Updating?
635
+ A: pip install -U obsideo-cli - the CLI also nudges you when an update is out.
636
+ """)
637
+
638
+ def do_messages(self, arg):
639
+ """Messages from the Obsideo team."""
640
+ try:
641
+ req = urllib.request.Request(
642
+ f"{config.signup_url()}/v1/notices",
643
+ headers={"User-Agent": config.USER_AGENT},
644
+ )
645
+ with urllib.request.urlopen(req, timeout=10, context=config.ssl_context()) as resp:
646
+ notices = json.loads(resp.read().decode()).get("notices", [])
647
+ except Exception:
648
+ print("\n Couldn't reach the message service - try again shortly.\n")
649
+ return
650
+ if not notices:
651
+ print("\n No messages from the Obsideo team right now.\n")
652
+ return
653
+ print("\n -- Messages from the Obsideo team --")
654
+ for n in notices:
655
+ print(f" - {n.get('body', '').strip()}")
656
+ print()
657
+
565
658
  # ── sync ──────────────────────────────────────────────────────────────────
566
659
  def do_sync(self, arg):
567
660
  """Sync your local folder with Obsideo. Usage: sync push|pull|status"""
@@ -1,122 +1,154 @@
1
- """Sync a local folder with an Obsideo remote prefix (default 'sync/').
2
-
3
- Encrypts on push, decrypts on pull (account data key). Tracks state in a local
4
- manifest so unchanged files are skipped. Adapted from Cloud_Terminal's sync onto
5
- the Obsideo storage seam.
6
- """
7
-
8
- import sys
9
- from pathlib import Path
10
-
11
- from obsideo_core import config, crypto, storage
12
- from obsideo import manifest
13
-
14
- REMOTE_PREFIX = "sync/"
15
-
16
-
17
- def _sync_dir() -> Path:
18
- return Path(config.load_config().get("sync_dir", str(Path.home() / "obsideo-sync")))
19
-
20
-
21
- def _remote_key(name: str) -> str:
22
- return f"{REMOTE_PREFIX}{name}"
23
-
24
-
25
- def sync_status() -> dict:
26
- sync_dir = _sync_dir()
27
- entries = manifest.get_all()
28
- status = {"to_push": [], "to_pull": [], "synced": []}
29
-
30
- local_files = {}
31
- if sync_dir.exists():
32
- local_files = {f.name: f for f in sync_dir.iterdir() if f.is_file()}
33
-
34
- for name, f in local_files.items():
35
- local_hash = manifest.file_sha256(f)
36
- entry = entries.get(name)
37
- if entry is None or entry.get("local_hash") != local_hash:
38
- status["to_push"].append(name)
39
- else:
40
- status["synced"].append(name)
41
-
42
- # Remote files we know about but don't have locally.
43
- try:
44
- remote = storage.list_prefix(REMOTE_PREFIX)
45
- remote_names = {f["name"] for f in remote["files"]}
46
- except Exception:
47
- remote_names = set(entries.keys())
48
- for name in remote_names:
49
- if name not in local_files:
50
- status["to_pull"].append(name)
51
-
52
- return status
53
-
54
-
55
- def push(verbose: bool = True) -> int:
56
- sync_dir = _sync_dir()
57
- if not sync_dir.exists():
58
- if verbose:
59
- print(f"Sync folder does not exist: {sync_dir}")
60
- return 0
61
-
62
- do_encrypt = config.load_config().get("encrypt", True)
63
- entries = manifest.get_all()
64
- pushed = 0
65
-
66
- for f in (p for p in sync_dir.iterdir() if p.is_file()):
67
- local_hash = manifest.file_sha256(f)
68
- entry = entries.get(f.name)
69
- if entry and entry.get("local_hash") == local_hash:
70
- if verbose:
71
- print(f" {f.name} - unchanged, skipping")
72
- continue
73
-
74
- raw = f.read_bytes()
75
- body = crypto.encrypt(raw) if do_encrypt else raw
76
- try:
77
- key = storage.put(_remote_key(f.name), body)
78
- manifest.upsert(f.name, remote_key=key, local_hash=local_hash,
79
- size=len(raw), encrypted=do_encrypt)
80
- pushed += 1
81
- if verbose:
82
- print(f" {f.name} - uploaded")
83
- except Exception as e:
84
- print(f" {f.name} - FAILED: {e}", file=sys.stderr)
85
-
86
- return pushed
87
-
88
-
89
- def pull(verbose: bool = True) -> int:
90
- sync_dir = _sync_dir()
91
- sync_dir.mkdir(parents=True, exist_ok=True)
92
-
93
- try:
94
- remote = storage.list_prefix(REMOTE_PREFIX)
95
- except Exception as e:
96
- print(f"Failed to list remote: {e}", file=sys.stderr)
97
- return 0
98
-
99
- pulled = 0
100
- for rf in remote["files"]:
101
- name = rf["name"]
102
- local_file = sync_dir / name
103
- try:
104
- blob = storage.get(rf["key"])
105
- try:
106
- raw = crypto.decrypt(blob)
107
- encrypted = True
108
- except Exception:
109
- raw = blob # was stored unencrypted
110
- encrypted = False
111
- local_file.parent.mkdir(parents=True, exist_ok=True)
112
- local_file.write_bytes(raw)
113
- manifest.upsert(name, remote_key=rf["key"],
114
- local_hash=manifest.file_sha256(local_file),
115
- size=len(raw), encrypted=encrypted)
116
- pulled += 1
117
- if verbose:
118
- print(f" {name} - downloaded")
119
- except Exception as e:
120
- print(f" {name} - FAILED: {e}", file=sys.stderr)
121
-
122
- return pulled
1
+ """Sync a local folder with an Obsideo remote prefix (default 'sync/').
2
+
3
+ Encrypts on push, decrypts on pull (account data key). Tracks state in a local
4
+ manifest so unchanged files are skipped. Adapted from Cloud_Terminal's sync onto
5
+ the Obsideo storage seam.
6
+ """
7
+
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from obsideo_core import config, crypto, storage
12
+ from obsideo import manifest
13
+
14
+ REMOTE_PREFIX = "sync/"
15
+
16
+ # A local guide dropped in the sync folder so a user who opens it knows what it is
17
+ # and how to use it. It is NEVER uploaded (push/status skip it).
18
+ README_NAME = "READ ME - Obsideo sync.txt"
19
+ _README_TEXT = """This is your Obsideo sync folder.
20
+
21
+ Put files here, then back them up to your encrypted Obsideo storage from the CLI:
22
+
23
+ obsideo open the app
24
+ sync push upload new / changed files (encrypted on your device)
25
+ sync pull download your files into this folder
26
+ sync status see what's pending
27
+
28
+ Everything is encrypted on your device before it leaves, so Obsideo cannot read
29
+ it. In the CLI, type 'about' or 'faq' to learn more.
30
+
31
+ (This file stays on your computer - it is not uploaded.)
32
+ """
33
+
34
+
35
+ def _sync_dir() -> Path:
36
+ return Path(config.load_config().get("sync_dir", str(Path.home() / "obsideo-sync")))
37
+
38
+
39
+ def ensure_sync_dir() -> Path:
40
+ """Return the sync folder, creating it if needed (and dropping a short READ ME
41
+ the first time). Called on login and by every sync command so a user never has
42
+ to make the folder by hand it just exists, with instructions inside."""
43
+ sd = _sync_dir()
44
+ created = not sd.exists()
45
+ sd.mkdir(parents=True, exist_ok=True)
46
+ if created:
47
+ try:
48
+ (sd / README_NAME).write_text(_README_TEXT)
49
+ except OSError:
50
+ pass
51
+ return sd
52
+
53
+
54
+ def _remote_key(name: str) -> str:
55
+ return f"{REMOTE_PREFIX}{name}"
56
+
57
+
58
+ def sync_status() -> dict:
59
+ sync_dir = ensure_sync_dir()
60
+ entries = manifest.get_all()
61
+ status = {"to_push": [], "to_pull": [], "synced": []}
62
+
63
+ local_files = {f.name: f for f in sync_dir.iterdir() if f.is_file() and f.name != README_NAME}
64
+
65
+ for name, f in local_files.items():
66
+ local_hash = manifest.file_sha256(f)
67
+ entry = entries.get(name)
68
+ if entry is None or entry.get("local_hash") != local_hash:
69
+ status["to_push"].append(name)
70
+ else:
71
+ status["synced"].append(name)
72
+
73
+ # Remote files we know about but don't have locally.
74
+ try:
75
+ remote = storage.list_prefix(REMOTE_PREFIX)
76
+ remote_names = {f["name"] for f in remote["files"]}
77
+ except Exception:
78
+ remote_names = set(entries.keys())
79
+ for name in remote_names:
80
+ if name not in local_files:
81
+ status["to_pull"].append(name)
82
+
83
+ return status
84
+
85
+
86
+ def push(verbose: bool = True) -> int:
87
+ sync_dir = ensure_sync_dir()
88
+ files = [p for p in sync_dir.iterdir() if p.is_file() and p.name != README_NAME]
89
+ if not files:
90
+ if verbose:
91
+ print(f" Your sync folder is empty:\n {sync_dir}\n"
92
+ f" Drop files in there, then run `sync push` again.")
93
+ return 0
94
+
95
+ do_encrypt = config.load_config().get("encrypt", True)
96
+ entries = manifest.get_all()
97
+ pushed = 0
98
+
99
+ for f in files:
100
+ local_hash = manifest.file_sha256(f)
101
+ entry = entries.get(f.name)
102
+ if entry and entry.get("local_hash") == local_hash:
103
+ if verbose:
104
+ print(f" {f.name} - unchanged, skipping")
105
+ continue
106
+
107
+ raw = f.read_bytes()
108
+ body = crypto.encrypt(raw) if do_encrypt else raw
109
+ try:
110
+ key = storage.put(_remote_key(f.name), body)
111
+ manifest.upsert(f.name, remote_key=key, local_hash=local_hash,
112
+ size=len(raw), encrypted=do_encrypt)
113
+ pushed += 1
114
+ if verbose:
115
+ print(f" {f.name} - uploaded")
116
+ except Exception as e:
117
+ print(f" {f.name} - FAILED: {e}", file=sys.stderr)
118
+
119
+ return pushed
120
+
121
+
122
+ def pull(verbose: bool = True) -> int:
123
+ sync_dir = ensure_sync_dir()
124
+
125
+ try:
126
+ remote = storage.list_prefix(REMOTE_PREFIX)
127
+ except Exception as e:
128
+ print(f"Failed to list remote: {e}", file=sys.stderr)
129
+ return 0
130
+
131
+ pulled = 0
132
+ for rf in remote["files"]:
133
+ name = rf["name"]
134
+ local_file = sync_dir / name
135
+ try:
136
+ blob = storage.get(rf["key"])
137
+ try:
138
+ raw = crypto.decrypt(blob)
139
+ encrypted = True
140
+ except Exception:
141
+ raw = blob # was stored unencrypted
142
+ encrypted = False
143
+ local_file.parent.mkdir(parents=True, exist_ok=True)
144
+ local_file.write_bytes(raw)
145
+ manifest.upsert(name, remote_key=rf["key"],
146
+ local_hash=manifest.file_sha256(local_file),
147
+ size=len(raw), encrypted=encrypted)
148
+ pulled += 1
149
+ if verbose:
150
+ print(f" {name} - downloaded")
151
+ except Exception as e:
152
+ print(f" {name} - FAILED: {e}", file=sys.stderr)
153
+
154
+ return pulled
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: obsideo-cli
3
- Version: 0.2.5
3
+ Version: 0.2.7
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.5"
7
+ version = "0.2.7"
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"
@@ -246,3 +246,52 @@ def test_update_check_windows_prints_command_not_pip(monkeypatch, capsys):
246
246
  err = capsys.readouterr().err
247
247
  assert ran == [] # never attempts the locked self-replace
248
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()
285
+
286
+
287
+ def test_sync_readme_created_and_not_pushed(tmp_path, monkeypatch, capsys):
288
+ from obsideo import sync
289
+ sd = tmp_path / "obsideo-sync"
290
+ monkeypatch.setattr(sync, "_sync_dir", lambda: sd)
291
+ sync.ensure_sync_dir()
292
+ readme = sd / sync.README_NAME
293
+ assert readme.exists() and "sync push" in readme.read_text() # guide dropped in
294
+ # A folder containing ONLY the README is treated as empty (README never uploads).
295
+ n = sync.push(verbose=True)
296
+ assert n == 0
297
+ assert "empty" in capsys.readouterr().out.lower()
File without changes
File without changes