obsideo-cli 0.2.7__tar.gz → 0.2.9__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.
- {obsideo_cli-0.2.7 → obsideo_cli-0.2.9}/PKG-INFO +1 -1
- {obsideo_cli-0.2.7 → obsideo_cli-0.2.9}/obsideo/cli.py +66 -17
- {obsideo_cli-0.2.7 → obsideo_cli-0.2.9}/obsideo_cli.egg-info/PKG-INFO +1 -1
- {obsideo_cli-0.2.7 → obsideo_cli-0.2.9}/obsideo_core/storage.py +25 -0
- {obsideo_cli-0.2.7 → obsideo_cli-0.2.9}/pyproject.toml +1 -1
- {obsideo_cli-0.2.7 → obsideo_cli-0.2.9}/tests/test_cli.py +48 -13
- {obsideo_cli-0.2.7 → obsideo_cli-0.2.9}/README.md +0 -0
- {obsideo_cli-0.2.7 → obsideo_cli-0.2.9}/obsideo/__init__.py +0 -0
- {obsideo_cli-0.2.7 → obsideo_cli-0.2.9}/obsideo/__main__.py +0 -0
- {obsideo_cli-0.2.7 → obsideo_cli-0.2.9}/obsideo/manifest.py +0 -0
- {obsideo_cli-0.2.7 → obsideo_cli-0.2.9}/obsideo/sync.py +0 -0
- {obsideo_cli-0.2.7 → obsideo_cli-0.2.9}/obsideo_cli.egg-info/SOURCES.txt +0 -0
- {obsideo_cli-0.2.7 → obsideo_cli-0.2.9}/obsideo_cli.egg-info/dependency_links.txt +0 -0
- {obsideo_cli-0.2.7 → obsideo_cli-0.2.9}/obsideo_cli.egg-info/entry_points.txt +0 -0
- {obsideo_cli-0.2.7 → obsideo_cli-0.2.9}/obsideo_cli.egg-info/requires.txt +0 -0
- {obsideo_cli-0.2.7 → obsideo_cli-0.2.9}/obsideo_cli.egg-info/top_level.txt +0 -0
- {obsideo_cli-0.2.7 → obsideo_cli-0.2.9}/obsideo_core/__init__.py +0 -0
- {obsideo_cli-0.2.7 → obsideo_cli-0.2.9}/obsideo_core/config.py +0 -0
- {obsideo_cli-0.2.7 → obsideo_cli-0.2.9}/obsideo_core/crypto.py +0 -0
- {obsideo_cli-0.2.7 → obsideo_cli-0.2.9}/obsideo_core/identity.py +0 -0
- {obsideo_cli-0.2.7 → obsideo_cli-0.2.9}/obsideo_core/login.py +0 -0
- {obsideo_cli-0.2.7 → obsideo_cli-0.2.9}/obsideo_core/names.py +0 -0
- {obsideo_cli-0.2.7 → obsideo_cli-0.2.9}/setup.cfg +0 -0
- {obsideo_cli-0.2.7 → obsideo_cli-0.2.9}/tests/test_core.py +0 -0
|
@@ -146,10 +146,12 @@ def show_status() -> None:
|
|
|
146
146
|
Makes a network call, so it's shown at session start / post-login only."""
|
|
147
147
|
if not _chrome_enabled() or not config.is_logged_in():
|
|
148
148
|
return
|
|
149
|
-
usage = _fetch_usage()
|
|
149
|
+
usage = _fetch_account_info() or _fetch_usage()
|
|
150
150
|
if not usage:
|
|
151
151
|
return
|
|
152
152
|
used, quota = usage.get("used_bytes", 0), usage.get("quota_bytes", 0)
|
|
153
|
+
if not quota:
|
|
154
|
+
return # no quota to show a bar against; account command shows raw usage
|
|
153
155
|
pct = usage.get("percent_used")
|
|
154
156
|
if pct is None:
|
|
155
157
|
pct = (used / quota) if quota else 0.0
|
|
@@ -339,6 +341,14 @@ class ObsideoShell(cmd.Cmd):
|
|
|
339
341
|
self._cwd = "" # S3 key prefix; "" = root
|
|
340
342
|
self._refresh_prompt()
|
|
341
343
|
|
|
344
|
+
def precmd(self, line: str) -> str:
|
|
345
|
+
# Tolerate a leading "obsideo " typed out of habit inside the shell
|
|
346
|
+
# (e.g. "obsideo ls" -> "ls"), so it doesn't error with Unknown syntax.
|
|
347
|
+
stripped = line.lstrip()
|
|
348
|
+
if stripped.lower().startswith("obsideo "):
|
|
349
|
+
return stripped[len("obsideo "):]
|
|
350
|
+
return line
|
|
351
|
+
|
|
342
352
|
# ── path helpers ────────────────────────────────────────────────────────
|
|
343
353
|
def _refresh_prompt(self):
|
|
344
354
|
self.prompt = f"obsideo:/{self._cwd} "
|
|
@@ -560,28 +570,41 @@ class ObsideoShell(cmd.Cmd):
|
|
|
560
570
|
if not self._require_login():
|
|
561
571
|
return
|
|
562
572
|
from obsideo import sync as sync_mod
|
|
573
|
+
# Usage + quota for any account via the gateway (works with just the S3
|
|
574
|
+
# creds); fall back to the signup-service token, then to a storage-only
|
|
575
|
+
# count - so this always shows something useful and never nags to log in.
|
|
576
|
+
info = _fetch_account_info() or (_fetch_usage() if config.account_token() else None)
|
|
563
577
|
print()
|
|
564
578
|
print(" -- Obsideo account --------------------------")
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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}%)")
|
|
579
|
+
if info:
|
|
580
|
+
tier = (info.get("tier") or "free").replace("testdrive", "Free").title()
|
|
581
|
+
print(f" Plan: {tier}")
|
|
582
|
+
used = info.get("used_bytes", 0)
|
|
583
|
+
quota = info.get("quota_bytes", 0)
|
|
584
|
+
if quota:
|
|
585
|
+
pct = used / quota
|
|
576
586
|
bar_len = 30
|
|
577
587
|
filled = int(bar_len * min(pct, 1.0))
|
|
578
|
-
print(f"
|
|
588
|
+
print(f" Used: {_human(used)} / {_human(quota)} ({pct*100:.1f}%)")
|
|
589
|
+
print(f" [{'#'*filled}{'-'*(bar_len-filled)}]")
|
|
579
590
|
if pct >= 0.8:
|
|
580
|
-
print("
|
|
591
|
+
print(" Near your limit - reply to any Obsideo email to upgrade.")
|
|
581
592
|
else:
|
|
582
|
-
print("
|
|
583
|
-
|
|
584
|
-
|
|
593
|
+
print(f" Used: {_human(used)}")
|
|
594
|
+
if info.get("object_count"):
|
|
595
|
+
print(f" Files: {info['object_count']} object(s)")
|
|
596
|
+
if info.get("days_remaining"):
|
|
597
|
+
print(f" Renews/expires in {info['days_remaining']} day(s)")
|
|
598
|
+
else:
|
|
599
|
+
print(" Plan: Free")
|
|
600
|
+
try:
|
|
601
|
+
used, n = storage.total_usage()
|
|
602
|
+
print(f" Used: {_human(used)} across {n} file(s)")
|
|
603
|
+
except Exception:
|
|
604
|
+
print(" Used: (couldn't read storage just now)")
|
|
605
|
+
print(f" Bucket: {storage.bucket()}")
|
|
606
|
+
print(f" Sync folder: {sync_mod.ensure_sync_dir()}")
|
|
607
|
+
print(f" Keys: {config.CONFIG_DIR} (back up data.key)")
|
|
585
608
|
print(" ---------------------------------------------")
|
|
586
609
|
print()
|
|
587
610
|
|
|
@@ -732,6 +755,32 @@ def _fetch_usage() -> dict | None:
|
|
|
732
755
|
return None
|
|
733
756
|
|
|
734
757
|
|
|
758
|
+
def _fetch_account_info() -> dict | None:
|
|
759
|
+
"""Usage + quota for the calling account via the gateway's SigV4-authed
|
|
760
|
+
/v1/account. Works for ANY account using only the S3 creds we already have
|
|
761
|
+
(no signup-service token needed). Returns {used_bytes, quota_bytes, tier,
|
|
762
|
+
object_count, days_remaining, ...} or None if unavailable (e.g. the gateway
|
|
763
|
+
endpoint isn't deployed yet, or no creds) — callers fall back gracefully."""
|
|
764
|
+
ak = os.environ.get("OBSIDEO_S3_ACCESS_KEY")
|
|
765
|
+
sk = os.environ.get("OBSIDEO_S3_SECRET_KEY")
|
|
766
|
+
if not (ak and sk):
|
|
767
|
+
return None
|
|
768
|
+
try:
|
|
769
|
+
from botocore.auth import SigV4Auth
|
|
770
|
+
from botocore.awsrequest import AWSRequest
|
|
771
|
+
from botocore.credentials import Credentials
|
|
772
|
+
endpoint = os.environ.get("OBSIDEO_S3_ENDPOINT", "https://s3.obsideo.io").rstrip("/")
|
|
773
|
+
region = os.environ.get("OBSIDEO_S3_REGION", "us-east-1")
|
|
774
|
+
url = f"{endpoint}/v1/account"
|
|
775
|
+
signed = AWSRequest(method="GET", url=url)
|
|
776
|
+
SigV4Auth(Credentials(ak, sk), "s3", region).add_auth(signed)
|
|
777
|
+
req = urllib.request.Request(url, headers=dict(signed.headers), method="GET")
|
|
778
|
+
with urllib.request.urlopen(req, timeout=15, context=config.ssl_context()) as resp:
|
|
779
|
+
return json.loads(resp.read().decode())
|
|
780
|
+
except Exception:
|
|
781
|
+
return None
|
|
782
|
+
|
|
783
|
+
|
|
735
784
|
def main():
|
|
736
785
|
argv = sys.argv[1:]
|
|
737
786
|
|
|
@@ -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.
|
|
7
|
+
version = "0.2.9"
|
|
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"
|
|
@@ -282,16 +282,51 @@ def test_messages_handles_unreachable(monkeypatch, capsys):
|
|
|
282
282
|
monkeypatch.setattr(cli.urllib.request, "urlopen", boom)
|
|
283
283
|
cli.ObsideoShell().do_messages("")
|
|
284
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()
|
|
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()
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def test_account_computes_usage_without_token(monkeypatch, tmp_path, capsys):
|
|
301
|
+
# No gateway info + no signup token -> compute from storage, NOT nag to log in.
|
|
302
|
+
from obsideo import sync
|
|
303
|
+
monkeypatch.setattr(cli, "_fetch_account_info", lambda: None) # don't hit network
|
|
304
|
+
monkeypatch.setattr(cli.config, "account_token", lambda: None)
|
|
305
|
+
monkeypatch.setattr(cli.storage, "total_usage", lambda: (1_500_000, 7))
|
|
306
|
+
monkeypatch.setattr(cli.storage, "bucket", lambda: "tb")
|
|
307
|
+
monkeypatch.setattr(sync, "_sync_dir", lambda: tmp_path / "s")
|
|
308
|
+
cli.ObsideoShell().do_account("")
|
|
309
|
+
out = capsys.readouterr().out
|
|
310
|
+
assert "across 7 file" in out
|
|
311
|
+
assert "obsideo login" not in out and "sign in" not in out.lower()
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def test_account_shows_percentage_from_gateway(monkeypatch, tmp_path, capsys):
|
|
315
|
+
# When the gateway returns quota, account shows used / quota / % + a bar.
|
|
316
|
+
from obsideo import sync
|
|
317
|
+
monkeypatch.setattr(cli, "_fetch_account_info",
|
|
318
|
+
lambda: {"tier": "testdrive", "used_bytes": 500_000_000,
|
|
319
|
+
"quota_bytes": 5_368_709_120, "object_count": 314})
|
|
320
|
+
monkeypatch.setattr(cli.storage, "bucket", lambda: "obsideo")
|
|
321
|
+
monkeypatch.setattr(sync, "_sync_dir", lambda: tmp_path / "s")
|
|
322
|
+
cli.ObsideoShell().do_account("")
|
|
323
|
+
out = capsys.readouterr().out
|
|
324
|
+
assert "/" in out and "%" in out and "[" in out # used / quota (pct) + bar
|
|
325
|
+
assert "314 object" in out and "Free" in out # testdrive shown as Free
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def test_precmd_strips_obsideo_prefix():
|
|
329
|
+
sh = cli.ObsideoShell()
|
|
330
|
+
assert sh.precmd("obsideo ls") == "ls"
|
|
331
|
+
assert sh.precmd("obsideo login") == "login"
|
|
332
|
+
assert sh.precmd("ls") == "ls" # unchanged when no prefix
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|