obsideo-cli 0.2.6__tar.gz → 0.2.8__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.6 → obsideo_cli-0.2.8}/PKG-INFO +1 -1
  2. {obsideo_cli-0.2.6 → obsideo_cli-0.2.8}/obsideo/cli.py +38 -20
  3. {obsideo_cli-0.2.6 → obsideo_cli-0.2.8}/obsideo/sync.py +154 -129
  4. {obsideo_cli-0.2.6 → obsideo_cli-0.2.8}/obsideo_cli.egg-info/PKG-INFO +1 -1
  5. {obsideo_cli-0.2.6 → obsideo_cli-0.2.8}/obsideo_core/storage.py +25 -0
  6. {obsideo_cli-0.2.6 → obsideo_cli-0.2.8}/pyproject.toml +1 -1
  7. {obsideo_cli-0.2.6 → obsideo_cli-0.2.8}/tests/test_cli.py +59 -26
  8. {obsideo_cli-0.2.6 → obsideo_cli-0.2.8}/README.md +0 -0
  9. {obsideo_cli-0.2.6 → obsideo_cli-0.2.8}/obsideo/__init__.py +0 -0
  10. {obsideo_cli-0.2.6 → obsideo_cli-0.2.8}/obsideo/__main__.py +0 -0
  11. {obsideo_cli-0.2.6 → obsideo_cli-0.2.8}/obsideo/manifest.py +0 -0
  12. {obsideo_cli-0.2.6 → obsideo_cli-0.2.8}/obsideo_cli.egg-info/SOURCES.txt +0 -0
  13. {obsideo_cli-0.2.6 → obsideo_cli-0.2.8}/obsideo_cli.egg-info/dependency_links.txt +0 -0
  14. {obsideo_cli-0.2.6 → obsideo_cli-0.2.8}/obsideo_cli.egg-info/entry_points.txt +0 -0
  15. {obsideo_cli-0.2.6 → obsideo_cli-0.2.8}/obsideo_cli.egg-info/requires.txt +0 -0
  16. {obsideo_cli-0.2.6 → obsideo_cli-0.2.8}/obsideo_cli.egg-info/top_level.txt +0 -0
  17. {obsideo_cli-0.2.6 → obsideo_cli-0.2.8}/obsideo_core/__init__.py +0 -0
  18. {obsideo_cli-0.2.6 → obsideo_cli-0.2.8}/obsideo_core/config.py +0 -0
  19. {obsideo_cli-0.2.6 → obsideo_cli-0.2.8}/obsideo_core/crypto.py +0 -0
  20. {obsideo_cli-0.2.6 → obsideo_cli-0.2.8}/obsideo_core/identity.py +0 -0
  21. {obsideo_cli-0.2.6 → obsideo_cli-0.2.8}/obsideo_core/login.py +0 -0
  22. {obsideo_cli-0.2.6 → obsideo_cli-0.2.8}/obsideo_core/names.py +0 -0
  23. {obsideo_cli-0.2.6 → obsideo_cli-0.2.8}/setup.cfg +0 -0
  24. {obsideo_cli-0.2.6 → obsideo_cli-0.2.8}/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.6
3
+ Version: 0.2.8
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
@@ -322,8 +322,16 @@ def run_login(url: str | None = None) -> bool:
322
322
 
323
323
 
324
324
  class ObsideoShell(cmd.Cmd):
325
- intro = ("\n Obsideo - encrypted storage we can't read.\n"
326
- " 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
+ )
327
335
  prompt = "obsideo:/ "
328
336
 
329
337
  def __init__(self):
@@ -331,6 +339,14 @@ class ObsideoShell(cmd.Cmd):
331
339
  self._cwd = "" # S3 key prefix; "" = root
332
340
  self._refresh_prompt()
333
341
 
342
+ def precmd(self, line: str) -> str:
343
+ # Tolerate a leading "obsideo " typed out of habit inside the shell
344
+ # (e.g. "obsideo ls" -> "ls"), so it doesn't error with Unknown syntax.
345
+ stripped = line.lstrip()
346
+ if stripped.lower().startswith("obsideo "):
347
+ return stripped[len("obsideo "):]
348
+ return line
349
+
334
350
  # ── path helpers ────────────────────────────────────────────────────────
335
351
  def _refresh_prompt(self):
336
352
  self.prompt = f"obsideo:/{self._cwd} "
