obsideo-cli 0.2.0__tar.gz → 0.2.1__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.0 → obsideo_cli-0.2.1}/PKG-INFO +2 -1
  2. {obsideo_cli-0.2.0 → obsideo_cli-0.2.1}/obsideo/cli.py +128 -5
  3. {obsideo_cli-0.2.0 → obsideo_cli-0.2.1}/obsideo_cli.egg-info/PKG-INFO +2 -1
  4. {obsideo_cli-0.2.0 → obsideo_cli-0.2.1}/obsideo_cli.egg-info/requires.txt +1 -0
  5. {obsideo_cli-0.2.0 → obsideo_cli-0.2.1}/obsideo_core/config.py +19 -4
  6. {obsideo_cli-0.2.0 → obsideo_cli-0.2.1}/obsideo_core/login.py +1 -1
  7. {obsideo_cli-0.2.0 → obsideo_cli-0.2.1}/pyproject.toml +2 -1
  8. {obsideo_cli-0.2.0 → obsideo_cli-0.2.1}/tests/test_cli.py +77 -0
  9. {obsideo_cli-0.2.0 → obsideo_cli-0.2.1}/README.md +0 -0
  10. {obsideo_cli-0.2.0 → obsideo_cli-0.2.1}/obsideo/__init__.py +0 -0
  11. {obsideo_cli-0.2.0 → obsideo_cli-0.2.1}/obsideo/__main__.py +0 -0
  12. {obsideo_cli-0.2.0 → obsideo_cli-0.2.1}/obsideo/manifest.py +0 -0
  13. {obsideo_cli-0.2.0 → obsideo_cli-0.2.1}/obsideo/sync.py +0 -0
  14. {obsideo_cli-0.2.0 → obsideo_cli-0.2.1}/obsideo_cli.egg-info/SOURCES.txt +0 -0
  15. {obsideo_cli-0.2.0 → obsideo_cli-0.2.1}/obsideo_cli.egg-info/dependency_links.txt +0 -0
  16. {obsideo_cli-0.2.0 → obsideo_cli-0.2.1}/obsideo_cli.egg-info/entry_points.txt +0 -0
  17. {obsideo_cli-0.2.0 → obsideo_cli-0.2.1}/obsideo_cli.egg-info/top_level.txt +0 -0
  18. {obsideo_cli-0.2.0 → obsideo_cli-0.2.1}/obsideo_core/__init__.py +0 -0
  19. {obsideo_cli-0.2.0 → obsideo_cli-0.2.1}/obsideo_core/crypto.py +0 -0
  20. {obsideo_cli-0.2.0 → obsideo_cli-0.2.1}/obsideo_core/identity.py +0 -0
  21. {obsideo_cli-0.2.0 → obsideo_cli-0.2.1}/obsideo_core/names.py +0 -0
  22. {obsideo_cli-0.2.0 → obsideo_cli-0.2.1}/obsideo_core/storage.py +0 -0
  23. {obsideo_cli-0.2.0 → obsideo_cli-0.2.1}/setup.cfg +0 -0
  24. {obsideo_cli-0.2.0 → obsideo_cli-0.2.1}/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.0
3
+ Version: 0.2.1
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
@@ -8,6 +8,7 @@ Requires-Python: >=3.10
8
8
  Description-Content-Type: text/markdown
9
9
  Requires-Dist: boto3>=1.28
10
10
  Requires-Dist: cryptography>=41.0
11
+ Requires-Dist: certifi
11
12
 
12
13
  # obsideo-cli
13
14
 
@@ -11,6 +11,7 @@ leaves, so Obsideo can't read it. An interactive shell plus one-shot commands.
11
11
  import cmd
12
12
  import os
13
13
  import shlex
14
+ import subprocess
14
15
  import sys
15
16
  import urllib.error
16
17
  import urllib.request
@@ -88,7 +89,7 @@ def show_notices() -> None:
88
89
  f"{config.signup_url()}/v1/notices",
89
90
  headers={"User-Agent": config.USER_AGENT},
90
91
  )
91
- with urllib.request.urlopen(req, timeout=4) as resp:
92
+ with urllib.request.urlopen(req, timeout=4, context=config.ssl_context()) as resp:
92
93
  notices = json.loads(resp.read().decode()).get("notices", [])
93
94
  except Exception:
