Skip to content

Commit 28720f1

Browse files
committed
Add HTTPActionClient Python SDK for the HTTP action server
1 parent 1907f60 commit 28720f1

4 files changed

Lines changed: 262 additions & 0 deletions

File tree

automation_file/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from typing import TYPE_CHECKING, Any
1111

12+
from automation_file.client import HTTPActionClient, HTTPActionClientException
1213
from automation_file.core.action_executor import (
1314
ActionExecutor,
1415
add_command_to_executor,
@@ -299,6 +300,8 @@ def __getattr__(name: str) -> Any:
299300
"start_autocontrol_socket_server",
300301
"HTTPActionServer",
301302
"start_http_action_server",
303+
"HTTPActionClient",
304+
"HTTPActionClientException",
302305
"ActionACL",
303306
"ActionNotPermittedException",
304307
"ProjectBuilder",

automation_file/client/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""Client SDK for talking to a running ``HTTPActionServer``."""
2+
3+
from __future__ import annotations
4+
5+
from automation_file.client.http_client import HTTPActionClient, HTTPActionClientException
6+
7+
__all__ = ["HTTPActionClient", "HTTPActionClientException"]
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
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]

tests/test_http_client.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"""Tests for :class:`HTTPActionClient`."""
2+
# pylint: disable=cyclic-import
3+
4+
from __future__ import annotations
5+
6+
import pytest
7+
8+
from automation_file.client import HTTPActionClient, HTTPActionClientException
9+
from automation_file.core.action_executor import executor
10+
from automation_file.server.action_acl import ActionACL
11+
from automation_file.server.http_server import start_http_action_server
12+
13+
14+
def _ensure_echo_registered() -> None:
15+
if "test_client_echo" not in executor.registry:
16+
executor.registry.register("test_client_echo", lambda value: value)
17+
18+
19+
def _base_url(server) -> str:
20+
host, port = server.server_address
21+
return f"http://{host}:{port}"
22+
23+
24+
def test_client_executes_action_round_trip() -> None:
25+
_ensure_echo_registered()
26+
server = start_http_action_server(host="127.0.0.1", port=0)
27+
try:
28+
with HTTPActionClient(_base_url(server)) as client:
29+
result = client.execute([["test_client_echo", {"value": "hello"}]])
30+
assert isinstance(result, dict)
31+
assert any(value == "hello" for value in result.values())
32+
finally:
33+
server.shutdown()
34+
35+
36+
def test_client_sends_bearer_token_when_configured() -> None:
37+
_ensure_echo_registered()
38+
server = start_http_action_server(host="127.0.0.1", port=0, shared_secret="topsecret")
39+
try:
40+
with HTTPActionClient(_base_url(server), shared_secret="topsecret") as client:
41+
result = client.execute([["test_client_echo", {"value": 42}]])
42+
assert any(value == 42 for value in result.values())
43+
finally:
44+
server.shutdown()
45+
46+
47+
def test_client_unauthorized_when_missing_secret() -> None:
48+
_ensure_echo_registered()
49+
server = start_http_action_server(host="127.0.0.1", port=0, shared_secret="topsecret")
50+
try:
51+
with (
52+
HTTPActionClient(_base_url(server)) as client,
53+
pytest.raises(HTTPActionClientException, match="unauthorized"),
54+
):
55+
client.execute([["test_client_echo", {"value": 1}]])
56+
finally:
57+
server.shutdown()
58+
59+
60+
def test_client_forbidden_when_denied_by_acl() -> None:
61+
_ensure_echo_registered()
62+
acl = ActionACL.build(denied=["test_client_echo"])
63+
server = start_http_action_server(host="127.0.0.1", port=0, action_acl=acl)
64+
try:
65+
with (
66+
HTTPActionClient(_base_url(server)) as client,
67+
pytest.raises(HTTPActionClientException, match="forbidden"),
68+
):
69+
client.execute([["test_client_echo", {"value": 1}]])
70+
finally:
71+
server.shutdown()
72+
73+
74+
def test_client_rejects_bad_action_type() -> None:
75+
client = HTTPActionClient("http://127.0.0.1:1")
76+
try:
77+
with pytest.raises(HTTPActionClientException, match="list or dict"):
78+
client.execute("not-a-list") # type: ignore[arg-type]
79+
finally:
80+
client.close()
81+
82+
83+
def test_client_requires_base_url() -> None:
84+
with pytest.raises(HTTPActionClientException, match="non-empty"):
85+
HTTPActionClient("")
86+
87+
88+
def test_client_connection_error_wraps_request_exception() -> None:
89+
# Port 1 is unlikely to have a server — connect should fail quickly.
90+
client = HTTPActionClient("http://127.0.0.1:1", timeout=1.0)
91+
try:
92+
with pytest.raises(HTTPActionClientException, match="failed"):
93+
client.execute([["noop"]])
94+
finally:
95+
client.close()
96+
97+
98+
def test_client_ping_reaches_running_server() -> None:
99+
_ensure_echo_registered()
100+
server = start_http_action_server(host="127.0.0.1", port=0)
101+
try:
102+
with HTTPActionClient(_base_url(server)) as client:
103+
assert client.ping() is True
104+
finally:
105+
server.shutdown()
106+
107+
108+
def test_client_ping_returns_false_for_dead_endpoint() -> None:
109+
client = HTTPActionClient("http://127.0.0.1:1", timeout=1.0)
110+
try:
111+
assert client.ping() is False
112+
finally:
113+
client.close()
114+
115+
116+
def test_client_surfaces_via_facade() -> None:
117+
import automation_file
118+
119+
assert automation_file.HTTPActionClient is HTTPActionClient
120+
assert automation_file.HTTPActionClientException is HTTPActionClientException

0 commit comments

Comments
 (0)