obsideo-cli 0.2.6__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.
- {obsideo_cli-0.2.6 → obsideo_cli-0.2.7}/PKG-INFO +1 -1
- {obsideo_cli-0.2.6 → obsideo_cli-0.2.7}/obsideo/cli.py +10 -2
- {obsideo_cli-0.2.6 → obsideo_cli-0.2.7}/obsideo/sync.py +154 -129
- {obsideo_cli-0.2.6 → obsideo_cli-0.2.7}/obsideo_cli.egg-info/PKG-INFO +1 -1
- {obsideo_cli-0.2.6 → obsideo_cli-0.2.7}/pyproject.toml +1 -1
- {obsideo_cli-0.2.6 → obsideo_cli-0.2.7}/tests/test_cli.py +44 -31
- {obsideo_cli-0.2.6 → obsideo_cli-0.2.7}/README.md +0 -0
- {obsideo_cli-0.2.6 → obsideo_cli-0.2.7}/obsideo/__init__.py +0 -0
- {obsideo_cli-0.2.6 → obsideo_cli-0.2.7}/obsideo/__main__.py +0 -0
- {obsideo_cli-0.2.6 → obsideo_cli-0.2.7}/obsideo/manifest.py +0 -0
- {obsideo_cli-0.2.6 → obsideo_cli-0.2.7}/obsideo_cli.egg-info/SOURCES.txt +0 -0
- {obsideo_cli-0.2.6 → obsideo_cli-0.2.7}/obsideo_cli.egg-info/dependency_links.txt +0 -0
- {obsideo_cli-0.2.6 → obsideo_cli-0.2.7}/obsideo_cli.egg-info/entry_points.txt +0 -0
- {obsideo_cli-0.2.6 → obsideo_cli-0.2.7}/obsideo_cli.egg-info/requires.txt +0 -0
- {obsideo_cli-0.2.6 → obsideo_cli-0.2.7}/obsideo_cli.egg-info/top_level.txt +0 -0
- {obsideo_cli-0.2.6 → obsideo_cli-0.2.7}/obsideo_core/__init__.py +0 -0
- {obsideo_cli-0.2.6 → obsideo_cli-0.2.7}/obsideo_core/config.py +0 -0
- {obsideo_cli-0.2.6 → obsideo_cli-0.2.7}/obsideo_core/crypto.py +0 -0
- {obsideo_cli-0.2.6 → obsideo_cli-0.2.7}/obsideo_core/identity.py +0 -0
- {obsideo_cli-0.2.6 → obsideo_cli-0.2.7}/obsideo_core/login.py +0 -0
- {obsideo_cli-0.2.6 → obsideo_cli-0.2.7}/obsideo_core/names.py +0 -0
- {obsideo_cli-0.2.6 → obsideo_cli-0.2.7}/obsideo_core/storage.py +0 -0
- {obsideo_cli-0.2.6 → obsideo_cli-0.2.7}/setup.cfg +0 -0
- {obsideo_cli-0.2.6 → obsideo_cli-0.2.7}/tests/test_core.py +0 -0
|
@@ -322,8 +322,16 @@ def run_login(url: str | None = None) -> bool:
|
|
|
322
322
|
|
|
323
323
|
|
|
324
324
|
class ObsideoShell(cmd.Cmd):
|
|
325
|
-
intro = (
|
|
326
|
-
|
|
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):
|
|
@@ -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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
@@ -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.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,39 +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()
|
|
249
285
|
|
|
250
286
|
|
|
251
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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()
|
|
297
|
+
assert "empty" in capsys.readouterr().out.lower()
|
|
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
|