@@ -555,25 +571,27 @@ class ObsideoShell(cmd.Cmd):
555
571
  print()
556
572
  print(" -- Obsideo account --------------------------")
557
573
  print(" Plan: Free")
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")
574
+ # Prefer the signup service (it knows your quota); otherwise compute usage
575
+ # straight from your storage so this always works - never nag to "log in".
576
+ usage = _fetch_usage() if config.account_token() else None
577
+ if usage:
578
+ used, quota = usage["used_bytes"], usage["quota_bytes"]
579
+ pct = usage.get("percent_used", (used / quota if quota else 0))
580
+ print(f" Used: {_human(used)} / {_human(quota)} ({pct*100:.1f}%)")
581
+ bar_len = 30
582
+ filled = int(bar_len * min(pct, 1.0))
583
+ print(f" [{'#'*filled}{'-'*(bar_len-filled)}]")
584
+ if pct >= 0.8:
585
+ print(" You're near your limit - reply to any Obsideo email to upgrade.")
562
586
  else:
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)")
587
+ try:
588
+ used, n = storage.total_usage()
589
+ print(f" Used: {_human(used)} across {n} file(s)")
590
+ except Exception:
591
+ print(" Used: (couldn't read storage just now)")
592
+ print(f" Bucket: {storage.bucket()}")
593
+ print(f" Sync folder: {sync_mod.ensure_sync_dir()}")
594
+ print(f" Keys: {config.CONFIG_DIR} (back up data.key)")
577
595
  print(" ---------------------------------------------")
578
596
  print()
579
597
 
@@ -1,129 +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 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
-
29
- def _remote_key(name: str) -> str:
30
- return f"{REMOTE_PREFIX}{name}"
31
-
32
-
33
- def sync_status() -> dict:
34
- sync_dir = ensure_sync_dir()
35
- entries = manifest.get_all()
36
- status = {"to_push": [], "to_pull": [], "synced": []}
37
-
38
- local_files = {f.name: f for f in sync_dir.iterdir() if f.is_file()}
39
-
40
- for name, f in local_files.items():
41
- local_hash = manifest.file_sha256(f)
42
- entry = entries.get(name)
43
- if entry is None or entry.get("local_hash") != local_hash:
44
- status["to_push"].append(name)
45
- else:
46
- status["synced"].append(name)
47
-
48
- # Remote files we know about but don't have locally.
49
- try:
50
- remote = storage.list_prefix(REMOTE_PREFIX)
51
- remote_names = {f["name"] for f in remote["files"]}
52
- except Exception:
53
- remote_names = set(entries.keys())
54
- for name in remote_names:
55
- if name not in local_files:
56
- status["to_pull"].append(name)
57
-
58
- return status
59
-
60
-
61
- def push(verbose: bool = True) -> int:
62
- sync_dir = ensure_sync_dir()
63
- files = [p for p in sync_dir.iterdir() if p.is_file()]
64
- if not files:
65
- if verbose:
66
- print(f" Your sync folder is empty:\n {sync_dir}\n"
67
- f" Drop files in there, then run `sync push` again.")
68
- return 0
69
-
70
- do_encrypt = config.load_config().get("encrypt", True)
71
- entries = manifest.get_all()
72
- pushed = 0
73
-
74
- for f in files:
75
- local_hash = manifest.file_sha256(f)
76
- entry = entries.get(f.name)
77
- if entry and entry.get("local_hash") == local_hash:
78
- if verbose:
79
- print(f" {f.name} - unchanged, skipping")
80
- continue
81
-
82
- raw = f.read_bytes()
83
- body = crypto.encrypt(raw) if do_encrypt else raw
84
- try:
85
- key = storage.put(_remote_key(f.name), body)
86
- manifest.upsert(f.name, remote_key=key, local_hash=local_hash,
87
- size=len(raw), encrypted=do_encrypt)
88
- pushed += 1
89
- if verbose:
90
- print(f" {f.name} - uploaded")
91
- except Exception as e:
92
- print(f" {f.name} - FAILED: {e}", file=sys.stderr)
93
-
94
- return pushed
95
-
96
-
97
- def pull(verbose: bool = True) -> int:
98
- sync_dir = ensure_sync_dir()
99
-
100
- try:
101
- remote = storage.list_prefix(REMOTE_PREFIX)
102
- except Exception as e:
103
- print(f"Failed to list remote: {e}", file=sys.stderr)
104
- return 0
105
-
106
- pulled = 0
107
- for rf in remote["files"]:
108
- name = rf["name"]
109
- local_file = sync_dir / name
110
- try:
111
- blob = storage.get(rf["key"])
112
- try:
113
- raw = crypto.decrypt(blob)
114
- encrypted = True
115
- except Exception:
116
- raw = blob # was stored unencrypted
117
- encrypted = False
118
- local_file.parent.mkdir(parents=True, exist_ok=True)
119
- local_file.write_bytes(raw)
120
- manifest.upsert(name, remote_key=rf["key"],
121
- local_hash=manifest.file_sha256(local_file),
122
- size=len(raw), encrypted=encrypted)
123
- pulled += 1
124
- if verbose:
125
- print(f" {name} - downloaded")
126
- except Exception as e:
127
- print(f" {name} - FAILED: {e}", file=sys.stderr)
128
-
129
- 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.6
3
+ Version: 0.2.8
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
@@ -189,6 +189,31 @@ def exists(key: str) -> bool:
189
189
  return head(key) is not None
