feat(cli): sync folders with remote paths
Build and Push Docker Container / build-and-push (push) Successful in 1m36s
Build and Push Docker Container / build-and-push (push) Successful in 1m36s
- Store synced folder locations in file_path instead of embedding them in file names. - Add ignore rules from .nanoshareignore and repeatable sync --ignore flags. - Adopt existing remote files only after SHA-256 verification. - Move adopted remotes with metadata updates instead of uploading duplicates. - Bump NanoShare to 1.23.0 and the standalone CLI to 0.2.0.
This commit is contained in:
+160
-10
@@ -9,6 +9,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'cli'))
|
||||
from nanoshare_client import cli
|
||||
from nanoshare_client.auth import _CallbackServer
|
||||
from nanoshare_client.completion import script as completion_script
|
||||
from nanoshare_client.ignore import is_ignored
|
||||
from nanoshare_client.table import format_table
|
||||
|
||||
class FakeClient:
|
||||
@@ -27,9 +28,17 @@ class FakeClient:
|
||||
self.remote_files.append({
|
||||
'file_id': file_id,
|
||||
'file_name': params['file_name'],
|
||||
'file_path': params.get('file_path', ''),
|
||||
'file_size': '1 B',
|
||||
})
|
||||
return {'file_id': file_id, 'file_name': params['file_name']}
|
||||
return {'file_id': file_id, 'file_name': params['file_name'], 'file_path': params.get('file_path', '')}
|
||||
if method == 'files.update':
|
||||
for item in self.remote_files:
|
||||
if item['file_id'] == params['file_id']:
|
||||
item['file_name'] = params['file_name']
|
||||
item['file_path'] = params.get('file_path', '')
|
||||
return {'updated': True}
|
||||
return {'updated': False}
|
||||
if method == 'files.delete':
|
||||
self.deleted.append(params['file_id'])
|
||||
self.remote_files = [item for item in self.remote_files if item['file_id'] != params['file_id']]
|
||||
@@ -39,6 +48,10 @@ class FakeClient:
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
def fake_download(client, node, file_id, path):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(f'downloaded {file_id}')
|
||||
|
||||
def test_sync_uploads_new_local_file_and_writes_state(tmp_path, monkeypatch):
|
||||
fake = FakeClient()
|
||||
monkeypatch.setattr(cli, 'make_client', lambda args: fake)
|
||||
@@ -57,6 +70,142 @@ def test_sync_uploads_new_local_file_and_writes_state(tmp_path, monkeypatch):
|
||||
state = json.loads((tmp_path / '.nanoshare-sync' / 'state.json').read_text())
|
||||
assert state['files']['hello.txt']['file_id'] == 'file_1'
|
||||
|
||||
def test_sync_uploads_nested_local_files(tmp_path, monkeypatch):
|
||||
fake = FakeClient()
|
||||
monkeypatch.setattr(cli, 'make_client', lambda args: fake)
|
||||
|
||||
nested = tmp_path / 'docs' / 'notes' / 'hello.txt'
|
||||
nested.parent.mkdir(parents=True)
|
||||
nested.write_text('hello')
|
||||
|
||||
code = cli.main([
|
||||
'sync',
|
||||
'--url', 'picoshare=https://example.com',
|
||||
'--token', 'token',
|
||||
str(tmp_path),
|
||||
])
|
||||
|
||||
assert code == 0
|
||||
assert fake.uploads[0][1]['file_name'] == 'hello.txt'
|
||||
assert fake.uploads[0][1]['file_path'] == 'docs/notes'
|
||||
state = json.loads((tmp_path / '.nanoshare-sync' / 'state.json').read_text())
|
||||
assert state['files']['docs/notes/hello.txt']['file_id'] == 'file_1'
|
||||
|
||||
def test_sync_adopts_and_moves_existing_remote_file(tmp_path, monkeypatch):
|
||||
fake = FakeClient()
|
||||
fake.remote_files = [{'file_id': 'file_1', 'file_name': 'hello.txt', 'file_path': '', 'file_size': '5 Bytes'}]
|
||||
monkeypatch.setattr(cli, 'make_client', lambda args: fake)
|
||||
def matching_download(client, node, file_id, path):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text('hello')
|
||||
|
||||
monkeypatch.setattr('nanoshare_client.sync.download', matching_download)
|
||||
|
||||
nested = tmp_path / 'docs' / 'hello.txt'
|
||||
nested.parent.mkdir(parents=True)
|
||||
nested.write_text('hello')
|
||||
|
||||
code = cli.main([
|
||||
'sync',
|
||||
'--url', 'picoshare=https://example.com',
|
||||
'--token', 'token',
|
||||
str(tmp_path),
|
||||
])
|
||||
|
||||
assert code == 0
|
||||
assert fake.uploads == []
|
||||
assert fake.remote_files[0]['file_name'] == 'hello.txt'
|
||||
assert fake.remote_files[0]['file_path'] == 'docs'
|
||||
state = json.loads((tmp_path / '.nanoshare-sync' / 'state.json').read_text())
|
||||
assert state['files']['docs/hello.txt']['file_id'] == 'file_1'
|
||||
|
||||
def test_sync_does_not_adopt_same_name_with_different_hash(tmp_path, monkeypatch):
|
||||
fake = FakeClient()
|
||||
fake.remote_files = [{'file_id': 'file_1', 'file_name': 'hello.txt', 'file_path': '', 'file_size': '5 Bytes'}]
|
||||
monkeypatch.setattr(cli, 'make_client', lambda args: fake)
|
||||
monkeypatch.setattr('nanoshare_client.sync.download', fake_download)
|
||||
|
||||
local = tmp_path / 'hello.txt'
|
||||
local.write_text('other')
|
||||
|
||||
code = cli.main([
|
||||
'sync',
|
||||
'--url', 'picoshare=https://example.com',
|
||||
'--token', 'token',
|
||||
str(tmp_path),
|
||||
])
|
||||
|
||||
assert code == 0
|
||||
assert len(fake.uploads) == 1
|
||||
assert fake.uploads[0][1]['file_name'] == 'hello.txt'
|
||||
|
||||
def test_sync_downloads_remote_subfolders(tmp_path, monkeypatch):
|
||||
fake = FakeClient()
|
||||
fake.remote_files = [{'file_id': 'file_1', 'file_name': 'hello.txt', 'file_path': 'docs/notes', 'file_size': '5 B'}]
|
||||
monkeypatch.setattr(cli, 'make_client', lambda args: fake)
|
||||
monkeypatch.setattr('nanoshare_client.sync.download', fake_download)
|
||||
|
||||
code = cli.main([
|
||||
'sync',
|
||||
'--url', 'picoshare=https://example.com',
|
||||
'--token', 'token',
|
||||
str(tmp_path),
|
||||
])
|
||||
|
||||
assert code == 0
|
||||
assert (tmp_path / 'docs' / 'notes' / 'hello.txt').read_text() == 'downloaded file_1'
|
||||
state = json.loads((tmp_path / '.nanoshare-sync' / 'state.json').read_text())
|
||||
assert state['files']['docs/notes/hello.txt']['file_id'] == 'file_1'
|
||||
|
||||
def test_sync_ignores_local_files_from_nanoshareignore(tmp_path, monkeypatch):
|
||||
fake = FakeClient()
|
||||
monkeypatch.setattr(cli, 'make_client', lambda args: fake)
|
||||
|
||||
(tmp_path / '.nanoshareignore').write_text('cache/\n*.tmp\n')
|
||||
(tmp_path / 'keep.txt').write_text('keep')
|
||||
(tmp_path / 'cache').mkdir()
|
||||
(tmp_path / 'cache' / 'ignored.txt').write_text('ignored')
|
||||
(tmp_path / 'scratch.tmp').write_text('ignored')
|
||||
|
||||
code = cli.main([
|
||||
'sync',
|
||||
'--url', 'picoshare=https://example.com',
|
||||
'--token', 'token',
|
||||
str(tmp_path),
|
||||
])
|
||||
|
||||
assert code == 0
|
||||
assert [upload[1]['file_name'] for upload in fake.uploads] == ['keep.txt']
|
||||
|
||||
def test_sync_ignores_remote_files_from_cli_pattern(tmp_path, monkeypatch):
|
||||
fake = FakeClient()
|
||||
fake.remote_files = [
|
||||
{'file_id': 'file_1', 'file_name': 'keep.txt', 'file_path': '', 'file_size': '5 B'},
|
||||
{'file_id': 'file_2', 'file_name': 'ignored.txt', 'file_path': 'cache', 'file_size': '5 B'},
|
||||
]
|
||||
monkeypatch.setattr(cli, 'make_client', lambda args: fake)
|
||||
monkeypatch.setattr('nanoshare_client.sync.download', fake_download)
|
||||
|
||||
code = cli.main([
|
||||
'sync',
|
||||
'--url', 'picoshare=https://example.com',
|
||||
'--token', 'token',
|
||||
'--ignore', 'cache/',
|
||||
str(tmp_path),
|
||||
])
|
||||
|
||||
assert code == 0
|
||||
assert (tmp_path / 'keep.txt').is_file()
|
||||
assert not (tmp_path / 'cache' / 'ignored.txt').exists()
|
||||
|
||||
def test_ignore_patterns_match_files_and_directories():
|
||||
patterns = ['cache/', '*.tmp', 'docs/*.draft.md']
|
||||
|
||||
assert is_ignored('cache/file.txt', patterns)
|
||||
assert is_ignored('nested/scratch.tmp', patterns)
|
||||
assert is_ignored('docs/page.draft.md', patterns)
|
||||
assert not is_ignored('docs/page.md', patterns)
|
||||
|
||||
def test_completion_scripts_include_commands():
|
||||
zsh = completion_script('zsh')
|
||||
bash = completion_script('bash')
|
||||
@@ -66,14 +215,15 @@ def test_completion_scripts_include_commands():
|
||||
assert 'login upload list download sync watch completion' in zsh
|
||||
assert 'login upload list download sync watch completion' in bash
|
||||
|
||||
def test_resolve_remote_file_accepts_id_or_exact_name():
|
||||
def test_resolve_remote_file_accepts_id_exact_name_or_path():
|
||||
files = [
|
||||
{'file_id': 'file_1', 'file_name': 'report.pdf'},
|
||||
{'file_id': 'file_1', 'file_name': 'report.pdf', 'file_path': 'docs'},
|
||||
{'file_id': 'file_2', 'file_name': 'photo.png'},
|
||||
]
|
||||
|
||||
assert cli._resolve_remote_file(files, 'file_1')['file_name'] == 'report.pdf'
|
||||
assert cli._resolve_remote_file(files, 'photo.png')['file_id'] == 'file_2'
|
||||
assert cli._resolve_remote_file(files, 'docs/report.pdf')['file_id'] == 'file_1'
|
||||
|
||||
def test_resolve_remote_file_rejects_duplicate_names():
|
||||
files = [
|
||||
@@ -90,18 +240,18 @@ def test_resolve_remote_file_rejects_duplicate_names():
|
||||
|
||||
def test_format_table_aligns_columns():
|
||||
output = format_table(
|
||||
['ID', 'Name', 'Size'],
|
||||
['ID', 'Path', 'Name', 'Size'],
|
||||
[
|
||||
['abc', 'short.txt', '1 KB'],
|
||||
['longer-id', 'a much longer file name.png', '22 MB'],
|
||||
['abc', '', 'short.txt', '1 KB'],
|
||||
['longer-id', 'docs', 'a much longer file name.png', '22 MB'],
|
||||
],
|
||||
)
|
||||
|
||||
assert output.splitlines() == [
|
||||
'ID Name Size',
|
||||
'--------- --------------------------- -----',
|
||||
'abc short.txt 1 KB',
|
||||
'longer-id a much longer file name.png 22 MB',
|
||||
'ID Path Name Size',
|
||||
'--------- ---- --------------------------- -----',
|
||||
'abc short.txt 1 KB',
|
||||
'longer-id docs a much longer file name.png 22 MB',
|
||||
]
|
||||
|
||||
def test_login_callback_returns_without_waiting_for_timeout():
|
||||
|
||||
Reference in New Issue
Block a user