"""HTTP-клиент хаба: для MCP, тестов и скриптов. Ошибки API → HubError, недоступный хаб → HubUnavailable.""" import json import urllib.error import urllib.parse import urllib.request from . import db, paths, settings class HubError(Exception): def __init__(self, status, error, detail=None): super().__init__(f"HTTP {status} {error}: {detail or ''}".strip()) self.status, self.error, self.detail = status, error, detail class HubUnavailable(Exception): """Хаб не отвечает — обычно не запущен (sdlc-hub serve).""" class HubClient: def __init__(self, base_url: str, token: str, timeout: float = 60): self.base, self.token, self.timeout = base_url.rstrip("/"), token, timeout @classmethod def from_home(cls, timeout: float = 60) -> "HubClient": conn = db.connect(paths.db_path()) try: settings.ensure_defaults(conn) return cls(f"http://127.0.0.1:{settings.get(conn, 'port')}", settings.tokens(conn)["client"], timeout) finally: conn.close() def call(self, method: str, path: str, body=None, params=None): raw = self._raw(method, path, body, params, "application/json") return json.loads(raw.decode("utf-8")) if raw else {} def get_bytes(self, path: str) -> bytes: """Тело ответа как есть (содержимое вложения); ошибки — как у call.""" return self._raw("GET", path, None, None, "*/*") def _raw(self, method, path, body, params, accept) -> bytes: url = self.base + path if params: url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v not in (None, "")}) data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None req = urllib.request.Request(url, data=data, method=method) req.add_header("Authorization", "Bearer " + self.token) req.add_header("Accept", accept) if data is not None: req.add_header("Content-Type", "application/json; charset=utf-8") try: with urllib.request.urlopen(req, timeout=self.timeout) as resp: return resp.read() except urllib.error.HTTPError as e: try: payload = json.loads(e.read().decode("utf-8")) except Exception: payload = {"error": "http", "detail": str(e)} raise HubError(e.code, payload.get("error", "http"), payload.get("detail")) from None except (urllib.error.URLError, ConnectionError, TimeoutError) as e: raise HubUnavailable(f"хаб не отвечает ({self.base}): {e}") from None def get(self, path, **params): return self.call("GET", path, params=params or None) def post(self, path, body=None): return self.call("POST", path, body if body is not None else {}) def put(self, path, body): return self.call("PUT", path, body) def patch(self, path, body): return self.call("PATCH", path, body)