Compare commits

...

8 Commits

Author SHA1 Message Date
daniel156161 d3e5e11f8d fix(cli): ignore generated folders in subfolders
Build and Push Docker Container / build-and-push (push) Successful in 3m12s
- Match directory ignore patterns against every path segment.
- Prevent nested .comments folders from uploading XML sidecar files.
- Bump the standalone CLI to 0.4.2 for the ignore fix.
2026-07-28 00:14:00 +02:00
daniel156161 08d0d154c9 fix(api): keep file event streams alive
Build and Push Docker Container / build-and-push (push) Successful in 2m7s
- Disable the Quart response timeout for authenticated file event streams.
- Send SSE keepalives more often to avoid idle proxy disconnects.
- Treat remote stream interruptions as reconnectable disconnects in watch output.
- Bump NanoShare to 1.26.0 and the standalone CLI to 0.4.1.
2026-07-27 22:51:03 +02:00
daniel156161 d3c914285c feat(cli): watch files with event streams
Build and Push Docker Container / build-and-push (push) Successful in 2m3s
- Add a private SSE endpoint that emits file change events for authenticated clients.
- Replace the watch sleep loop with watchdog local file events and remote SSE triggers.
- Debounce local event bursts and keep watch running after transient sync failures.
- Add watchdog as a standalone CLI dependency and cover watch behavior in tests.
- Bump NanoShare to 1.25.0 and the standalone CLI to 0.4.0.
2026-07-27 22:18:09 +02:00
daniel156161 ffdcc979e6 fix(cli): ignore generated folders by default
- Skip .comments, virtualenvs, env files, VCS folders, and OS metadata in sync.
- Apply defaults before project .nanoshareignore and repeatable --ignore rules.
- Cover default ignores for local uploads and direct pattern matching.
2026-07-27 22:01:52 +02:00
daniel156161 472ed11e9f test(cli): cover expiry preservation on moves
- Assert tracked-file moves keep an existing remote expires_at value.
- Keep the move regression focused on metadata preservation.
2026-07-27 21:52:44 +02:00
daniel156161 2b0215cd94 fix(cli): preserve notes during sync moves
Build and Push Docker Container / build-and-push (push) Successful in 1m52s
- Leave sync and watch notes empty by default instead of writing placeholder text.
- Preserve existing remote notes when moving or adopting files without --note.
- Detect tracked local moves by SHA-256 and update remote metadata safely.
- Skip files that vanish during watch scans instead of aborting the sync cycle.
- Bump NanoShare to 1.24.0 and the standalone CLI to 0.3.0.
2026-07-27 21:40:30 +02:00
daniel156161 993337be9f feat(cli): sync folders with remote paths
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.
2026-07-27 20:53:00 +02:00
daniel156161 77c76b16c4 feat(cli): improve file discovery and completion
- Render remote files as an aligned table with JSON and TSV alternatives.
- Add Bash and Zsh completion for commands, options, paths, and remote files.
- Allow downloads by exact filename while rejecting ambiguous duplicate names.
- Cover table formatting, completion scripts, and selector resolution with tests.
2026-07-27 17:30:43 +02:00
18 changed files with 940 additions and 44 deletions
+57 -15
View File
@@ -3,14 +3,17 @@ from __future__ import annotations
import argparse import argparse
import json import json
import sys import sys
import time
from pathlib import Path from pathlib import Path
from .app import make_client from .app import make_client
from .auth import login as auth_login from .auth import login as auth_login
from .client import download, list_remote, upload from .client import download, list_remote, upload
from .completion import script as completion_script
from .config import DEFAULT_CONFIG from .config import DEFAULT_CONFIG
from .remote_path import join_remote_path
from .sync import sync_once from .sync import sync_once
from .table import format_table
from .watch import watch_loop
DEFAULT_NODE = 'picoshare' DEFAULT_NODE = 'picoshare'
@@ -42,21 +45,50 @@ def _cmd_list(args) -> int:
return 1 return 1
finally: finally:
client.close() client.close()
for item in files: if args.format == 'json':
print(f"{item.get('file_id')}\t{item.get('file_name')}\t{item.get('file_size', '')}") print(json.dumps(files, indent=2, ensure_ascii=False))
elif args.format == 'tsv':
for item in files:
print(f"{item.get('file_id', '')}\t{item.get('file_path', '')}\t{item.get('file_name', '')}\t{item.get('file_size', '')}")
else:
rows = [
[item.get('file_id'), item.get('file_path', ''), item.get('file_name'), item.get('file_size', '')]
for item in files
]
print(format_table(['ID', 'Path', 'Name', 'Size'], rows))
return 0 return 0
def _resolve_remote_file(files: list[dict], selector: str) -> dict:
id_matches = [item for item in files if item.get('file_id') == selector]
if id_matches:
return id_matches[0]
full_path_matches = [item for item in files if join_remote_path(str(item.get('file_name') or ''), str(item.get('file_path') or ''), str(item.get('file_id') or '')) == selector]
if full_path_matches:
if len(full_path_matches) > 1:
raise RuntimeError(f'multiple remote files at {selector!r}; use the file ID')
return full_path_matches[0]
name_matches = [item for item in files if item.get('file_name') == selector]
if not name_matches:
raise FileNotFoundError(f'no remote file matches: {selector}')
if len(name_matches) > 1:
raise RuntimeError(f'multiple remote files named {selector!r}; use the file ID or full path')
return name_matches[0]
def _cmd_download(args) -> int: def _cmd_download(args) -> int:
dest = Path(args.output).expanduser() if args.output else Path(args.file_id)
client = make_client(args) client = make_client(args)
try: try:
download(client, args.node, args.file_id, dest) remote = _resolve_remote_file(list_remote(client, args.node), args.file)
file_id = str(remote['file_id'])
file_name = str(remote.get('file_name') or file_id)
remote_name = join_remote_path(file_name, str(remote.get('file_path') or ''), file_id)
dest = Path(args.output).expanduser() if args.output else Path(file_name)
download(client, args.node, file_id, dest)
except Exception as exc: except Exception as exc:
print(f'error: {exc}', file=sys.stderr) print(f'error: {exc}', file=sys.stderr)
return 1 return 1
finally: finally:
client.close() client.close()
print(f'downloaded {args.file_id} -> {dest}') print(f'downloaded {remote_name} ({file_id}) -> {dest}')
return 0 return 0
def _cmd_sync(args) -> int: def _cmd_sync(args) -> int:
@@ -67,12 +99,15 @@ def _cmd_sync(args) -> int:
client.close() client.close()
def _cmd_watch(args) -> int: def _cmd_watch(args) -> int:
print(f'watching {args.folder} every {args.interval}s') return watch_loop(args, _cmd_sync)
while True:
code = _cmd_sync(args) def _cmd_completion(args) -> int:
if code: try:
return code print(completion_script(args.shell))
time.sleep(args.interval) except ValueError as exc:
print(f'error: {exc}', file=sys.stderr)
return 1
return 0
def _add_common(parser: argparse.ArgumentParser) -> None: def _add_common(parser: argparse.ArgumentParser) -> None:
parser.add_argument('--config', default=str(DEFAULT_CONFIG), help='Path to config.toml.') parser.add_argument('--config', default=str(DEFAULT_CONFIG), help='Path to config.toml.')
@@ -111,31 +146,38 @@ def main(argv: list[str] | None = None) -> int:
list_cmd = sub.add_parser('list', help='List remote files.') list_cmd = sub.add_parser('list', help='List remote files.')
_add_common(list_cmd) _add_common(list_cmd)
list_cmd.add_argument('--format', choices=['table', 'json', 'tsv'], default='table')
list_cmd.set_defaults(func=_cmd_list) list_cmd.set_defaults(func=_cmd_list)
download_cmd = sub.add_parser('download', help='Download one file via private API endpoint.') download_cmd = sub.add_parser('download', help='Download one file via private API endpoint.')
_add_common(download_cmd) _add_common(download_cmd)
download_cmd.add_argument('file_id') download_cmd.add_argument('file', help='Remote file ID or exact file name.')
download_cmd.add_argument('-o', '--output') download_cmd.add_argument('-o', '--output')
download_cmd.set_defaults(func=_cmd_download) download_cmd.set_defaults(func=_cmd_download)
sync_cmd = sub.add_parser('sync', help='Two-way folder sync MVP.') sync_cmd = sub.add_parser('sync', help='Two-way folder sync MVP.')
_add_common(sync_cmd) _add_common(sync_cmd)
sync_cmd.add_argument('folder') sync_cmd.add_argument('folder')
sync_cmd.add_argument('--note', default='synced from nanoshare cli') sync_cmd.add_argument('--note', default='')
sync_cmd.add_argument('--expires', default='') sync_cmd.add_argument('--expires', default='')
sync_cmd.add_argument('--delete', action='store_true', help='Delete remote files that were deleted locally.') sync_cmd.add_argument('--delete', action='store_true', help='Delete remote files that were deleted locally.')
sync_cmd.add_argument('--ignore', action='append', default=[], help='Ignore glob pattern for sync; repeatable.')
sync_cmd.set_defaults(func=_cmd_sync) sync_cmd.set_defaults(func=_cmd_sync)
watch_cmd = sub.add_parser('watch', help='Run sync repeatedly.') watch_cmd = sub.add_parser('watch', help='Run sync repeatedly.')
_add_common(watch_cmd) _add_common(watch_cmd)
watch_cmd.add_argument('folder') watch_cmd.add_argument('folder')
watch_cmd.add_argument('--note', default='synced from nanoshare cli') watch_cmd.add_argument('--note', default='')
watch_cmd.add_argument('--expires', default='') watch_cmd.add_argument('--expires', default='')
watch_cmd.add_argument('--delete', action='store_true') watch_cmd.add_argument('--delete', action='store_true')
watch_cmd.add_argument('--ignore', action='append', default=[], help='Ignore glob pattern for sync; repeatable.')
watch_cmd.add_argument('--interval', type=float, default=10.0) watch_cmd.add_argument('--interval', type=float, default=10.0)
watch_cmd.set_defaults(func=_cmd_watch) watch_cmd.set_defaults(func=_cmd_watch)
completion_cmd = sub.add_parser('completion', help='Print shell completion script.')
completion_cmd.add_argument('shell', choices=['zsh', 'bash'])
completion_cmd.set_defaults(func=_cmd_completion)
args = parser.parse_args(argv) args = parser.parse_args(argv)
return args.func(args) return args.func(args)
+23 -3
View File
@@ -10,7 +10,7 @@ import httpx
def content_type(path: Path) -> str: def content_type(path: Path) -> str:
return mimetypes.guess_type(path.name)[0] or 'application/octet-stream' return mimetypes.guess_type(path.name)[0] or 'application/octet-stream'
def upload(client, node: str, path: Path, remote_name: str, note: str, expires: str) -> dict: def upload(client, node: str, path: Path, remote_name: str, note: str, expires: str, remote_path: str = '') -> dict:
content_b64 = base64.b64encode(path.read_bytes()).decode('ascii') content_b64 = base64.b64encode(path.read_bytes()).decode('ascii')
params = { params = {
'file_name': remote_name, 'file_name': remote_name,
@@ -18,11 +18,25 @@ def upload(client, node: str, path: Path, remote_name: str, note: str, expires:
'content_type': content_type(path), 'content_type': content_type(path),
'note': note, 'note': note,
} }
if remote_path:
params['file_path'] = remote_path
if expires: if expires:
params['expires'] = expires params['expires'] = expires
result = client.call(node, 'files.upload', params) result = client.call(node, 'files.upload', params)
return result if isinstance(result, dict) else {'result': result} return result if isinstance(result, dict) else {'result': result}
def update_remote(client, node: str, file_id: str, file_name: str, file_path: str, note: str | None, expires: str) -> None:
params = {
'file_id': file_id,
'file_name': file_name,
'file_path': file_path,
}
if note is not None:
params['note'] = note
if expires:
params['expires'] = expires
client.call(node, 'files.update', params)
def delete_remote(client, node: str, file_id: str) -> None: def delete_remote(client, node: str, file_id: str) -> None:
client.call(node, 'files.delete', {'file_id': file_id}) client.call(node, 'files.delete', {'file_id': file_id})
@@ -31,11 +45,17 @@ def list_remote(client, node: str) -> list[dict]:
files = result.get('files', []) if isinstance(result, dict) else [] files = result.get('files', []) if isinstance(result, dict) else []
return files if isinstance(files, list) else [] return files if isinstance(files, list) else []
def download_url(client, node: str, file_id: str) -> str: def node_url(client, node: str, path: str) -> str:
base = client.registry.get(node) base = client.registry.get(node)
if not base: if not base:
raise RuntimeError(f'unknown node: {node}') raise RuntimeError(f'unknown node: {node}')
return f"{base.rstrip('/')}/api/files/{quote(file_id, safe='')}/download" return f"{base.rstrip('/')}{path}"
def download_url(client, node: str, file_id: str) -> str:
return node_url(client, node, f"/api/files/{quote(file_id, safe='')}/download")
def events_url(client, node: str) -> str:
return node_url(client, node, '/api/files/events')
def download(client, node: str, file_id: str, path: Path) -> None: def download(client, node: str, file_id: str, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
+108
View File
@@ -0,0 +1,108 @@
from __future__ import annotations
_ZSH = r'''# nanoshare zsh completion -- add to ~/.zshrc: eval "$(nanoshare completion zsh)"
_nanoshare_files() {
local id path name size label
while IFS=$'\t' read -r id path name size; do
label="${path:+$path/}$name"
[[ -n $id ]] && printf '%s:%s\n' "$id" "$label"
done < <(nanoshare list --format tsv 2>/dev/null)
}
_nanoshare() {
local -a commands common_opts login_opts sync_opts watch_opts download_opts
commands=(login upload list download sync watch completion)
common_opts=(--config --url --token --refresh-token --token-url --source --timeout --node)
login_opts=(--config --node --source --scope --callback-host --callback-port --login-timeout --timeout --no-browser)
sync_opts=($common_opts --note --expires --delete --ignore)
watch_opts=($sync_opts --interval)
download_opts=($common_opts --output -o)
if (( CURRENT == 2 )); then
compadd -- $commands
return
fi
case ${words[2]} in
login)
compadd -- $login_opts
;;
upload)
compadd -- $common_opts --name --note --expires
_files
;;
list)
compadd -- $common_opts --format
;;
download)
if (( CURRENT == 3 )); then
local -a remote_files
remote_files=("${(@f)$(_nanoshare_files)}")
_describe 'remote file' remote_files
else
compadd -- $download_opts
_files
fi
;;
sync|watch)
compadd -- ${(P)${:-${words[2]}_opts}}
_files -/
;;
completion)
(( CURRENT == 3 )) && compadd -- zsh bash
;;
esac
}
compdef _nanoshare nanoshare
'''
_BASH = r'''# nanoshare bash completion -- add to ~/.bashrc: eval "$(nanoshare completion bash)"
_nanoshare_files() {
nanoshare list --format tsv 2>/dev/null
}
_nanoshare() {
local cur="${COMP_WORDS[COMP_CWORD]}" cmd="${COMP_WORDS[1]}"
local commands="login upload list download sync watch completion"
local common_opts="--config --url --token --refresh-token --token-url --source --timeout --node"
local login_opts="--config --node --source --scope --callback-host --callback-port --login-timeout --timeout --no-browser"
local sync_opts="$common_opts --note --expires --delete --ignore"
local watch_opts="$sync_opts --interval"
local download_opts="$common_opts --output -o"
if [ "$COMP_CWORD" -eq 1 ]; then
COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
return
fi
case "$cmd" in
login)
COMPREPLY=( $(compgen -W "$login_opts" -- "$cur") ) ;;
upload)
COMPREPLY=( $(compgen -W "$common_opts --name --note --expires" -- "$cur") ) ;;
list)
COMPREPLY=( $(compgen -W "$common_opts --format" -- "$cur") ) ;;
download)
if [ "$COMP_CWORD" -eq 2 ]; then
COMPREPLY=( $(compgen -W "$(_nanoshare_files | cut -f1)" -- "$cur") )
else
COMPREPLY=( $(compgen -W "$download_opts" -- "$cur") )
fi ;;
sync)
COMPREPLY=( $(compgen -W "$sync_opts" -- "$cur") ) ;;
watch)
COMPREPLY=( $(compgen -W "$watch_opts" -- "$cur") ) ;;
completion)
[ "$COMP_CWORD" -eq 2 ] && COMPREPLY=( $(compgen -W "zsh bash" -- "$cur") ) ;;
esac
}
complete -F _nanoshare nanoshare
'''
def script(shell: str) -> str:
if shell == 'zsh':
return _ZSH
if shell == 'bash':
return _BASH
raise ValueError(f'unsupported shell: {shell}')
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
from fnmatch import fnmatch
from pathlib import Path
IGNORE_FILE_NAME = '.nanoshareignore'
DEFAULT_IGNORE_PATTERNS = [
IGNORE_FILE_NAME,
'.comments/',
'.env',
'.env.*',
'.venv/',
'venv/',
'__pycache__/',
'.pytest_cache/',
'.mypy_cache/',
'.ruff_cache/',
'.git/',
'.hg/',
'.svn/',
'.DS_Store',
'Thumbs.db',
'desktop.ini',
'$RECYCLE.BIN/',
'System Volume Information/',
]
def load_ignore_patterns(root: Path, extra_patterns: list[str] | None = None) -> list[str]:
patterns = list(DEFAULT_IGNORE_PATTERNS)
ignore_file = root / IGNORE_FILE_NAME
if ignore_file.is_file():
for line in ignore_file.read_text().splitlines():
pattern = line.strip()
if pattern and not pattern.startswith('#'):
patterns.append(pattern)
patterns.extend(pattern for pattern in extra_patterns or [] if pattern)
return patterns
def is_ignored(rel_path: str, patterns: list[str]) -> bool:
path = rel_path.strip('/')
parts = path.split('/')
for pattern in patterns:
normalized = pattern.strip().replace('\\', '/').strip('/')
if not normalized:
continue
if pattern.endswith('/'):
if path == normalized or path.startswith(f'{normalized}/'):
return True
if any(fnmatch(part, normalized) for part in parts):
return True
continue
if '/' in normalized:
if fnmatch(path, normalized):
return True
elif any(fnmatch(part, normalized) for part in parts):
return True
return False
+18
View File
@@ -0,0 +1,18 @@
from __future__ import annotations
from pathlib import PurePosixPath
def split_remote_path(value: str, file_id: str = '') -> tuple[str, str]:
raw = (value or file_id).replace('\\', '/')
parts = [
part for part in PurePosixPath(raw).parts
if part not in ('', '.', '..', '/')
]
if not parts:
return file_id, ''
return parts[-1], '/'.join(parts[:-1])
def join_remote_path(file_name: str, file_path: str | None = None, file_id: str = '') -> str:
name, embedded_path = split_remote_path(file_name, file_id)
clean_path = split_remote_path(f'{file_path or ""}/placeholder')[1] if file_path else embedded_path
return f'{clean_path}/{name}' if clean_path else name
+131 -13
View File
@@ -5,10 +5,13 @@ import json
import os import os
import shutil import shutil
import sys import sys
import tempfile
import time import time
from pathlib import Path from pathlib import Path
from .client import delete_remote, download, list_remote, upload from .client import delete_remote, download, list_remote, update_remote, upload
from .ignore import is_ignored, load_ignore_patterns
from .remote_path import join_remote_path, split_remote_path
SYNC_DIR_NAME = '.nanoshare-sync' SYNC_DIR_NAME = '.nanoshare-sync'
STATE_FILE_NAME = 'state.json' STATE_FILE_NAME = 'state.json'
@@ -44,21 +47,23 @@ def save_state(root: Path, state: dict) -> None:
tmp.write_text(json.dumps(state, indent=2, sort_keys=True)) tmp.write_text(json.dumps(state, indent=2, sort_keys=True))
os.replace(tmp, path) os.replace(tmp, path)
def iter_local_files(root: Path): def iter_local_files(root: Path, ignore_patterns: list[str] | None = None):
sync_dir = root / SYNC_DIR_NAME sync_dir = root / SYNC_DIR_NAME
patterns = ignore_patterns or []
for path in sorted(root.rglob('*')): for path in sorted(root.rglob('*')):
if not path.is_file(): if not path.is_file():
continue continue
if path == state_path(root) or sync_dir in path.parents: if path == state_path(root) or sync_dir in path.parents:
continue continue
if is_ignored(relative(root, path), patterns):
continue
yield path yield path
def relative(root: Path, path: Path) -> str: def relative(root: Path, path: Path) -> str:
return path.relative_to(root).as_posix() return path.relative_to(root).as_posix()
def safe_remote_name(name: str, file_id: str) -> str: def safe_remote_name(name: str, file_id: str) -> str:
candidate = Path(name or file_id).name return join_remote_path(name, file_id=file_id)
return candidate or file_id
def unique_path(path: Path) -> Path: def unique_path(path: Path) -> Path:
if not path.exists(): if not path.exists():
@@ -80,22 +85,90 @@ def remote_by_name(remote_files: list[dict]) -> dict[str, dict]:
result = {} result = {}
for item in remote_files: for item in remote_files:
if isinstance(item, dict) and item.get('file_name') and item.get('file_id'): if isinstance(item, dict) and item.get('file_name') and item.get('file_id'):
result.setdefault(item['file_name'], item) result.setdefault(join_remote_path(item['file_name'], item.get('file_path') or ''), item)
return result return result
def parse_size(value: object) -> int | None:
if isinstance(value, int):
return value
text = str(value or '').strip()
if not text:
return None
parts = text.split()
try:
number = float(parts[0])
except (ValueError, IndexError):
return None
unit = parts[1].lower() if len(parts) > 1 else 'bytes'
factors = {
'byte': 1,
'bytes': 1,
'b': 1,
'kb': 1024,
'mb': 1024 ** 2,
'gb': 1024 ** 3,
'tb': 1024 ** 4,
}
factor = factors.get(unit)
return int(number * factor) if factor else None
def find_moved_entry(root: Path, known: dict, rel: str, local_hash: str, remote_ids: dict[str, dict]) -> tuple[str, dict] | None:
for old_rel, entry in known.items():
if old_rel == rel or not isinstance(entry, dict):
continue
file_id = entry.get('file_id')
if entry.get('sha256') == local_hash and file_id in remote_ids and not (root / old_rel).is_file():
return old_rel, entry
return None
def find_adoptable_remote(client, node: str, path: Path, local_hash: str, remote_files: list[dict], known_ids: set[str]) -> dict | None:
size = path.stat().st_size
name = path.name
candidates = []
for remote in remote_files:
if not isinstance(remote, dict):
continue
file_id = remote.get('file_id')
if not file_id or file_id in known_ids:
continue
if remote.get('file_name') != name:
continue
remote_size = parse_size(remote.get('size_bytes') or remote.get('file_size'))
if remote_size is not None and remote_size != size:
continue
candidates.append(remote)
hash_matches = []
with tempfile.TemporaryDirectory(prefix='nanoshare-adopt-') as temp_dir:
for remote in candidates:
temp_path = Path(temp_dir) / str(remote['file_id'])
try:
download(client, node, str(remote['file_id']), temp_path)
except Exception as exc:
print(f"warning: could not verify remote {remote['file_id']} for adoption: {exc}", file=sys.stderr)
continue
if sha256(temp_path) == local_hash:
hash_matches.append(remote)
return hash_matches[0] if len(hash_matches) == 1 else None
def sync_once(args, client) -> int: def sync_once(args, client) -> int:
root = Path(args.folder).expanduser().resolve() root = Path(args.folder).expanduser().resolve()
root.mkdir(parents=True, exist_ok=True) root.mkdir(parents=True, exist_ok=True)
state = load_state(root) state = load_state(root)
known = state.setdefault('files', {}) known = state.setdefault('files', {})
try: try:
ignore_patterns = load_ignore_patterns(root, getattr(args, 'ignore', None))
remote_files = list_remote(client, args.node) remote_files = list_remote(client, args.node)
remote_ids = remote_by_id(remote_files) remote_ids = remote_by_id(remote_files)
remote_names = remote_by_name(remote_files) remote_names = remote_by_name(remote_files)
local_paths = {relative(root, path): path for path in iter_local_files(root)}
for rel, path in local_paths.items(): for path in iter_local_files(root, ignore_patterns):
current_hash = sha256(path) rel = relative(root, path)
try:
current_hash = sha256(path)
except FileNotFoundError:
print(f'skip vanished local file: {rel}')
continue
entry = known.get(rel) entry = known.get(rel)
remote_id = entry.get('file_id') if isinstance(entry, dict) else None remote_id = entry.get('file_id') if isinstance(entry, dict) else None
old_hash = entry.get('sha256') if isinstance(entry, dict) else None old_hash = entry.get('sha256') if isinstance(entry, dict) else None
@@ -103,13 +176,53 @@ def sync_once(args, client) -> int:
if old_hash == current_hash and remote_id in remote_ids: if old_hash == current_hash and remote_id in remote_ids:
continue continue
remote_file_name, remote_file_path = split_remote_path(rel)
if not entry:
moved = find_moved_entry(root, known, rel, current_hash, remote_ids)
if moved:
old_rel, moved_entry = moved
moved_id = moved_entry['file_id']
update_remote(client, args.node, moved_id, remote_file_name, remote_file_path, args.note if args.note else None, args.expires or '')
print(f'move remote: {old_rel} -> {rel}')
known.pop(old_rel, None)
known[rel] = {
'file_id': moved_id,
'sha256': current_hash,
'local_mtime': path.stat().st_mtime,
'file_name': remote_file_name,
'file_path': remote_file_path,
}
remote_files = list_remote(client, args.node)
remote_ids = remote_by_id(remote_files)
remote_names = remote_by_name(remote_files)
continue
adopted = find_adoptable_remote(client, args.node, path, current_hash, remote_files, {value.get('file_id') for value in known.values() if isinstance(value, dict)})
if adopted:
adopted_id = adopted['file_id']
current_remote_name = join_remote_path(adopted.get('file_name') or '', adopted.get('file_path') or '', adopted_id)
if current_remote_name != rel:
update_remote(client, args.node, adopted_id, remote_file_name, remote_file_path, args.note if args.note else None, args.expires or '')
print(f'move remote: {current_remote_name} -> {rel}')
known[rel] = {
'file_id': adopted_id,
'sha256': current_hash,
'local_mtime': path.stat().st_mtime,
'file_name': remote_file_name,
'file_path': remote_file_path,
}
remote_files = list_remote(client, args.node)
remote_ids = remote_by_id(remote_files)
remote_names = remote_by_name(remote_files)
continue
if remote_id and remote_id not in remote_ids and old_hash != current_hash: if remote_id and remote_id not in remote_ids and old_hash != current_hash:
conflict_path = unique_path(path) conflict_path = unique_path(path)
shutil.copy2(path, conflict_path) shutil.copy2(path, conflict_path)
print(f'conflict: kept changed local copy at {conflict_path}') print(f'conflict: kept changed local copy at {conflict_path}')
print(f'upload: {rel}') print(f'upload: {rel}')
result = upload(client, args.node, path, rel, args.note or '', args.expires or '') result = upload(client, args.node, path, remote_file_name, args.note or '', args.expires or '', remote_file_path)
new_file_id = result.get('file_id') or result.get('id') new_file_id = result.get('file_id') or result.get('id')
if not new_file_id: if not new_file_id:
matching = remote_names.get(rel) matching = remote_names.get(rel)
@@ -134,7 +247,8 @@ def sync_once(args, client) -> int:
'file_id': new_file_id, 'file_id': new_file_id,
'sha256': current_hash, 'sha256': current_hash,
'local_mtime': path.stat().st_mtime, 'local_mtime': path.stat().st_mtime,
'file_name': rel, 'file_name': remote_file_name,
'file_path': remote_file_path,
} }
known_ids = {entry.get('file_id') for entry in known.values() if isinstance(entry, dict)} known_ids = {entry.get('file_id') for entry in known.values() if isinstance(entry, dict)}
@@ -142,20 +256,24 @@ def sync_once(args, client) -> int:
file_id = remote.get('file_id') file_id = remote.get('file_id')
if not file_id or file_id in known_ids: if not file_id or file_id in known_ids:
continue continue
name = safe_remote_name(remote.get('file_name') or '', file_id) name = join_remote_path(remote.get('file_name') or '', remote.get('file_path') or '', file_id)
if is_ignored(name, ignore_patterns):
continue
path = unique_path(root / name) path = unique_path(root / name)
print(f'download: {file_id} -> {relative(root, path)}') print(f'download: {file_id} -> {relative(root, path)}')
download(client, args.node, file_id, path) download(client, args.node, file_id, path)
remote_file_name, remote_file_path = split_remote_path(name)
known[relative(root, path)] = { known[relative(root, path)] = {
'file_id': file_id, 'file_id': file_id,
'sha256': sha256(path), 'sha256': sha256(path),
'local_mtime': path.stat().st_mtime, 'local_mtime': path.stat().st_mtime,
'file_name': relative(root, path), 'file_name': remote_file_name,
'file_path': remote_file_path,
} }
if args.delete: if args.delete:
for rel, entry in list(known.items()): for rel, entry in list(known.items()):
if rel in local_paths: if (root / rel).is_file():
continue continue
file_id = entry.get('file_id') if isinstance(entry, dict) else None file_id = entry.get('file_id') if isinstance(entry, dict) else None
if file_id and file_id in remote_ids: if file_id and file_id in remote_ids:
+26
View File
@@ -0,0 +1,26 @@
from __future__ import annotations
from collections.abc import Iterable
def _display_width(value: str) -> int:
return len(value)
def _cell(value: object) -> str:
return '' if value is None else str(value)
def format_table(headers: list[str], rows: Iterable[Iterable[object]]) -> str:
string_rows = [[_cell(value) for value in row] for row in rows]
widths = [_display_width(header) for header in headers]
for row in string_rows:
for index, value in enumerate(row):
if index < len(widths):
widths[index] = max(widths[index], _display_width(value))
def render_row(values: list[str]) -> str:
padded = [value.ljust(widths[index]) for index, value in enumerate(values)]
return ' '.join(padded).rstrip()
separator = ' '.join('-' * width for width in widths).rstrip()
lines = [render_row(headers), separator]
lines.extend(render_row(row) for row in string_rows)
return '\n'.join(lines)
+127
View File
@@ -0,0 +1,127 @@
from __future__ import annotations
import sys
import threading
import time
from pathlib import Path
from typing import Callable
import httpx
from .client import events_url
from .ignore import is_ignored, load_ignore_patterns
from .sync import relative, state_path, SYNC_DIR_NAME
SyncFunc = Callable[[object], int]
class _ChangeFlag:
def __init__(self) -> None:
self.event = threading.Event()
def notify(self) -> None:
self.event.set()
def wait(self, timeout: float) -> bool:
return self.event.wait(timeout)
def clear(self) -> None:
self.event.clear()
def _is_relevant_path(root: Path, path: str, ignore_patterns: list[str]) -> bool:
try:
candidate = Path(path).expanduser().resolve()
rel = relative(root, candidate)
except ValueError:
return False
if candidate == state_path(root) or root / SYNC_DIR_NAME in candidate.parents:
return False
return not is_ignored(rel, ignore_patterns)
def _remote_events_thread(args, flag: _ChangeFlag, stop: threading.Event) -> threading.Thread:
def run() -> None:
while not stop.is_set():
client = None
try:
from .app import make_client
client = make_client(args)
headers = {'Accept': 'text/event-stream', **client.auth_headers()}
with httpx.Client(timeout=None) as http:
with http.stream('GET', events_url(client, args.node), headers=headers) as response:
response.raise_for_status()
for line in response.iter_lines():
if stop.is_set():
return
if line.startswith('event: files.changed'):
flag.notify()
except Exception as exc:
if not stop.is_set():
print(f'remote event stream disconnected; retrying in 30s: {exc}', file=sys.stderr)
stop.wait(30)
finally:
if client:
client.close()
thread = threading.Thread(target=run, daemon=True)
thread.start()
return thread
def _watchdog_observer(root: Path, ignore_patterns: list[str], flag: _ChangeFlag):
from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer
class Handler(FileSystemEventHandler):
def on_any_event(self, event):
paths = [event.src_path]
dest_path = getattr(event, 'dest_path', None)
if dest_path:
paths.append(dest_path)
if any(_is_relevant_path(root, path, ignore_patterns) for path in paths):
flag.notify()
observer = Observer()
observer.schedule(Handler(), str(root), recursive=True)
observer.start()
return observer
def watch_loop(args, sync_func: SyncFunc) -> int:
root = Path(args.folder).expanduser().resolve()
root.mkdir(parents=True, exist_ok=True)
ignore_patterns = load_ignore_patterns(root, getattr(args, 'ignore', None))
interval = max(float(getattr(args, 'interval', 10.0)), 0.1)
flag = _ChangeFlag()
observer = None
stop_events = threading.Event()
events_thread = None
try:
try:
observer = _watchdog_observer(root, ignore_patterns, flag)
print(f'watching {args.folder} for local changes; remote events with {interval}s fallback')
except Exception as exc:
print(f'watchdog unavailable, polling every {interval}s: {exc}', file=sys.stderr)
print(f'watching {args.folder} every {interval}s')
events_thread = _remote_events_thread(args, flag, stop_events)
while True:
flag.clear()
code = sync_func(args)
if code:
print(f'watch sync failed; retrying in {interval}s', file=sys.stderr)
flag.wait(interval)
continue
if flag.wait(interval):
flag.clear()
time.sleep(1.0)
while flag.wait(1.0):
flag.clear()
except KeyboardInterrupt:
return 0
finally:
stop_events.set()
if observer:
observer.stop()
observer.join(timeout=5)
if events_thread:
events_thread.join(timeout=5)
+2 -1
View File
@@ -1,11 +1,12 @@
[project] [project]
name = "nanoshare-cli" name = "nanoshare-cli"
version = "0.1.0" version = "0.4.2"
description = "NanoShare desktop CLI and folder sync client" description = "NanoShare desktop CLI and folder sync client"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
"httpx==0.28.1", "httpx==0.28.1",
"watchdog==6.0.0",
] ]
[project.scripts] [project.scripts]
+9 -4
View File
@@ -30,15 +30,16 @@ class ConvexDB(ConvexDbBase):
return [ { return [ {
"file_id": x['file_id'], "file_id": x['file_id'],
"file_name": x['file_name'], "file_name": x['file_name'],
"file_path": x.get('file_path', ''),
"file_size": x['file_size'], "file_size": x['file_size'],
"note": x['note'], "note": x['note'],
"expires_at": int(x['expires_at']) if x.get('expires_at', None) else '', "expires_at": int(x['expires_at']) if x.get('expires_at', None) else '',
"uploaded_at": int(x['uploaded_at']), "uploaded_at": int(x['uploaded_at']),
} for x in data ] } for x in data ]
async def add_file(self, file_name:str, file_size:str, note:str, content_type:str, expires_at:datetime|None, storage_id:str, user_id:str): async def add_file(self, file_name:str, file_size:str, note:str, content_type:str, expires_at:datetime|None, storage_id:str, user_id:str, file_path:str=''):
args = { args = {
'file_name': file_name, 'file_size': file_size, 'content_type': content_type, 'file_name': file_name, 'file_path': file_path, 'file_size': file_size, 'content_type': content_type,
'note': note, 'note': note,
'file_storage_id': storage_id, 'user_id': user_id 'file_storage_id': storage_id, 'user_id': user_id
} }
@@ -51,13 +52,17 @@ class ConvexDB(ConvexDbBase):
) )
return data return data
async def update_file(self, file_id:str, file_name:str, note:str, expires_at:datetime|None, user_id:str): async def update_file(self, file_id:str, file_name:str, note:str|None, expires_at:datetime|None, user_id:str, file_path:str='', preserve_missing:bool=False):
args = { args = {
'file_id': file_id, 'file_id': file_id,
'file_name': file_name, 'file_name': file_name,
'note': note, 'file_path': file_path,
'user_id': user_id 'user_id': user_id
} }
if note is not None:
args['note'] = note
elif not preserve_missing:
args['note'] = ''
if expires_at: if expires_at:
args['expires_at'] = expires_at.isoformat() args['expires_at'] = expires_at.isoformat()
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "nanoshare" name = "nanoshare"
version = "1.22.0" version = "1.26.0"
description = "Add your description here" description = "Add your description here"
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"
+1
View File
@@ -21,6 +21,7 @@ from .side.upload import upload_bp
from .api.cli_auth import cli_auth_bp from .api.cli_auth import cli_auth_bp
from .api.download import api_download_bp from .api.download import api_download_bp
from .api.events import api_events_bp
# Health # Health
from .api.health import health_bp from .api.health import health_bp
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
import asyncio
import json
from my_modules.app.setup import LIMITER
from my_modules.decoratory.header import token_required
from quart import Blueprint, Response, current_app, stream_with_context
api_events_bp = Blueprint('api_events', __name__)
async def _snapshot(user_id: str) -> str:
files = await current_app.convex.get_files(user_id)
rows = []
for item in files or []:
if not isinstance(item, dict):
continue
rows.append({
'file_id': item.get('file_id'),
'file_name': item.get('file_name'),
'file_path': item.get('file_path') or '',
'file_size': item.get('file_size'),
'expires_at': item.get('expires_at'),
'updated_at': item.get('updated_at') or item.get('uploaded_at'),
})
rows.sort(key=lambda row: str(row.get('file_id') or ''))
return json.dumps(rows, sort_keys=True, separators=(',', ':'))
@api_events_bp.get('/api/files/events')
@LIMITER.limit('6 per minute;60 per hour;')
@token_required(['files', 'mesh'])
async def api_file_events(user: dict):
user_id = user['sub']
@stream_with_context
async def stream():
previous = await _snapshot(user_id)
yield 'event: ready\ndata: {}\n\n'
while True:
await asyncio.sleep(15)
current = await _snapshot(user_id)
if current != previous:
previous = current
yield f'event: files.changed\ndata: {current}\n\n'
else:
yield ': keepalive\n\n'
response = Response(stream(), content_type='text/event-stream', headers={
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no',
})
response.timeout = None
return response
+18 -4
View File
@@ -12,6 +12,8 @@ from __future__ import annotations
import base64 import base64
import os import os
from pathlib import PurePosixPath
from quart import current_app from quart import current_app
from my_modules.app.setup import LIMITER from my_modules.app.setup import LIMITER
@@ -24,6 +26,14 @@ RPC_SCOPES = ('files', 'mesh')
router = Router('picoshare') router = Router('picoshare')
def _safe_file_path(value: str) -> str:
raw = (value or '').replace('\\', '/')
parts = [
part for part in PurePosixPath(raw).parts
if part not in ('', '.', '..', '/')
]
return '/'.join(parts)
def _user_id(ctx): def _user_id(ctx):
if ctx.principal is None: if ctx.principal is None:
raise Unauthorized('authentication required') raise Unauthorized('authentication required')
@@ -47,10 +57,12 @@ async def files_upload(params, ctx):
else: else:
raise InvalidParams('provide text or content_b64') raise InvalidParams('provide text or content_b64')
file_name = params.get('file_name') or iso_stamp_filename('mesh', default_ext) file_name = PurePosixPath(str(params.get('file_name') or iso_stamp_filename('mesh', default_ext)).replace('\\', '/')).name
file_path = _safe_file_path(str(params.get('file_path') or ''))
storage_id = await current_app.convex.send_to_storage(data=data, content_type=content_type) storage_id = await current_app.convex.send_to_storage(data=data, content_type=content_type)
file_record = await current_app.convex.add_file( file_record = await current_app.convex.add_file(
file_name=file_name, file_name=file_name,
file_path=file_path,
file_size=format_size(len(data)), file_size=format_size(len(data)),
note=params.get('note', ''), note=params.get('note', ''),
content_type=content_type, content_type=content_type,
@@ -59,7 +71,7 @@ async def files_upload(params, ctx):
user_id=user_id, user_id=user_id,
) )
file_id = file_record.get('file_id') if isinstance(file_record, dict) else None file_id = file_record.get('file_id') if isinstance(file_record, dict) else None
return {'file_id': file_id, 'file_name': file_name, 'size': len(data), 'content_type': content_type} return {'file_id': file_id, 'file_name': file_name, 'file_path': file_path, 'size': len(data), 'content_type': content_type}
@router.method('files.list') @router.method('files.list')
async def files_list(params, ctx): async def files_list(params, ctx):
@@ -85,15 +97,17 @@ async def files_info(params, ctx):
@router.method('files.update') @router.method('files.update')
async def files_update(params, ctx): async def files_update(params, ctx):
file_id = params.get('file_id') file_id = params.get('file_id')
file_name = params.get('file_name') file_name = PurePosixPath(str(params.get('file_name') or '').replace('\\', '/')).name
if not file_id or not file_name: if not file_id or not file_name:
raise InvalidParams('file_id and file_name are required') raise InvalidParams('file_id and file_name are required')
await current_app.convex.update_file( await current_app.convex.update_file(
file_id=file_id, file_id=file_id,
file_name=file_name, file_name=file_name,
note=params.get('note', ''), file_path=_safe_file_path(str(params.get('file_path') or '')),
note=params.get('note') if 'note' in params else None,
expires_at=ensure_utc(parse_expires(params.get('expires', ''))), expires_at=ensure_utc(parse_expires(params.get('expires', ''))),
user_id=_user_id(ctx), user_id=_user_id(ctx),
preserve_missing=True,
) )
return {'updated': True} return {'updated': True}
+2
View File
@@ -13,6 +13,7 @@ from routes import (
upload_bp, upload_bp,
cli_auth_bp, cli_auth_bp,
api_download_bp, api_download_bp,
api_events_bp,
health_bp health_bp
) )
from routes.api.link import link_bp as servicelink_bp from routes.api.link import link_bp as servicelink_bp
@@ -25,6 +26,7 @@ app.register_blueprint(side_main_bp)
app.register_blueprint(upload_bp) app.register_blueprint(upload_bp)
app.register_blueprint(cli_auth_bp) app.register_blueprint(cli_auth_bp)
app.register_blueprint(api_download_bp) app.register_blueprint(api_download_bp)
app.register_blueprint(api_events_bp)
# ServiceLink node-to-node mesh endpoint (POST /rpc) # ServiceLink node-to-node mesh endpoint (POST /rpc)
app.register_blueprint(servicelink_bp) app.register_blueprint(servicelink_bp)
+304 -1
View File
@@ -8,6 +8,10 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'cli'))
from nanoshare_client import cli from nanoshare_client import cli
from nanoshare_client.auth import _CallbackServer from nanoshare_client.auth import _CallbackServer
from nanoshare_client.completion import script as completion_script
from nanoshare_client.ignore import load_ignore_patterns, is_ignored
from nanoshare_client.table import format_table
from nanoshare_client.watch import _is_relevant_path
class FakeClient: class FakeClient:
def __init__(self): def __init__(self):
@@ -25,9 +29,19 @@ class FakeClient:
self.remote_files.append({ self.remote_files.append({
'file_id': file_id, 'file_id': file_id,
'file_name': params['file_name'], 'file_name': params['file_name'],
'file_path': params.get('file_path', ''),
'file_size': '1 B', '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', '')
if 'note' in params:
item['note'] = params['note']
return {'updated': True}
return {'updated': False}
if method == 'files.delete': if method == 'files.delete':
self.deleted.append(params['file_id']) self.deleted.append(params['file_id'])
self.remote_files = [item for item in self.remote_files if item['file_id'] != params['file_id']] self.remote_files = [item for item in self.remote_files if item['file_id'] != params['file_id']]
@@ -37,6 +51,10 @@ class FakeClient:
def close(self): def close(self):
pass 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): def test_sync_uploads_new_local_file_and_writes_state(tmp_path, monkeypatch):
fake = FakeClient() fake = FakeClient()
monkeypatch.setattr(cli, 'make_client', lambda args: fake) monkeypatch.setattr(cli, 'make_client', lambda args: fake)
@@ -55,6 +73,291 @@ def test_sync_uploads_new_local_file_and_writes_state(tmp_path, monkeypatch):
state = json.loads((tmp_path / '.nanoshare-sync' / 'state.json').read_text()) state = json.loads((tmp_path / '.nanoshare-sync' / 'state.json').read_text())
assert state['files']['hello.txt']['file_id'] == 'file_1' 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_updates_remote_path_when_tracked_file_moves(tmp_path, monkeypatch):
fake = FakeClient()
fake.remote_files = [{'file_id': 'file_1', 'file_name': 'old.txt', 'file_path': '', 'file_size': '5 Bytes', 'expires_at': 123456}]
monkeypatch.setattr(cli, 'make_client', lambda args: fake)
state_dir = tmp_path / '.nanoshare-sync'
state_dir.mkdir()
(state_dir / 'state.json').write_text(json.dumps({
'version': 1,
'files': {
'old.txt': {
'file_id': 'file_1',
'sha256': '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824',
'local_mtime': 1,
'file_name': 'old.txt',
'file_path': '',
}
}
}))
nested = tmp_path / 'docs' / 'new.txt'
nested.parent.mkdir(parents=True)
nested.write_text('hello')
code = cli.main([
'sync',
'--url', 'picoshare=https://example.com',
'--token', 'token',
'--delete',
str(tmp_path),
])
assert code == 0
assert fake.uploads == []
assert fake.deleted == []
assert fake.remote_files[0]['file_name'] == 'new.txt'
assert fake.remote_files[0]['file_path'] == 'docs'
assert fake.remote_files[0]['expires_at'] == 123456
assert 'note' not in fake.remote_files[0]
state = json.loads((state_dir / 'state.json').read_text())
assert 'old.txt' not in state['files']
assert state['files']['docs/new.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')
(tmp_path / '.comments').mkdir()
(tmp_path / '.comments' / 'ignored.xml').write_text('<comment />')
(tmp_path / 'Cerberus' / '.comments').mkdir(parents=True)
(tmp_path / 'Cerberus' / '.comments' / '76. Two-headeds.PNG.xml').write_text('<comment />')
(tmp_path / '.env').write_text('SECRET=ignored')
(tmp_path / '.venv').mkdir()
(tmp_path / '.venv' / 'ignored.py').write_text('ignored')
(tmp_path / 'Thumbs.db').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_default_ignore_patterns_skip_common_generated_files(tmp_path):
patterns = load_ignore_patterns(tmp_path)
assert is_ignored('.comments/comment.xml', patterns)
assert is_ignored('Cerberus/.comments/76. Two-headeds.PNG.xml', patterns)
assert is_ignored('.env', patterns)
assert is_ignored('.env.local', patterns)
assert is_ignored('.venv/bin/python', patterns)
assert is_ignored('venv/bin/python', patterns)
assert is_ignored('__pycache__/mod.pyc', patterns)
assert is_ignored('nested/Thumbs.db', patterns)
assert not is_ignored('docs/note.txt', patterns)
def test_watch_loop_retries_after_sync_error(monkeypatch, tmp_path):
from nanoshare_client import watch as watch_module
calls = []
class FakeFlag:
def __init__(self):
pass
def clear(self):
pass
def wait(self, timeout):
return False
def fake_sync(args):
calls.append(args.folder)
if len(calls) == 1:
return 1
raise KeyboardInterrupt
monkeypatch.setattr(watch_module, '_ChangeFlag', FakeFlag)
monkeypatch.setattr(watch_module, '_watchdog_observer', lambda *args: None)
args = type('Args', (), {'folder': str(tmp_path), 'ignore': [], 'interval': 0.1})()
code = watch_module.watch_loop(args, fake_sync)
assert code == 0
assert calls == [str(tmp_path), str(tmp_path)]
def test_watch_relevance_ignores_sync_state_and_default_ignores(tmp_path):
patterns = load_ignore_patterns(tmp_path)
assert _is_relevant_path(tmp_path, str(tmp_path / 'note.txt'), patterns)
assert not _is_relevant_path(tmp_path, str(tmp_path / '.nanoshare-sync' / 'state.json'), patterns)
assert not _is_relevant_path(tmp_path, str(tmp_path / '.comments' / 'comment.xml'), patterns)
assert not _is_relevant_path(tmp_path, str(tmp_path / '.env'), patterns)
def test_completion_scripts_include_commands():
zsh = completion_script('zsh')
bash = completion_script('bash')
assert 'compdef _nanoshare nanoshare' in zsh
assert 'complete -F _nanoshare nanoshare' in bash
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_exact_name_or_path():
files = [
{'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 = [
{'file_id': 'file_1', 'file_name': 'report.pdf'},
{'file_id': 'file_2', 'file_name': 'report.pdf'},
]
try:
cli._resolve_remote_file(files, 'report.pdf')
except RuntimeError as exc:
assert 'multiple remote files' in str(exc)
else:
raise AssertionError('expected duplicate names to raise')
def test_format_table_aligns_columns():
output = format_table(
['ID', 'Path', 'Name', 'Size'],
[
['abc', '', 'short.txt', '1 KB'],
['longer-id', 'docs', 'a much longer file name.png', '22 MB'],
],
)
assert output.splitlines() == [
'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(): def test_login_callback_returns_without_waiting_for_timeout():
server = _CallbackServer('127.0.0.1', 0, 'expected-state') server = _CallbackServer('127.0.0.1', 0, 'expected-state')
result = {} result = {}
+2 -1
View File
@@ -43,9 +43,10 @@ class FakeConvex:
async def send_to_storage(self, data, content_type): async def send_to_storage(self, data, content_type):
return 'storage_1' return 'storage_1'
async def add_file(self, file_name, file_size, note, content_type, expires_at, storage_id, user_id): async def add_file(self, file_name, file_size, note, content_type, expires_at, storage_id, user_id, file_path=''):
self.files[file_name] = { self.files[file_name] = {
'file_name': file_name, 'file_name': file_name,
'file_path': file_path,
'note': note, 'note': note,
'content_type': content_type, 'content_type': content_type,
'storage_id': storage_id, 'storage_id': storage_id,
Generated
+1 -1
View File
@@ -720,7 +720,7 @@ wheels = [
[[package]] [[package]]
name = "nanoshare" name = "nanoshare"
version = "1.22.0" version = "1.26.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "aiohttp" }, { name = "aiohttp" },