94
95
  return
@@ -106,6 +107,116 @@ def show_notices() -> None:
106
107
  _mark_seen(shown)
107
108
 
108
109
 
110
+ # ── Branding banner + version self-check ──────────────────────────────────────
111
+
112
+ _BANNER = r"""
113
+ /\
114
+ / \ OBSIDEO DRIVE
115
+ \ / encrypted storage we can't read
116
+ \/
117
+ """
118
+
119
+ _BANNER_SHOWN = False
120
+
121
+
122
+ def _chrome_enabled() -> bool:
123
+ """Banner/prompt are human chrome: only on an interactive stdout, and never
124
+ when OBSIDEO_NO_BANNER / NO_COLOR is set. Keeps stdout clean for agents/pipes."""
125
+ if os.environ.get("OBSIDEO_NO_BANNER") or os.environ.get("NO_COLOR"):
126
+ return False
127
+ return sys.stdout.isatty()
128
+
129
+
130
+ def show_banner() -> None:
131
+ """Branded ASCII banner to stderr, once per process, TTY-gated."""
132
+ global _BANNER_SHOWN
133
+ if _BANNER_SHOWN or not _chrome_enabled():
134
+ return
135
+ _BANNER_SHOWN = True
136
+ print(f"\033[36m{_BANNER}\033[0m", file=sys.stderr)
137
+
138
+
139
+ def _usage_bar(pct: float, cells: int = 10) -> str:
140
+ filled = min(cells, max(0, round(pct * cells)))
141
+ return "#" * filled + "-" * (cells - filled)
142
+
143
+
144
+ def show_status() -> None:
145
+ """One-line account status (tier · usage bar · upgrade hint) to stderr, TTY-gated.
146
+ Makes a network call, so it's shown at session start / post-login only."""
147
+ if not _chrome_enabled() or not config.is_logged_in():
148
+ return
149
+ usage = _fetch_usage()
150
+ if not usage:
151
+ return
152
+ used, quota = usage.get("used_bytes", 0), usage.get("quota_bytes", 0)
153
+ pct = usage.get("percent_used")
154
+ if pct is None:
155
+ pct = (used / quota) if quota else 0.0
156
+ hint = " · \033[36mupgrade\033[0m for more" if pct >= 0.8 else ""
157
+ print(f"\033[2mFree\033[0m [{_usage_bar(pct)}] {_human(used)} of {_human(quota)}{hint}",
158
+ file=sys.stderr)
159
+
160
+
161
+ def _parse_version(v: str) -> tuple:
162
+ """Lenient dotted-version → comparable int tuple. '0.2.10' > '0.2.9'."""
163
+ out = []
164
+ for part in v.split("."):
165
+ digits = ""
166
+ for ch in part:
167
+ if ch.isdigit():
168
+ digits += ch
169
+ else:
170
+ break
171
+ out.append(int(digits) if digits else 0)
172
+ return tuple(out)
173
+
174
+
175
+ def _latest_pypi_version() -> str | None:
176
+ try:
177
+ req = urllib.request.Request(
178
+ f"https://pypi.org/pypi/{config.PACKAGE}/json",
179
+ headers={"User-Agent": config.USER_AGENT},
180
+ )
181
+ with urllib.request.urlopen(req, timeout=5, context=config.ssl_context()) as resp:
182
+ return json.loads(resp.read().decode())["info"]["version"]
183
+ except Exception:
184
+ return None
185
+
186
+
187
+ def check_for_update() -> None:
188
+ """On interactive init: if PyPI has a newer obsideo-cli, offer to update now.
189
+ TTY-gated (never prompts agents/scripts), fail-silent (a network hiccup never
190
+ blocks startup). Disable with OBSIDEO_NO_UPDATE_CHECK."""
191
+ if not sys.stdout.isatty() or os.environ.get("OBSIDEO_NO_UPDATE_CHECK"):
192
+ return
193
+ current = config.VERSION
194
+ latest = _latest_pypi_version()
195
+ if not latest or _parse_version(latest) <= _parse_version(current):
196
+ return
197
+ print(f"\n\033[36mUpdate available: {current} -> {latest}\033[0m", file=sys.stderr)
198
+ try:
199
+ ans = input("Update now? [Y/n]: ").strip().lower()
200
+ except (EOFError, KeyboardInterrupt):
201
+ print(file=sys.stderr)
202
+ return
203
+ if ans not in ("", "y", "yes"):
204
+ print(f"Skipped. Update later with: pip install -U {config.PACKAGE}", file=sys.stderr)
205
+ return
206
+ print(f"Updating to {latest}...", file=sys.stderr)
207
+ try:
208
+ rc = subprocess.run(
209
+ [sys.executable, "-m", "pip", "install", "-U", config.PACKAGE]
210
+ ).returncode
211
+ except Exception as e:
212
+ print(f"Update failed: {e}\nTry manually: pip install -U {config.PACKAGE}", file=sys.stderr)
213
+ return
214
+ if rc == 0:
215
+ print(f"\nUpdated to {latest}. Restart `obsideo` to use the new version.", file=sys.stderr)
216
+ sys.exit(0)
217
+ print(f"Update didn't complete. Try: pip install -U {config.PACKAGE}", file=sys.stderr)
218
+
219
+
109
220
  # ── Operator tooling: broadcast a message to all users ────────────────────────
