Files
simple-nanoshare/cli/nanoshare_client/http_client.py
T
daniel156161 73fdeacbfe
Build and Push Docker Container / build-and-push (push) Successful in 2m25s
feat!: adopt Python 3.14 and 3.15 language features
- Require Python 3.15 for the server and Python 3.14 for the CLI.
- Match the server requirement to quart_common's Python 3.15 features.
- Replace future annotations with native deferred evaluation.
- Defer CLI command dependencies and QR imports on Python 3.15.
- Enable argparse suggestions for mistyped CLI commands.
- Remove the obsolete tomllib import fallback.
- Update my_helpers and quart_common submodule references.
- Lock test dependencies and exclude them from Docker installations.
- Add regression coverage for lazy imports and deferred annotations.
- Document runtime requirements and standalone CLI validation.
- Validate 276 tests on Python 3.15 and 18 CLI tests on Python 3.14.

BREAKING CHANGE: The server now requires Python 3.15+ and the standalone CLI requires Python 3.14+.
2026-10-26 14:47:29 +01:00

93 lines
3.5 KiB
Python

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