190
190
 
191
191
 
192
+ def total_usage() -> tuple[int, int]:
193
+ """Total stored bytes + object count across the account's bucket (flat list).
194
+ Lets `account` show real usage without the signup-service token — it just reads
195
+ the storage the account can already see. Names stay opaque; only sizes summed."""
196
+ s3 = _s3()
197
+ total = 0
198
+ count = 0
199
+ token = None
200
+ while True:
201
+ kwargs = dict(Bucket=bucket())
202
+ if token:
203
+ kwargs["ContinuationToken"] = token
204
+ resp = s3.list_objects_v2(**kwargs)
205
+ for obj in resp.get("Contents", []):
206
+ if obj["Key"].endswith("/"):
207
+ continue # folder marker, not a real object
208
+ total += obj.get("Size", 0)
209
+ count += 1
210
+ if resp.get("IsTruncated"):
211
+ token = resp.get("NextContinuationToken")
212
+ else:
213
+ break
214
+ return total, count
215
+
216
+
192
217
  def list_prefix(prefix: str = "", delimiter: str = "/") -> dict:
193
218
  """List one VFS level. Returns {'folders': [name...], 'files': [{name,key,size}]}.
194
219
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "obsideo-cli"
7
- version = "0.2.6"
7
+ version = "0.2.8"
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,39 +246,72 @@ 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()
249
285
 
250
286
 
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):
287
+ def test_sync_readme_created_and_not_pushed(tmp_path, monkeypatch, capsys):
263
288
  from obsideo import sync
264
- monkeypatch.setattr(sync, "_sync_dir", lambda: tmp_path / "s")
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).
265
295
  n = sync.push(verbose=True)
266
296
  assert n == 0
267
- out = capsys.readouterr().out.lower()
268
- assert "empty" in out and "sync push" in out # guides instead of erroring
297
+ assert "empty" in capsys.readouterr().out.lower()
269
298
 
270
299
 
271
- def test_about_and_faq_print(capsys):
272
- sh = cli.ObsideoShell()
273
- sh.do_about("")
274
- sh.do_faq("")
300
+ def test_account_computes_usage_without_token(monkeypatch, tmp_path, capsys):
301
+ # No signup token -> account must compute usage from storage, NOT nag to log in.
302
+ from obsideo import sync
303
+ monkeypatch.setattr(cli.config, "account_token", lambda: None)
304
+ monkeypatch.setattr(cli.storage, "total_usage", lambda: (1_500_000, 7))
305
+ monkeypatch.setattr(cli.storage, "bucket", lambda: "tb")
306
+ monkeypatch.setattr(sync, "_sync_dir", lambda: tmp_path / "s")
307
+ cli.ObsideoShell().do_account("")
275
308
  out = capsys.readouterr().out
276
- assert "OBSIDEO DRIVE" in out and "FAQ" in out and "obsideo-sync" in out
309
+ assert "across 7 file" in out
310
+ assert "obsideo login" not in out and "sign in" not in out.lower()
277
311
 
278
312
 
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()
313
+ def test_precmd_strips_obsideo_prefix():
314
+ sh = cli.ObsideoShell()
315
+ assert sh.precmd("obsideo ls") == "ls"
316
+ assert sh.precmd("obsideo login") == "login"
317
+ assert sh.precmd("ls") == "ls" # unchanged when no prefix
File without changes
File without changes