110
221
 
111
222
  def run_admin(argv: list) -> int:
@@ -148,7 +259,7 @@ def run_admin(argv: list) -> int:
148
259
  method="POST",
149
260
  )
150
261
  try:
151
- with urllib.request.urlopen(req, timeout=15) as resp:
262
+ with urllib.request.urlopen(req, timeout=15, context=config.ssl_context()) as resp:
152
263
  out = json.loads(resp.read().decode())
153
264
  except urllib.error.HTTPError as e:
154
265
  detail = e.read().decode()[:200]
@@ -482,7 +593,7 @@ class ObsideoShell(cmd.Cmd):
482
593
  if parts[0] == "set" and len(parts) == 3:
483
594
  key, value = parts[1], parts[2]
484
595
  cfg = config.load_config()
485
- if key == "encrypt":
596
+ if key in ("encrypt", "encrypt_names"):
486
597
  value = value.lower() in ("true", "1", "yes", "on")
487
598
  cfg[key] = value
488
599
  config.save_config(cfg)
@@ -515,7 +626,7 @@ def _fetch_usage() -> dict | None:
515
626
  f"{config.signup_url()}/v1/account/usage",
516
627
  headers={"Authorization": f"Bearer {token}", "User-Agent": config.USER_AGENT},
517
628
  )
518
- with urllib.request.urlopen(req, timeout=15) as resp:
629
+ with urllib.request.urlopen(req, timeout=15, context=config.ssl_context()) as resp:
519
630
  return json.loads(resp.read().decode())
520
631
  except Exception:
521
632
  return None
@@ -524,6 +635,11 @@ def _fetch_usage() -> dict | None:
524
635
  def main():
525
636
  argv = sys.argv[1:]
526
637
 
638
+ # Branded banner on every init (stderr, TTY-gated). Skip for `admin` so
639
+ # operator tooling output stays clean.
640
+ if not (argv and argv[0] == "admin"):
641
+ show_banner()
642
+
527
643
  # Standard --help / -h (cmd.Cmd would otherwise read "--help" as a command).
528
644
  if argv and argv[0] in ("-h", "--help", "help"):
529
645
  ObsideoShell().onecmd("help")
@@ -532,6 +648,8 @@ def main():
532
648
  # `obsideo login` is interactive and handled specially.
533
649
  if argv and argv[0] == "login":
534
650
  ok = run_login()
651
+ if ok:
652
+ show_status()
535
653
  sys.exit(0 if ok else 1)
536
654
 
537
655
  # `obsideo admin ...` is operator tooling, not a shell command.
@@ -543,11 +661,15 @@ def main():
543
661
 
544
662
  shell = ObsideoShell()
545
663
 
546
- # One-shot: `obsideo ls`, `obsideo put file.txt`, etc.
664
+ # One-shot: `obsideo ls`, `obsideo put file.txt`, etc. No update prompt or
665
+ # status line here — one-shots stay fast and scriptable.
547
666
  if argv:
548
667
  shell.onecmd(" ".join(argv))
549
668
  return
550
669
 
670
+ # Interactive session ("initialization"): offer an update if one's out.
671
+ check_for_update()
672
+
551
673
  # First-run nudge: not logged in -> offer login.
