Compare commits
5 Commits
993337be9f
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
08d0d154c9
|
|||
|
d3c914285c
|
|||
|
ffdcc979e6
|
|||
|
472ed11e9f
|
|||
|
2b0215cd94
|
@@ -3,7 +3,6 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from .app import make_client
|
||||
@@ -14,6 +13,7 @@ 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'
|
||||
|
||||
@@ -99,12 +99,7 @@ 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:
|
||||
@@ -163,7 +158,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
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.')
|
||||
@@ -172,7 +167,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
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.')
|
||||
|
||||
@@ -25,13 +25,14 @@ def upload(client, node: str, path: Path, remote_name: str, note: str, 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, expires: str) -> None:
|
||||
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,
|
||||
'note': note,
|
||||
}
|
||||
if note is not None:
|
||||
params['note'] = note
|
||||
if expires:
|
||||
params['expires'] = expires
|
||||
client.call(node, 'files.update', params)
|
||||
@@ -44,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)
|
||||
|
||||
@@ -4,9 +4,29 @@ 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 = [IGNORE_FILE_NAME]
|
||||
patterns = list(DEFAULT_IGNORE_PATTERNS)
|
||||
ignore_file = root / IGNORE_FILE_NAME
|
||||
if ignore_file.is_file():
|
||||
for line in ignore_file.read_text().splitlines():
|
||||
|
||||
@@ -112,6 +112,15 @@ def parse_size(value: object) -> int | None:
|
||||
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
|
||||
@@ -152,10 +161,14 @@ def sync_once(args, client) -> int:
|
||||
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, ignore_patterns)}
|
||||
|
||||
for rel, path in local_paths.items():
|
||||
current_hash = sha256(path)
|
||||
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
|
||||
@@ -165,12 +178,31 @@ def sync_once(args, client) -> int:
|
||||
|
||||
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 or '', args.expires or '')
|
||||
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,
|
||||
@@ -241,7 +273,7 @@ def sync_once(args, client) -> int:
|
||||
|
||||
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:
|
||||
|
||||
@@ -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
@@ -1,11 +1,12 @@
|
||||
[project]
|
||||
name = "nanoshare-cli"
|
||||
version = "0.2.0"
|
||||
version = "0.4.1"
|
||||
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]
|
||||
|
||||
@@ -52,14 +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, file_path: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,
|
||||
'file_path': file_path,
|
||||
'note': note,
|
||||
'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
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "nanoshare"
|
||||
version = "1.23.0"
|
||||
version = "1.26.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
+2
-1
@@ -104,9 +104,10 @@ async def files_update(params, ctx):
|
||||
file_id=file_id,
|
||||
file_name=file_name,
|
||||
file_path=_safe_file_path(str(params.get('file_path') or '')),
|
||||
note=params.get('note', ''),
|
||||
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}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+102
-1
@@ -9,8 +9,9 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'cli'))
|
||||
from nanoshare_client import cli
|
||||
from nanoshare_client.auth import _CallbackServer
|
||||
from nanoshare_client.completion import script as completion_script
|
||||
from nanoshare_client.ignore import is_ignored
|
||||
from nanoshare_client.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):
|
||||
@@ -37,6 +38,8 @@ class FakeClient:
|
||||
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':
|
||||
@@ -91,6 +94,48 @@ def test_sync_uploads_nested_local_files(tmp_path, monkeypatch):
|
||||
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'}]
|
||||
@@ -166,6 +211,12 @@ def test_sync_ignores_local_files_from_nanoshareignore(tmp_path, monkeypatch):
|
||||
(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 / '.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',
|
||||
@@ -206,6 +257,56 @@ def test_ignore_patterns_match_files_and_directories():
|
||||
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('.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')
|
||||
|
||||
Reference in New Issue
Block a user