|
| 1 | +"""Python SDK for :class:`~automation_file.server.http_server.HTTPActionServer`. |
| 2 | +
|
| 3 | +``HTTPActionClient(base_url, *, shared_secret=None)`` wraps a single |
| 4 | +``requests.Session`` and exposes ``execute(actions)`` which POSTs the JSON |
| 5 | +action list to ``<base_url>/actions``. The client is intentionally thin: |
| 6 | +it handles auth header assembly, response-code checking, and error |
| 7 | +translation, but makes no attempt to mirror ``ActionExecutor``'s API |
| 8 | +surface — callers pass the same action-list shape they would pass to |
| 9 | +``execute_action``. |
| 10 | +""" |
| 11 | + |
| 12 | +from __future__ import annotations |
| 13 | + |
| 14 | +from types import TracebackType |
| 15 | +from typing import Any |
| 16 | + |
| 17 | +import requests |
| 18 | + |
| 19 | +from automation_file.exceptions import FileAutomationException |
| 20 | +from automation_file.logging_config import file_automation_logger |
| 21 | +from automation_file.remote.url_validator import validate_http_url |
| 22 | + |
| 23 | +_DEFAULT_TIMEOUT = 30.0 |
| 24 | +_ACTIONS_PATH = "/actions" |
| 25 | + |
| 26 | + |
| 27 | +class HTTPActionClientException(FileAutomationException): |
| 28 | + """Raised when the server rejects a request or the response is malformed.""" |
| 29 | + |
| 30 | + |
| 31 | +class HTTPActionClient: |
| 32 | + """Synchronous SDK for a running :class:`HTTPActionServer`.""" |
| 33 | + |
| 34 | + def __init__( |
| 35 | + self, |
| 36 | + base_url: str, |
| 37 | + *, |
| 38 | + shared_secret: str | None = None, |
| 39 | + timeout: float = _DEFAULT_TIMEOUT, |
| 40 | + verify_loopback: bool = False, |
| 41 | + ) -> None: |
| 42 | + stripped = base_url.rstrip("/") |
| 43 | + if not stripped: |
| 44 | + raise HTTPActionClientException("base_url must be non-empty") |
| 45 | + if verify_loopback: |
| 46 | + validate_http_url(stripped) |
| 47 | + self._base_url = stripped |
| 48 | + self._shared_secret = shared_secret |
| 49 | + self._timeout = float(timeout) |
| 50 | + self._session = requests.Session() |
| 51 | + |
| 52 | + @property |
| 53 | + def base_url(self) -> str: |
| 54 | + return self._base_url |
| 55 | + |
| 56 | + def execute(self, actions: list | dict) -> Any: |
| 57 | + """POST ``actions`` to ``/actions`` and return the decoded JSON body.""" |
| 58 | + if not isinstance(actions, (list, dict)): |
| 59 | + raise HTTPActionClientException( |
| 60 | + f"actions must be list or dict, got {type(actions).__name__}" |
| 61 | + ) |
| 62 | + url = f"{self._base_url}{_ACTIONS_PATH}" |
| 63 | + headers = {"Content-Type": "application/json"} |
| 64 | + if self._shared_secret: |
| 65 | + headers["Authorization"] = f"Bearer {self._shared_secret}" |
| 66 | + try: |
| 67 | + response = self._session.post( |
| 68 | + url, |
| 69 | + json=actions, |
| 70 | + headers=headers, |
| 71 | + timeout=self._timeout, |
| 72 | + allow_redirects=False, |
| 73 | + ) |
| 74 | + except requests.RequestException as err: |
| 75 | + raise HTTPActionClientException(f"request to {url} failed: {err}") from err |
| 76 | + return _decode_response(response) |
| 77 | + |
| 78 | + def ping(self) -> bool: |
| 79 | + """Best-effort reachability probe — returns True if the server responds.""" |
| 80 | + url = f"{self._base_url}{_ACTIONS_PATH}" |
| 81 | + try: |
| 82 | + response = self._session.request( |
| 83 | + "OPTIONS", url, timeout=min(self._timeout, 5.0), allow_redirects=False |
| 84 | + ) |
| 85 | + except requests.RequestException: |
| 86 | + return False |
| 87 | + # The server only handles POST /actions; OPTIONS yields 501 which |
| 88 | + # still proves it's reachable. 401/403 also prove reachability. |
| 89 | + return response.status_code < 500 or response.status_code == 501 |
| 90 | + |
| 91 | + def close(self) -> None: |
| 92 | + self._session.close() |
| 93 | + |
| 94 | + def __enter__(self) -> HTTPActionClient: |
| 95 | + return self |
| 96 | + |
| 97 | + def __exit__( |
| 98 | + self, |
| 99 | + exc_type: type[BaseException] | None, |
| 100 | + exc: BaseException | None, |
| 101 | + tb: TracebackType | None, |
| 102 | + ) -> None: |
| 103 | + self.close() |
| 104 | + |
| 105 | + |
| 106 | +def _decode_response(response: requests.Response) -> Any: |
| 107 | + status = response.status_code |
| 108 | + if status == 401: |
| 109 | + raise HTTPActionClientException("unauthorized: missing or invalid shared secret") |
| 110 | + if status == 403: |
| 111 | + body = _safe_body(response) |
| 112 | + raise HTTPActionClientException(f"forbidden: {body}") |
| 113 | + if status == 404: |
| 114 | + raise HTTPActionClientException("server does not expose /actions") |
| 115 | + if status >= 400: |
| 116 | + body = _safe_body(response) |
| 117 | + raise HTTPActionClientException(f"server returned HTTP {status}: {body}") |
| 118 | + try: |
| 119 | + return response.json() |
| 120 | + except ValueError as err: |
| 121 | + file_automation_logger.error("http_client: bad JSON response: %r", err) |
| 122 | + raise HTTPActionClientException(f"server returned invalid JSON: {err}") from err |
| 123 | + |
| 124 | + |
| 125 | +def _safe_body(response: requests.Response) -> str: |
| 126 | + try: |
| 127 | + data = response.json() |
| 128 | + except ValueError: |
| 129 | + return response.text[:200] |
| 130 | + if isinstance(data, dict) and "error" in data: |
| 131 | + return str(data["error"]) |
| 132 | + return str(data)[:200] |
0 commit comments