552
674
  if not config.is_logged_in():
553
675
  print("Welcome to Obsideo - encrypted storage we can't read.")
@@ -558,6 +680,7 @@ def main():
558
680
  print("Run 'obsideo login' when you're ready.")
559
681
  return
560
682
 
683
+ show_status()
561
684
  try:
562
685
  shell.cmdloop()
563
686
  except KeyboardInterrupt:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: obsideo-cli
3
- Version: 0.2.0
3
+ Version: 0.2.1
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
@@ -8,6 +8,7 @@ Requires-Python: >=3.10
8
8
  Description-Content-Type: text/markdown
9
9
  Requires-Dist: boto3>=1.28
10
10
  Requires-Dist: cryptography>=41.0
11
+ Requires-Dist: certifi
11
12
 
12
13
  # obsideo-cli
13
14
 
@@ -1,2 +1,3 @@
1
1
  boto3>=1.28
2
2
  cryptography>=41.0
3
+ certifi
@@ -28,16 +28,31 @@ _DEFAULT_REGION = "us-east-1"
28
28
  # Sent on every request to the signup shim. A descriptive User-Agent avoids
29
29
  # Cloudflare's default-`Python-urllib` bot block (HTTP 403 / error 1010) that
30
30
  # fronts signup.obsideo.io; without it, `obsideo login` and usage lookups fail.
31
+ PACKAGE = "obsideo-cli" # PyPI distribution name (used for version + update checks)
31
32
  try:
32
33
  from importlib.metadata import version as _pkg_version, PackageNotFoundError
33
34
  try:
34
- _VERSION = _pkg_version("obsideo-cloud")
35
+ _VERSION = _pkg_version(PACKAGE)
35
36
  except PackageNotFoundError:
36
- _VERSION = "0.2.0"
37
+ _VERSION = "0.2.1"
37
38
  except Exception:
38
- _VERSION = "0.2.0"
39
+ _VERSION = "0.2.1"
39
40
 
40
- USER_AGENT = f"obsideo-cloud/{_VERSION}"
41
+ VERSION = _VERSION
42
+ USER_AGENT = f"obsideo-cli/{_VERSION}"
43
+
44
+
45
+ def ssl_context():
46
+ """An SSL context that verifies against certifi's CA bundle. urllib otherwise
47
+ trusts the OS certificate store, which is often incomplete on fresh/locked-down
48
+ Windows installs (CERTIFICATE_VERIFY_FAILED on a perfectly valid cert). Falls
49
+ back to the system default if certifi isn't importable."""
50
+ import ssl
51
+ try:
52
+ import certifi
53
+ return ssl.create_default_context(cafile=certifi.where())
54
+ except Exception:
55
+ return ssl.create_default_context()
41
56
 
42
57
 
43
58
  def write_secret_file(path: Path, value: str) -> None:
@@ -24,7 +24,7 @@ def _post_json(url: str, payload: dict) -> dict:
24
24
  method="POST",
25
25
  )
26
26
  try:
27
- with urllib.request.urlopen(req, timeout=30) as resp:
27
+ with urllib.request.urlopen(req, timeout=30, context=config.ssl_context()) as resp:
28
28
  return json.loads(resp.read().decode())
29
29
  except urllib.error.HTTPError as e:
30
30
  try:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "obsideo-cli"
7
- version = "0.2.0"
7
+ version = "0.2.1"
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"
@@ -12,6 +12,7 @@ license = {text = "MIT"}
12
12
  dependencies = [
13
13
  "boto3>=1.28",
14
14
  "cryptography>=41.0",
15
+ "certifi",
15
16
  ]
16
17
 
17
18
  [project.urls]
@@ -151,3 +151,80 @@ def test_put_folder_into_cwd(shell, tmp_path):
151
151
  sh.do_cd("backup")
152
152
  sh.do_put(str(folder))
153
153
  assert "backup/docs/x.txt" in fake.objs
