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())