Files
simple-nanoshare/cli/nanoshare_client/http_client.py
T
daniel156161 42064de633
Build and Push Docker Container / build-and-push (push) Successful in 1m3s
feat(cli): add private NanoShare sync client
- Add a separate installable NanoShare CLI package under cli/.
- Implement browser login with local callback and refresh-token config.
- Add upload, list, download, sync, and watch CLI commands.
- Add private owner-only file download endpoint for CLI downloads.
- Add CLI auth endpoints for browser login and token refresh.
- Return file_id from ServiceLink uploads for reliable sync state.
- Exclude the CLI package from NanoShare container builds.
- Include tests for private downloads and sync state updates.
2026-07-27 15:52:34 +02:00

95 lines
3.6 KiB
Python

from __future__ import annotations
import hashlib
import json
import os
import time
from pathlib import Path
import httpx
from .envelope import JSON_CT, request_envelope, unwrap_response
def _normalize_authorization(token: str) -> str:
return token if token.lower().startswith('bearer ') else f'Bearer {token}'
class RefreshingTokenProvider:
def __init__(self, refresh_token: str, token_url: str, *, timeout: float = 30.0):
self.refresh_token = refresh_token
self.token_url = token_url
self.timeout = timeout
self.access_token: str | None = None
self.expires_at = 0.0
self.cache_path = self._cache_path(refresh_token, token_url)
self._load_cache()
@staticmethod
def _cache_path(refresh_token: str, token_url: str) -> Path:
key = hashlib.sha256(f'{token_url}|{refresh_token}'.encode()).hexdigest()[:16]
return Path('/tmp/.nanoshare-cli') / f'token-{key}.json'
def __call__(self) -> str:
if self.access_token and time.time() < self.expires_at - 60:
return self.access_token
headers = {'Authorization': _normalize_authorization(self.refresh_token), 'Accept': 'application/json'}
response = httpx.post(self.token_url, headers=headers, timeout=self.timeout)
response.raise_for_status()
data = response.json()
self.access_token = data['access_token']
self.expires_at = float(data.get('expires_at') or (time.time() + int(data.get('expires_in', 3600))))
self._store_cache()
return self.access_token
def _load_cache(self) -> None:
if not self.cache_path.is_file():
return
try:
data = json.loads(self.cache_path.read_text())
except (OSError, ValueError):
return
if isinstance(data.get('access_token'), str):
self.access_token = data['access_token']
self.expires_at = float(data.get('expires_at') or 0)
def _store_cache(self) -> None:
try:
self.cache_path.parent.mkdir(parents=True, exist_ok=True)
os.chmod(self.cache_path.parent, 0o700)
tmp = self.cache_path.with_name(f'{self.cache_path.name}.{os.getpid()}.tmp')
tmp.write_text(json.dumps({'access_token': self.access_token, 'expires_at': self.expires_at}))
os.chmod(tmp, 0o600)
os.replace(tmp, self.cache_path)
except OSError:
pass
class NanoShareClient:
def __init__(self, *, source: str, registry: dict[str, str], token: str | None = None, refresh_token: str | None = None, token_url: str | None = None, timeout: float = 30.0):
self.source = source
self.registry = {name: url.rstrip('/') for name, url in registry.items()}
self.token = token
self.timeout = timeout
self.token_provider = RefreshingTokenProvider(refresh_token, token_url, timeout=timeout) if refresh_token and token_url else None
self._client = httpx.Client(timeout=timeout)
def close(self) -> None:
self._client.close()
def auth_headers(self) -> dict[str, str]:
token = self.token or (self.token_provider() if self.token_provider else None)
return {'Authorization': _normalize_authorization(token)} if token else {}
def url(self, node: str, path: str = '/rpc') -> str:
if node not in self.registry:
raise RuntimeError(f'unknown node: {node}')
return self.registry[node].rstrip('/') + path
def call(self, node: str, method: str, params: dict | None = None):
envelope = request_envelope(method, params, source=self.source, target=node)
response = self._client.post(
self.url(node, '/rpc'),
json=envelope,
headers={'Accept': JSON_CT, 'Content-Type': JSON_CT, **self.auth_headers()},
)
response.raise_for_status()
return unwrap_response(response.json())