154
+
155
+
156
+ # ── Banner + update check (0.2.1) ─────────────────────────────────────────────
157
+
158
+ def test_banner_tty_gated_once(monkeypatch, capsys):
159
+ monkeypatch.setattr(cli.sys.stdout, "isatty", lambda: True, raising=False)
160
+ monkeypatch.delenv("OBSIDEO_NO_BANNER", raising=False)
161
+ monkeypatch.delenv("NO_COLOR", raising=False)
162
+ cli._BANNER_SHOWN = False
163
+ cli.show_banner()
164
+ assert "OBSIDEO DRIVE" in capsys.readouterr().err
165
+ cli.show_banner() # once per process
166
+ assert capsys.readouterr().err == ""
167
+ # non-tty: silent
168
+ monkeypatch.setattr(cli.sys.stdout, "isatty", lambda: False, raising=False)
169
+ cli._BANNER_SHOWN = False
170
+ cli.show_banner()
171
+ assert capsys.readouterr().err == ""
172
+
173
+
174
+ def test_parse_version_ordering():
175
+ assert cli._parse_version("0.2.10") > cli._parse_version("0.2.9")
176
+ assert cli._parse_version("0.3.0") > cli._parse_version("0.2.99")
177
+ assert cli._parse_version("0.2.1") == cli._parse_version("0.2.1")
178
+ assert not (cli._parse_version("0.2.0") > cli._parse_version("0.2.1"))
179
+
180
+
181
+ def test_update_check_silent_when_not_tty(monkeypatch, capsys):
182
+ monkeypatch.setattr(cli.sys.stdout, "isatty", lambda: False, raising=False)
183
+ called = []
184
+ monkeypatch.setattr(cli, "_latest_pypi_version", lambda: called.append(1) or "9.9.9")
185
+ cli.check_for_update()
186
+ assert capsys.readouterr().err == ""
187
+ assert called == [] # short-circuits before any network call
188
+
189
+
190
+ def test_update_check_noop_when_current(monkeypatch, capsys):
191
+ monkeypatch.setattr(cli.sys.stdout, "isatty", lambda: True, raising=False)
192
+ monkeypatch.delenv("OBSIDEO_NO_UPDATE_CHECK", raising=False)
193
+ monkeypatch.setattr(cli.config, "VERSION", "0.2.1")
194
+ monkeypatch.setattr(cli, "_latest_pypi_version", lambda: "0.2.1") # not newer
195
+ asked = []
196
+ monkeypatch.setattr("builtins.input", lambda *a: asked.append(1) or "y")
197
+ cli.check_for_update()
198
+ assert asked == [] # never prompts when already current
199
+ assert "Update available" not in capsys.readouterr().err
200
+
201
+
202
+ def test_update_check_prompts_and_respects_no(monkeypatch, capsys):
203
+ monkeypatch.setattr(cli.sys.stdout, "isatty", lambda: True, raising=False)
204
+ monkeypatch.delenv("OBSIDEO_NO_UPDATE_CHECK", raising=False)
205
+ monkeypatch.setattr(cli.config, "VERSION", "0.2.1")
206
+ monkeypatch.setattr(cli, "_latest_pypi_version", lambda: "0.2.2")
207
+ monkeypatch.setattr("builtins.input", lambda *a: "n")
208
+ ran = []
209
+ monkeypatch.setattr(cli.subprocess, "run", lambda *a, **k: ran.append(a))
210
+ cli.check_for_update()
211
+ err = capsys.readouterr().err
212
+ assert "Update available: 0.2.1 -> 0.2.2" in err
213
+ assert ran == [] # declined -> no pip invocation
214
+
215
+
216
+ def test_update_check_runs_pip_on_yes(monkeypatch, capsys):
217
+ monkeypatch.setattr(cli.sys.stdout, "isatty", lambda: True, raising=False)
218
+ monkeypatch.delenv("OBSIDEO_NO_UPDATE_CHECK", raising=False)
219
+ monkeypatch.setattr(cli.config, "VERSION", "0.2.1")
220
+ monkeypatch.setattr(cli, "_latest_pypi_version", lambda: "0.2.2")
221
+ monkeypatch.setattr("builtins.input", lambda *a: "y")
222
+
223
+ class _R:
224
+ returncode = 0
225
+ calls = []
226
+ monkeypatch.setattr(cli.subprocess, "run", lambda *a, **k: calls.append(a[0]) or _R())
227
+ with pytest.raises(SystemExit) as e:
228
+ cli.check_for_update()
229
+ assert e.value.code == 0
230
+ assert calls and calls[0][1:] == ["-m", "pip", "install", "-U", "obsideo-cli"]
File without changes
File without changes
File without changes