Compare commits

..

12 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
daniel156161 9db36c2d5b fix: allow CLI file tokens for RPC access
Build and Push Docker Container / build-and-push (push) Successful in 1m2s
- Accept files-scoped bearer tokens on servicelink RPC calls.
- Keep mesh shared-secret auth for trusted internal callers.
- Validate CLI auth scopes and reject unsupported values early.
- Stop CLI browser login waiting for the full timeout after callback.
- Add tests for scope normalization, RPC access, and login callback timing.
2026-07-27 17:08:23 +02:00
daniel156161 6e4e5b837b chore(deps): update locked h2 package
Build and Push Docker Container / build-and-push (push) Successful in 1m12s
- Update h2 from 4.3.0 to 4.4.0 in uv.lock.
- Refresh the locked source distribution metadata.
- Refresh the locked wheel metadata for the new release.
2026-07-27 16:54:04 +02:00
daniel156161 d71f0838d6 chore(release): sync lockfile version
- Update the locked NanoShare package version to 1.22.0.
- Keep uv.lock aligned with the package metadata release bump.
2026-07-27 16:51:22 +02:00
daniel156161 4f223e529d fix(cli): package standalone client wheel
- Declare the NanoShare client package for Hatch wheel builds.
- Ensure uv tool install can build and install the CLI from cli/.
- Bump the server package version to 1.22.0 for the CLI release.
2026-07-27 16:27:21 +02:00
21 changed files with 1056 additions and 64 deletions
+16 -5
View File
@@ -22,6 +22,7 @@ class _CallbackServer:
self.code: str | None = None
self.error: str | None = None
self.expected_state = expected_state
self.done = threading.Event()
outer = self
class Handler(BaseHTTPRequestHandler):
@@ -36,6 +37,7 @@ class _CallbackServer:
self.send_response(400)
self.end_headers()
self.wfile.write(b'NanoShare CLI login failed: state mismatch')
outer.done.set()
return
outer.code = params.get('code', [''])[0]
outer.error = params.get('error', [''])[0] or None
@@ -45,31 +47,40 @@ class _CallbackServer:
self.wfile.write(b'NanoShare CLI login complete. You can close this browser tab.')
else:
self.wfile.write(b'NanoShare CLI login failed.')
outer.done.set()
self.httpd = ThreadingHTTPServer((host, port), Handler)
self.url = f'http://{host}:{self.httpd.server_port}/callback'
self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
self.started = False
def start(self) -> None:
if not self.started:
self.thread.start()
self.started = True
def wait_for_code(self, timeout: float) -> str:
self.thread.start()
self.thread.join(timeout)
self.start()
received_callback = self.done.wait(timeout)
self.httpd.shutdown()
self.thread.join(5)
if self.error:
raise RuntimeError(self.error)
if not self.code:
if not received_callback or not self.code:
raise TimeoutError('login timed out')
return self.code
def login(args) -> int:
state = secrets.token_urlsafe(24)
server = _CallbackServer(args.callback_host, args.callback_port, state)
server.start()
authorize_url = _make_authorize_url(args.base_url, server.url, state, args.scope)
print(f'Opening browser for NanoShare login: {authorize_url}')
print(f'Opening browser for NanoShare login: {authorize_url}', flush=True)
if not args.no_browser:
webbrowser.open(authorize_url)
else:
print(authorize_url)
print(authorize_url, flush=True)
try:
code = server.wait_for_code(args.login_timeout)
+56 -14
View File
@@ -3,14 +3,17 @@ from __future__ import annotations
import argparse
import json
import sys
import time
from pathlib import Path
from .app import make_client
from .auth import login as auth_login
from .client import download, list_remote, upload
from .completion import script as completion_script
from .config import DEFAULT_CONFIG
from .remote_path import join_remote_path
from .sync import sync_once
from .table import format_table
from .watch import watch_loop
DEFAULT_NODE = 'picoshare'
@@ -42,21 +45,50 @@ def _cmd_list(args) -> int:
return 1
finally:
client.close()
if args.format == 'json':
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_name')}\t{item.get('file_size', '')}")
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
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:
dest = Path(args.output).expanduser() if args.output else Path(args.file_id)
client = make_client(args)
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:
print(f'error: {exc}', file=sys.stderr)
return 1
finally:
client.close()
print(f'downloaded {args.file_id} -> {dest}')
print(f'downloaded {remote_name} ({file_id}) -> {dest}')
return 0
def _cmd_sync(args) -> int:
@@ -67,12 +99,15 @@ def _cmd_sync(args) -> int:
client.close()
def _cmd_watch(args) -> int:
print(f'watching {args.folder} every {args.interval}s')
while True:
code = _cmd_sync(args)
if code:
return code
time.sleep(args.interval)
return watch_loop(args, _cmd_sync)
def _cmd_completion(args) -> int:
try:
print(completion_script(args.shell))
except ValueError as exc:
print(f'error: {exc}', file=sys.stderr)
return 1
return 0
def _add_common(parser: argparse.ArgumentParser) -> None:
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.')
_add_common(list_cmd)
list_cmd.add_argument('--format', choices=['table', 'json', 'tsv'], default='table')
list_cmd.set_defaults(func=_cmd_list)
download_cmd = sub.add_parser('download', help='Download one file via private API endpoint.')
_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.set_defaults(func=_cmd_download)
sync_cmd = sub.add_parser('sync', help='Two-way folder sync MVP.')
_add_common(sync_cmd)
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('--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)
watch_cmd = sub.add_parser('watch', help='Run sync repeatedly.')
_add_common(watch_cmd)
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('--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.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)
return args.func(args)
+23 -3
View File
@@ -10,7 +10,7 @@ import httpx
def content_type(path: Path) -> str:
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')
params = {
'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),
'note': note,
}
if remote_path:
params['file_path'] = remote_path
if expires:
params['expires'] = expires
result = client.call(node, 'files.upload', params)
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:
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 []
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)
if not base:
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:
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
+130 -12
View File
@@ -5,10 +5,13 @@ import json
import os
import shutil
import sys
import tempfile
import time
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'
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))
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
patterns = ignore_patterns or []
for path in sorted(root.rglob('*')):
if not path.is_file():
continue
if path == state_path(root) or sync_dir in path.parents:
continue
if is_ignored(relative(root, path), patterns):
continue
yield path
def relative(root: Path, path: Path) -> str:
return path.relative_to(root).as_posix()
def safe_remote_name(name: str, file_id: str) -> str:
candidate = Path(name or file_id).name
return candidate or file_id
return join_remote_path(name, file_id=file_id)
def unique_path(path: Path) -> Path:
if not path.exists():
@@ -80,22 +85,90 @@ def remote_by_name(remote_files: list[dict]) -> dict[str, dict]:
result = {}
for item in remote_files:
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
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:
root = Path(args.folder).expanduser().resolve()
root.mkdir(parents=True, exist_ok=True)
state = load_state(root)
known = state.setdefault('files', {})
try:
ignore_patterns = load_ignore_patterns(root, getattr(args, 'ignore', None))
remote_files = list_remote(client, args.node)
remote_ids = remote_by_id(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):
rel = relative(root, path)
try:
current_hash = sha256(path)
except FileNotFoundError:
print(f'skip vanished local file: {rel}')
continue
entry = known.get(rel)
remote_id = entry.get('file_id') 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:
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:
conflict_path = unique_path(path)
shutil.copy2(path, conflict_path)
print(f'conflict: kept changed local copy at {conflict_path}')
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')
if not new_file_id:
matching = remote_names.get(rel)
@@ -134,7 +247,8 @@ def sync_once(args, client) -> int:
'file_id': new_file_id,
'sha256': current_hash,
'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)}
@@ -142,20 +256,24 @@ def sync_once(args, client) -> int:
file_id = remote.get('file_id')
if not file_id or file_id in known_ids:
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)
print(f'download: {file_id} -> {relative(root, path)}')
download(client, args.node, file_id, path)
remote_file_name, remote_file_path = split_remote_path(name)
known[relative(root, path)] = {
'file_id': file_id,
'sha256': sha256(path),
'local_mtime': path.stat().st_mtime,
'file_name': relative(root, path),
'file_name': remote_file_name,
'file_path': remote_file_path,
}
if args.delete:
for rel, entry in list(known.items()):
if rel in local_paths:
if (root / rel).is_file():
continue
file_id = entry.get('file_id') if isinstance(entry, dict) else None
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)
+5 -1
View File
@@ -1,16 +1,20 @@
[project]
name = "nanoshare-cli"
version = "0.1.0"
version = "0.4.2"
description = "NanoShare desktop CLI and folder sync client"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"httpx==0.28.1",
"watchdog==6.0.0",
]
[project.scripts]
nanoshare = "nanoshare_client.cli:main"
[tool.hatch.build.targets.wheel]
packages = ["nanoshare_client"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
+9 -4
View File
@@ -30,15 +30,16 @@ class ConvexDB(ConvexDbBase):
return [ {
"file_id": x['file_id'],
"file_name": x['file_name'],
"file_path": x.get('file_path', ''),
"file_size": x['file_size'],
"note": x['note'],
"expires_at": int(x['expires_at']) if x.get('expires_at', None) else '',
"uploaded_at": int(x['uploaded_at']),
} 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 = {
'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,
'file_storage_id': storage_id, 'user_id': user_id
}
@@ -51,13 +52,17 @@ class ConvexDB(ConvexDbBase):
)
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 = {
'file_id': file_id,
'file_name': file_name,
'note': note,
'file_path': file_path,
'user_id': user_id
}
if note is not None:
args['note'] = note
elif not preserve_missing:
args['note'] = ''
if expires_at:
args['expires_at'] = expires_at.isoformat()
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "nanoshare"
version = "1.21.0"
version = "1.26.0"
description = "Add your description here"
readme = "README.md"
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.download import api_download_bp
from .api.events import api_events_bp
# Health
from .api.health import health_bp
+17 -3
View File
@@ -12,13 +12,24 @@ from quart import current_app
cli_auth_bp = Blueprint('cli_auth', __name__)
CLI_CODE_TTL_SECONDS = 300
CLI_AUTH_DEFAULT_SCOPE = 'files'
CLI_AUTH_ALLOWED_SCOPES = {'all', 'files'}
def _normalize_cli_scope(scope: str | None) -> str:
requested_scope = (scope or CLI_AUTH_DEFAULT_SCOPE).strip() or CLI_AUTH_DEFAULT_SCOPE
if requested_scope not in CLI_AUTH_ALLOWED_SCOPES:
raise ValueError(f'unsupported CLI scope: {requested_scope}')
return requested_scope
@cli_auth_bp.get('/auth/cli/login')
@LIMITER.limit('10 per minute;60 per hour;')
async def cli_login():
redirect_uri = request.args.get('redirect_uri', '').strip()
state = request.args.get('state', '').strip()
scope = request.args.get('scope', 'files').strip() or 'files'
try:
scope = _normalize_cli_scope(request.args.get('scope'))
except ValueError as exc:
return jsonify({'ok': False, 'error': str(exc)}), 400
add_wide_event_context(auth={'method': 'cli_browser', 'operation': 'cli_login_start'})
if not redirect_uri or not state:
@@ -44,7 +55,7 @@ async def cli_login_complete():
user['sub'],
login_request['redirect_uri'],
login_request['state'],
login_request.get('scope') or 'files',
_normalize_cli_scope(login_request.get('scope')),
)
async def _redirect_with_cli_code(user_id: str, redirect_uri: str, state: str, scope: str):
@@ -72,7 +83,10 @@ async def cli_token_exchange():
await cache.delete(f'cli:login:{code}')
user_id = login_data['user_id']
scope = login_data.get('scope') or 'files'
try:
scope = _normalize_cli_scope(login_data.get('scope'))
except ValueError as exc:
return jsonify({'ok': False, 'error': str(exc)}), 400
token_data = await current_app.convex.add_refresh_token(
token_name='nanoshare-cli',
user_id=user_id,
+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
+26 -10
View File
@@ -4,7 +4,7 @@ Lets other nodes (browser-cli, website) push files in and read file metadata
over the shared servicelink envelope at POST /rpc, alongside the existing web
UI and /api routes.
Every call needs a bearer token carrying the `mesh` scope; the endpoint is rate
Every call needs a bearer token carrying the `files` or `mesh` scope; the endpoint is rate
limited and body-size capped. Keep /rpc on the internal node network.
'''
from __future__ import annotations
@@ -12,21 +12,33 @@ from __future__ import annotations
import base64
import os
from pathlib import PurePosixPath
from quart import current_app
from my_modules.app.setup import LIMITER
from my_modules.expiry import ensure_utc, parse_expires
from my_modules.file_meta import format_size, iso_stamp_filename
from servicelink import InvalidParams, NotFound, Router, Unauthorized, any_verifier, bearer_verifier, create_link_blueprint, shared_secret_verifier
from servicelink import Forbidden, InvalidParams, NotFound, Router, Unauthorized, any_verifier, bearer_verifier, create_link_blueprint, shared_secret_verifier
MAX_RPC_BODY = 16 * 1024 * 1024
MESH_SCOPE = 'mesh'
RPC_SCOPES = ('files', 'mesh')
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):
if ctx.principal is None:
raise Unauthorized('authentication required')
if not any(ctx.principal.has_scope(scope) for scope in RPC_SCOPES):
raise Forbidden('missing required scope: files')
return ctx.principal.subject
@router.method('files.upload')
@@ -45,10 +57,12 @@ async def files_upload(params, ctx):
else:
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)
file_record = await current_app.convex.add_file(
file_name=file_name,
file_path=file_path,
file_size=format_size(len(data)),
note=params.get('note', ''),
content_type=content_type,
@@ -57,7 +71,7 @@ async def files_upload(params, ctx):
user_id=user_id,
)
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')
async def files_list(params, ctx):
@@ -83,15 +97,17 @@ async def files_info(params, ctx):
@router.method('files.update')
async def files_update(params, ctx):
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:
raise InvalidParams('file_id and file_name are required')
await current_app.convex.update_file(
file_id=file_id,
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', ''))),
user_id=_user_id(ctx),
preserve_missing=True,
)
return {'updated': True}
@@ -110,11 +126,11 @@ async def _decode_access_token(token):
return payload
def _build_verify():
# Accept a JWT access token with the mesh scope (public path) OR, on the
# Accept a JWT access token with a file/RPC scope (public path) OR, on the
# trusted Docker network, a static shared secret from SERVICELINK_MESH_SECRET.
jwt = bearer_verifier(_decode_access_token, require_scope=MESH_SCOPE)
jwt = bearer_verifier(_decode_access_token)
secret = os.getenv('SERVICELINK_MESH_SECRET')
return any_verifier(shared_secret_verifier(secret, scopes=(MESH_SCOPE,)), jwt) if secret else jwt
return any_verifier(shared_secret_verifier(secret, scopes=('mesh',)), jwt) if secret else jwt
verify = _build_verify()
link_bp = create_link_blueprint(router, verify=verify, limiter=LIMITER.limit('30 per minute'), max_body=MAX_RPC_BODY)
+2
View File
@@ -13,6 +13,7 @@ from routes import (
upload_bp,
cli_auth_bp,
api_download_bp,
api_events_bp,
health_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(cli_auth_bp)
app.register_blueprint(api_download_bp)
app.register_blueprint(api_events_bp)
# ServiceLink node-to-node mesh endpoint (POST /rpc)
app.register_blueprint(servicelink_bp)
+43
View File
@@ -0,0 +1,43 @@
import importlib.util
import sys
import types
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
class _NoOpLimiter:
def limit(self, *args, **kwargs):
return lambda fn: fn
def _load_cli_auth_module():
setup_stub = types.ModuleType('my_modules.app.setup')
setup_stub.LIMITER = _NoOpLimiter()
setup_stub.cache = object()
sys.modules['my_modules.app.setup'] = setup_stub
module_path = Path(__file__).resolve().parents[1] / 'routes' / 'api' / 'cli_auth.py'
spec = importlib.util.spec_from_file_location('api_cli_auth_under_test', module_path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_normalize_cli_scope_keeps_files_scope():
module = _load_cli_auth_module()
assert module._normalize_cli_scope('files') == 'files'
def test_normalize_cli_scope_defaults_to_files():
module = _load_cli_auth_module()
assert module._normalize_cli_scope(None) == 'files'
assert module._normalize_cli_scope('') == 'files'
def test_normalize_cli_scope_rejects_unsupported_scope():
module = _load_cli_auth_module()
try:
module._normalize_cli_scope('printer')
except ValueError as exc:
assert 'unsupported CLI scope' in str(exc)
else:
raise AssertionError('expected unsupported scope to raise')
+324 -1
View File
@@ -1,10 +1,17 @@
import json
import sys
import threading
import urllib.request
from pathlib import Path
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 load_ignore_patterns, is_ignored
from nanoshare_client.table import format_table
from nanoshare_client.watch import _is_relevant_path
class FakeClient:
def __init__(self):
@@ -22,9 +29,19 @@ 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', '')
if 'note' in params:
item['note'] = params['note']
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']]
@@ -34,6 +51,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)
@@ -52,6 +73,308 @@ 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_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():
server = _CallbackServer('127.0.0.1', 0, 'expected-state')
result = {}
def wait_for_code():
result['code'] = server.wait_for_code(30)
thread = threading.Thread(target=wait_for_code)
thread.start()
with urllib.request.urlopen(f'{server.url}?code=login-code&state=expected-state', timeout=5) as response:
assert response.status == 200
assert b'login complete' in response.read()
thread.join(5)
assert not thread.is_alive()
assert result['code'] == 'login-code'
def test_sync_deletes_old_remote_when_local_file_changes(tmp_path, monkeypatch):
fake = FakeClient()
fake.remote_files = [{'file_id': 'old_file', 'file_name': 'hello.txt', 'file_size': '5 B'}]
+8 -4
View File
@@ -43,9 +43,10 @@ class FakeConvex:
async def send_to_storage(self, data, content_type):
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] = {
'file_name': file_name,
'file_path': file_path,
'note': note,
'content_type': content_type,
'storage_id': storage_id,
@@ -56,6 +57,9 @@ class FakeConvex:
async def get_file(self, file_id):
return self.files.get(file_id)
async def get_files(self, user_id):
return list(self.files.values())
def _call(envelope, token=None):
# Call the handler the /rpc route delegates to, bypassing the LIMITER/size-cap
# wrapper; exercises the real verify (mesh scope) + dispatch + handlers.
@@ -100,7 +104,7 @@ def test_missing_token_is_unauthorized():
assert status == 401
assert body['error']['code'] == 'unauthorized'
def test_token_without_mesh_scope_is_forbidden():
def test_token_with_files_scope_is_allowed():
status, body = _call(_request('files.list'), token='nomesh')
assert status == 403
assert body['error']['code'] == 'forbidden'
assert status == 200
assert body['ok'] is True
Generated
+4 -4
View File
@@ -457,15 +457,15 @@ wheels = [
[[package]]
name = "h2"
version = "4.3.0"
version = "4.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "hpack" },
{ name = "hyperframe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" }
sdist = { url = "https://files.pythonhosted.org/packages/30/d4/a7d6fb3f58be99d65cbf2d3f766896217a2921d0f3ab10711c45dc1519ee/h2-4.4.0.tar.gz", hash = "sha256:46b551bdcdc7e83cf5c04d0bf93badb8a939bd2287d9fee1abb23a445b9e0580", size = 2156691, upload-time = "2026-07-23T19:14:19.442Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" },
{ url = "https://files.pythonhosted.org/packages/f6/df/5b14a118322d6097cb9bb30ec6bacad268e546a8ecfcb1f6d0de618dac2f/h2-4.4.0-py3-none-any.whl", hash = "sha256:6acffe1aeab79098d7eb0f8385c1add11f2c7a94815f6fa2b7060eeddee3d87c", size = 62368, upload-time = "2026-07-23T19:14:16.143Z" },
]
[[package]]
@@ -720,7 +720,7 @@ wheels = [
[[package]]
name = "nanoshare"
version = "1.21.0"
version = "1.26.0"
source = { virtual = "." }
dependencies = [
{ name = "aiohttp" },