mquark-actionful-client 1.0.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,34 @@
1
+ # .NET
2
+ bin/
3
+ obj/
4
+ *.user
5
+ *.suo
6
+ .vs/
7
+ TestResults/
8
+ *.nupkg
9
+
10
+ # Rider / JetBrains
11
+ .idea/
12
+
13
+ # VS Code
14
+ .vscode/
15
+
16
+ # Environment
17
+ .env
18
+ *.local
19
+
20
+ # JS/TS
21
+ node_modules/
22
+ dist/
23
+
24
+ # Python
25
+ __pycache__/
26
+ *.pyc
27
+ *.pyo
28
+ *.egg-info/
29
+ .venv/
30
+ dist/
31
+
32
+ # OS
33
+ .DS_Store
34
+ Thumbs.db
@@ -0,0 +1,63 @@
1
+ Metadata-Version: 2.4
2
+ Name: mquark-actionful-client
3
+ Version: 1.0.2
4
+ Summary: Official Python client for invoking mQuark Actionful endpoints — single, batch, and pipeline.
5
+ Project-URL: Homepage, https://github.com/m-quark/ActionfulClient
6
+ Project-URL: Repository, https://github.com/m-quark/ActionfulClient
7
+ License: MIT
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: httpx>=0.27
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
12
+ Requires-Dist: pytest>=8.3; extra == 'dev'
13
+ Requires-Dist: respx>=0.21; extra == 'dev'
14
+ Description-Content-Type: text/markdown
15
+
16
+ # mquark-actionful-client
17
+
18
+ Official Python client for invoking [mQuark Actionful](https://mquark.com) endpoints.
19
+
20
+ See the full documentation and examples at [github.com/m-quark/ActionfulClient](https://github.com/m-quark/ActionfulClient).
21
+
22
+ ## Installation
23
+
24
+ ```sh
25
+ pip install mquark-actionful-client
26
+ ```
27
+
28
+ Requires Python 3.10+ and `httpx`.
29
+
30
+ ## Quick start
31
+
32
+ ```python
33
+ from mquark_actionful import ActionfulClient, ActionfulClientOptions
34
+
35
+ client = ActionfulClient(ActionfulClientOptions(
36
+ endpoint_url="https://...",
37
+ access_token="...",
38
+ access_secret="...",
39
+ ))
40
+
41
+ # Single invocation
42
+ result = await client.invoke({"orderId": 42})
43
+
44
+ # Batch
45
+ results = await client.invoke_batch([item1, item2, item3])
46
+
47
+ # Stream
48
+ async for r in client.stream_batch(items):
49
+ if r.is_success:
50
+ print(r.output)
51
+
52
+ # Async pipeline
53
+ async for result in client.process(async_item_stream()):
54
+ await sink.write(result)
55
+
56
+ # Close when done
57
+ await client.aclose()
58
+ # or use as context manager: async with ActionfulClient(...) as client: ...
59
+ ```
60
+
61
+ ## License
62
+
63
+ MIT
@@ -0,0 +1,48 @@
1
+ # mquark-actionful-client
2
+
3
+ Official Python client for invoking [mQuark Actionful](https://mquark.com) endpoints.
4
+
5
+ See the full documentation and examples at [github.com/m-quark/ActionfulClient](https://github.com/m-quark/ActionfulClient).
6
+
7
+ ## Installation
8
+
9
+ ```sh
10
+ pip install mquark-actionful-client
11
+ ```
12
+
13
+ Requires Python 3.10+ and `httpx`.
14
+
15
+ ## Quick start
16
+
17
+ ```python
18
+ from mquark_actionful import ActionfulClient, ActionfulClientOptions
19
+
20
+ client = ActionfulClient(ActionfulClientOptions(
21
+ endpoint_url="https://...",
22
+ access_token="...",
23
+ access_secret="...",
24
+ ))
25
+
26
+ # Single invocation
27
+ result = await client.invoke({"orderId": 42})
28
+
29
+ # Batch
30
+ results = await client.invoke_batch([item1, item2, item3])
31
+
32
+ # Stream
33
+ async for r in client.stream_batch(items):
34
+ if r.is_success:
35
+ print(r.output)
36
+
37
+ # Async pipeline
38
+ async for result in client.process(async_item_stream()):
39
+ await sink.write(result)
40
+
41
+ # Close when done
42
+ await client.aclose()
43
+ # or use as context manager: async with ActionfulClient(...) as client: ...
44
+ ```
45
+
46
+ ## License
47
+
48
+ MIT
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "mquark-actionful-client"
7
+ version = "1.0.2"
8
+ description = "Official Python client for invoking mQuark Actionful endpoints — single, batch, and pipeline."
9
+ license = { text = "MIT" }
10
+ readme = "README.md"
11
+ requires-python = ">=3.10"
12
+ dependencies = ["httpx>=0.27"]
13
+
14
+ [project.urls]
15
+ Homepage = "https://github.com/m-quark/ActionfulClient"
16
+ Repository = "https://github.com/m-quark/ActionfulClient"
17
+
18
+ [project.optional-dependencies]
19
+ dev = [
20
+ "pytest>=8.3",
21
+ "pytest-asyncio>=0.24",
22
+ "respx>=0.21",
23
+ ]
24
+
25
+ [tool.pytest.ini_options]
26
+ asyncio_mode = "auto"
27
+ testpaths = ["tests"]
28
+
29
+ [tool.hatch.build.targets.wheel]
30
+ packages = ["src/mquark_actionful"]
@@ -0,0 +1,24 @@
1
+ from ._client import ActionfulClient, Pipeline
2
+ from ._types import (
3
+ ActionfulClientOptions,
4
+ ActionfulError,
5
+ BatchOptions,
6
+ InvocationJob,
7
+ InvocationResult,
8
+ InvocationStatus,
9
+ InvocationTicket,
10
+ PipelineOptions,
11
+ )
12
+
13
+ __all__ = [
14
+ "ActionfulClient",
15
+ "ActionfulClientOptions",
16
+ "ActionfulError",
17
+ "BatchOptions",
18
+ "InvocationJob",
19
+ "InvocationResult",
20
+ "InvocationStatus",
21
+ "InvocationTicket",
22
+ "Pipeline",
23
+ "PipelineOptions",
24
+ ]
@@ -0,0 +1,341 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import dataclasses
5
+ import json
6
+ from datetime import datetime, timezone
7
+ from typing import Any, AsyncGenerator, AsyncIterator, Iterable
8
+ from urllib.parse import urljoin, urlparse
9
+
10
+ import httpx
11
+
12
+ from ._types import (
13
+ ActionfulClientOptions,
14
+ ActionfulError,
15
+ BatchOptions,
16
+ InvocationJob,
17
+ InvocationResult,
18
+ InvocationStatus,
19
+ InvocationTicket,
20
+ PipelineOptions,
21
+ )
22
+
23
+ # Sent on submit() to force an immediate 202 (internal optimisation, not part of the public contract).
24
+ _TIMEOUT_HEADER = "Mq-Timeout-Seconds"
25
+
26
+ _SENTINEL = object()
27
+
28
+
29
+ class ActionfulClient:
30
+ """Client for invoking a single published mQuark Actionful endpoint."""
31
+
32
+ def __init__(
33
+ self,
34
+ options: ActionfulClientOptions,
35
+ *,
36
+ http_client: httpx.AsyncClient | None = None,
37
+ ) -> None:
38
+ self._endpoint_url = options.endpoint_url
39
+ self._poll_interval = options.poll_interval
40
+ self._http = http_client or httpx.AsyncClient(
41
+ auth=(options.access_token, options.access_secret)
42
+ )
43
+
44
+ async def aclose(self) -> None:
45
+ await self._http.aclose()
46
+
47
+ async def __aenter__(self) -> ActionfulClient:
48
+ return self
49
+
50
+ async def __aexit__(self, *_: Any) -> None:
51
+ await self.aclose()
52
+
53
+ # ── Layer 1 · Raw async ──────────────────────────────────────────────────
54
+
55
+ async def submit(self, input: Any) -> InvocationTicket:
56
+ """Submits a payload and returns a ticket immediately (always 202)."""
57
+ response = await self._http.post(
58
+ self._endpoint_url,
59
+ content=_serialise(input),
60
+ headers={"Content-Type": "application/json", _TIMEOUT_HEADER: "0"},
61
+ )
62
+ _ensure_success(response)
63
+ return _parse_ticket(response)
64
+
65
+ async def get_job(self, ticket_or_url: InvocationTicket | str) -> InvocationJob:
66
+ """Polls once and returns the job's current state."""
67
+ poll_url = ticket_or_url if isinstance(ticket_or_url, str) else ticket_or_url.poll_url
68
+ response = await self._http.get(poll_url)
69
+ _ensure_success(response)
70
+
71
+ job_id = _extract_job_id(poll_url)
72
+ if response.status_code == 200:
73
+ return InvocationJob(
74
+ job_id=job_id,
75
+ status=InvocationStatus.SUCCEEDED,
76
+ result_json=response.text,
77
+ )
78
+ return InvocationJob(job_id=job_id, status=InvocationStatus.RUNNING)
79
+
80
+ # ── Layer 2 · Invoke and wait ─────────────────────────────────────────
81
+
82
+ async def invoke_raw(self, payload: str) -> str:
83
+ """Submits payload, polls until completion, returns the raw result string."""
84
+ response = await self._http.post(
85
+ self._endpoint_url,
86
+ content=payload,
87
+ headers={"Content-Type": "application/json"},
88
+ )
89
+ _ensure_success(response)
90
+
91
+ if response.status_code == 200:
92
+ return response.text
93
+
94
+ ticket = _parse_ticket(response)
95
+ return await self._poll_until_complete(ticket.poll_url)
96
+
97
+ async def invoke(self, input: Any) -> Any:
98
+ """Serialises input, invokes the endpoint, returns the deserialised result (dict/list/primitive)."""
99
+ raw = await self.invoke_raw(_serialise(input))
100
+ return _deserialise(raw)
101
+
102
+ # ── Layer 3 · Batch ───────────────────────────────────────────────────
103
+
104
+ async def invoke_batch(
105
+ self,
106
+ inputs: Iterable[Any],
107
+ options: BatchOptions | None = None,
108
+ ) -> list[InvocationResult[Any]]:
109
+ """Processes all inputs and returns results when everything completes."""
110
+ results: list[InvocationResult[Any]] = []
111
+ async for r in self.stream_batch(inputs, options):
112
+ results.append(r)
113
+ return results
114
+
115
+ async def stream_batch(
116
+ self,
117
+ inputs: Iterable[Any],
118
+ options: BatchOptions | None = None,
119
+ ) -> AsyncGenerator[InvocationResult[Any], None]:
120
+ """Processes inputs concurrently, yielding results as each completes."""
121
+ opts = options or BatchOptions()
122
+ out: asyncio.Queue[Any] = asyncio.Queue(maxsize=opts.output_buffer_capacity)
123
+ sem = asyncio.Semaphore(opts.max_concurrency)
124
+ stop = asyncio.Event()
125
+
126
+ async def worker(item: Any) -> None:
127
+ result = await self._invoke_one(item)
128
+ if not result.is_success and opts.stop_on_first_failure:
129
+ stop.set()
130
+ await out.put(result)
131
+ sem.release()
132
+
133
+ async def producer() -> None:
134
+ tasks: list[asyncio.Task[None]] = []
135
+ for item in inputs:
136
+ if stop.is_set():
137
+ break
138
+ await sem.acquire()
139
+ tasks.append(asyncio.create_task(worker(item)))
140
+ await asyncio.gather(*tasks, return_exceptions=True)
141
+ await out.put(_SENTINEL)
142
+
143
+ producer_task = asyncio.create_task(producer())
144
+
145
+ while True:
146
+ item = await out.get()
147
+ if item is _SENTINEL:
148
+ break
149
+ yield item
150
+
151
+ await producer_task
152
+
153
+ # ── Layer 4 · Pipeline ────────────────────────────────────────────────
154
+
155
+ async def process(
156
+ self,
157
+ inputs: AsyncIterator[Any],
158
+ options: PipelineOptions | None = None,
159
+ ) -> AsyncGenerator[InvocationResult[Any], None]:
160
+ """Processes an async-iterable stream, yielding results as each completes."""
161
+ opts = options or PipelineOptions()
162
+ out: asyncio.Queue[Any] = asyncio.Queue(maxsize=opts.output_buffer_capacity)
163
+ sem = asyncio.Semaphore(opts.max_concurrency)
164
+
165
+ async def worker(item: Any) -> None:
166
+ result = await self._invoke_one(item)
167
+ await out.put(result)
168
+ sem.release()
169
+
170
+ async def producer() -> None:
171
+ tasks: list[asyncio.Task[None]] = []
172
+ async for item in inputs:
173
+ await sem.acquire()
174
+ tasks.append(asyncio.create_task(worker(item)))
175
+ await asyncio.gather(*tasks, return_exceptions=True)
176
+ await out.put(_SENTINEL)
177
+
178
+ producer_task = asyncio.create_task(producer())
179
+
180
+ while True:
181
+ item = await out.get()
182
+ if item is _SENTINEL:
183
+ break
184
+ yield item
185
+
186
+ await producer_task
187
+
188
+ def create_pipeline(self, options: PipelineOptions | None = None) -> Pipeline:
189
+ """Creates a long-running pipeline with explicit push/complete/iterate control."""
190
+ return Pipeline(self._invoke_one, options)
191
+
192
+ # ── Internal ─────────────────────────────────────────────────────────────
193
+
194
+ async def _poll_until_complete(self, poll_url: str) -> str:
195
+ while True:
196
+ response = await self._http.get(poll_url)
197
+ _ensure_success(response)
198
+
199
+ if response.status_code == 200:
200
+ return response.text
201
+
202
+ await asyncio.sleep(_poll_wait(response, self._poll_interval))
203
+
204
+ async def _invoke_one(self, item: Any) -> InvocationResult[Any]:
205
+ ticket = InvocationTicket(job_id="unknown", poll_url="", submitted_at=datetime.now(timezone.utc))
206
+ try:
207
+ ticket = await self.submit(item)
208
+ result_json = await self._poll_until_complete(ticket.poll_url)
209
+ return InvocationResult(ticket=ticket, output=_deserialise(result_json), error=None)
210
+ except asyncio.CancelledError:
211
+ raise
212
+ except Exception as exc:
213
+ return InvocationResult(ticket=ticket, output=None, error=str(exc))
214
+
215
+
216
+ class Pipeline:
217
+ """Long-running pipeline with explicit push/complete/iterate control.
218
+
219
+ Create via ``client.create_pipeline()`` inside an async context. Push items
220
+ with ``await push()``, signal end with ``complete()``, and consume results
221
+ with ``async for``.
222
+ """
223
+
224
+ def __init__(
225
+ self,
226
+ invoke_fn: Any,
227
+ options: PipelineOptions | None = None,
228
+ ) -> None:
229
+ self._invoke_fn = invoke_fn
230
+ self._opts = options or PipelineOptions()
231
+ self._input: asyncio.Queue[Any] | None = None
232
+ self._output: asyncio.Queue[Any] | None = None
233
+ self._task: asyncio.Task[None] | None = None
234
+
235
+ def _ensure_started(self) -> None:
236
+ if self._task is None:
237
+ self._input = asyncio.Queue(maxsize=self._opts.input_buffer_capacity)
238
+ self._output = asyncio.Queue(maxsize=self._opts.output_buffer_capacity)
239
+ self._task = asyncio.create_task(self._run())
240
+
241
+ async def push(self, item: Any) -> None:
242
+ """Sends an item to the pipeline. Blocks when the input buffer is full."""
243
+ self._ensure_started()
244
+ assert self._input is not None
245
+ await self._input.put(item)
246
+
247
+ def complete(self) -> None:
248
+ """Signals that no more items will be pushed. Must be called exactly once."""
249
+ self._ensure_started()
250
+ assert self._input is not None
251
+ self._input.put_nowait(_SENTINEL)
252
+
253
+ def __aiter__(self) -> Pipeline:
254
+ return self
255
+
256
+ async def __anext__(self) -> InvocationResult[Any]:
257
+ self._ensure_started()
258
+ assert self._output is not None
259
+ result = await self._output.get()
260
+ if result is _SENTINEL:
261
+ raise StopAsyncIteration
262
+ return result
263
+
264
+ async def _run(self) -> None:
265
+ assert self._input is not None
266
+ assert self._output is not None
267
+ sem = asyncio.Semaphore(self._opts.max_concurrency)
268
+ tasks: list[asyncio.Task[None]] = []
269
+
270
+ while True:
271
+ item = await self._input.get()
272
+ if item is _SENTINEL:
273
+ break
274
+ await sem.acquire()
275
+
276
+ async def worker(i: Any, s: asyncio.Semaphore = sem) -> None:
277
+ result = await self._invoke_fn(i)
278
+ await self._output.put(result) # type: ignore[union-attr]
279
+ s.release()
280
+
281
+ tasks.append(asyncio.create_task(worker(item)))
282
+
283
+ await asyncio.gather(*tasks, return_exceptions=True)
284
+ await self._output.put(_SENTINEL)
285
+
286
+
287
+ # ── Helpers ───────────────────────────────────────────────────────────────────
288
+
289
+ def _serialise(input: Any) -> str:
290
+ if isinstance(input, str):
291
+ return input
292
+ if dataclasses.is_dataclass(input) and not isinstance(input, type):
293
+ return json.dumps(dataclasses.asdict(input))
294
+ return json.dumps(input)
295
+
296
+
297
+ def _deserialise(json_str: str) -> Any:
298
+ try:
299
+ return json.loads(json_str)
300
+ except json.JSONDecodeError:
301
+ return json_str
302
+
303
+
304
+ def _parse_ticket(response: httpx.Response) -> InvocationTicket:
305
+ location = response.headers.get("location") or response.headers.get("Location")
306
+ if not location:
307
+ raise ActionfulError(response.status_code, "202 response missing Location header")
308
+ if not location.startswith("http"):
309
+ location = urljoin(str(response.url), location)
310
+ return InvocationTicket(
311
+ job_id=_extract_job_id(location),
312
+ poll_url=location,
313
+ submitted_at=datetime.now(timezone.utc),
314
+ )
315
+
316
+
317
+ def _extract_job_id(url: str) -> str:
318
+ path = urlparse(url).path.rstrip("/")
319
+ return path.rsplit("/", 1)[-1]
320
+
321
+
322
+ def _ensure_success(response: httpx.Response) -> None:
323
+ if response.status_code in (200, 202):
324
+ return
325
+ retry_after: int | None = None
326
+ if response.status_code == 429:
327
+ ra = response.headers.get("retry-after", "")
328
+ if ra.isdigit():
329
+ retry_after = int(ra)
330
+ raise ActionfulError(
331
+ status_code=response.status_code,
332
+ body=response.text.strip(),
333
+ retry_after=retry_after,
334
+ )
335
+
336
+
337
+ def _poll_wait(response: httpx.Response, floor: float) -> float:
338
+ ra = response.headers.get("retry-after", "")
339
+ if ra.isdigit():
340
+ return max(floor, float(ra))
341
+ return floor
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from enum import Enum
6
+ from typing import Generic, TypeVar
7
+
8
+ T = TypeVar("T")
9
+
10
+
11
+ class InvocationStatus(str, Enum):
12
+ PENDING = "pending"
13
+ RUNNING = "running"
14
+ SUCCEEDED = "succeeded"
15
+ FAILED = "failed"
16
+ CANCELLED = "cancelled"
17
+
18
+ @property
19
+ def is_terminal(self) -> bool:
20
+ return self in (self.SUCCEEDED, self.FAILED, self.CANCELLED)
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class InvocationTicket:
25
+ job_id: str
26
+ poll_url: str
27
+ submitted_at: datetime
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class InvocationJob:
32
+ job_id: str
33
+ status: InvocationStatus
34
+ result_json: str | None = None
35
+ error: str | None = None
36
+
37
+ @property
38
+ def is_terminal(self) -> bool:
39
+ return self.status.is_terminal
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class InvocationResult(Generic[T]):
44
+ ticket: InvocationTicket
45
+ output: T | None
46
+ error: str | None
47
+
48
+ @property
49
+ def is_success(self) -> bool:
50
+ return self.error is None
51
+
52
+
53
+ class ActionfulError(Exception):
54
+ """Raised for non-2xx HTTP responses."""
55
+
56
+ def __init__(self, status_code: int, body: str, retry_after: int | None = None) -> None:
57
+ super().__init__(f"HTTP {status_code}: {body}")
58
+ self.status_code = status_code
59
+ self.body = body
60
+ self.retry_after = retry_after # seconds; populated on 429
61
+
62
+
63
+ @dataclass
64
+ class ActionfulClientOptions:
65
+ """Configuration for ActionfulClient."""
66
+
67
+ endpoint_url: str
68
+ access_token: str
69
+ access_secret: str
70
+ # Floor for poll wait; server Retry-After wins when higher.
71
+ poll_interval: float = 2.0
72
+
73
+ def __post_init__(self) -> None:
74
+ if not self.endpoint_url:
75
+ raise ValueError("endpoint_url is required")
76
+ if not self.access_token:
77
+ raise ValueError("access_token is required")
78
+ if not self.access_secret:
79
+ raise ValueError("access_secret is required")
80
+
81
+
82
+ @dataclass
83
+ class BatchOptions:
84
+ max_concurrency: int = 10
85
+ stop_on_first_failure: bool = False
86
+ output_buffer_capacity: int = 1000
87
+
88
+
89
+ @dataclass
90
+ class PipelineOptions:
91
+ max_concurrency: int = 10
92
+ input_buffer_capacity: int = 1000
93
+ output_buffer_capacity: int = 1000
File without changes
@@ -0,0 +1,30 @@
1
+ import httpx
2
+ import pytest
3
+
4
+ from mquark_actionful import ActionfulClient, ActionfulClientOptions
5
+
6
+ ENDPOINT = "https://api.test/endpoint"
7
+ JOB_URL = "https://api.test/jobs/abc123"
8
+
9
+
10
+ def make_client(poll_interval: float = 0.001) -> ActionfulClient:
11
+ return ActionfulClient(
12
+ ActionfulClientOptions(
13
+ endpoint_url=ENDPOINT,
14
+ access_token="tok",
15
+ access_secret="sec",
16
+ poll_interval=poll_interval,
17
+ )
18
+ )
19
+
20
+
21
+ def resp_202(job_url: str = JOB_URL) -> httpx.Response:
22
+ return httpx.Response(202, headers={"Location": job_url})
23
+
24
+
25
+ def resp_200(body: str = '{"score": 0.9}') -> httpx.Response:
26
+ return httpx.Response(200, text=body)
27
+
28
+
29
+ def resp_error(status: int = 500, body: str = "internal error") -> httpx.Response:
30
+ return httpx.Response(status, text=body)
@@ -0,0 +1,102 @@
1
+ import asyncio
2
+ import httpx
3
+ import respx
4
+
5
+ from .conftest import ENDPOINT, JOB_URL, make_client, resp_200, resp_202, resp_error
6
+
7
+
8
+ async def test_invoke_batch_all_succeed():
9
+ with respx.mock:
10
+ respx.post(ENDPOINT).mock(return_value=resp_202())
11
+ respx.get(JOB_URL).mock(return_value=resp_200('{"score": 0.5}'))
12
+ async with make_client() as client:
13
+ results = await client.invoke_batch([{"id": 1}, {"id": 2}, {"id": 3}])
14
+
15
+ assert len(results) == 3
16
+ assert all(r.is_success for r in results)
17
+ assert all(r.output == {"score": 0.5} for r in results)
18
+
19
+
20
+ async def test_invoke_batch_partial_failure():
21
+ call_count = 0
22
+
23
+ def post_side_effect(request: httpx.Request) -> httpx.Response:
24
+ nonlocal call_count
25
+ call_count += 1
26
+ if call_count % 2 == 0:
27
+ return resp_error(500, "forced error")
28
+ return resp_202("https://api.test/jobs/ok")
29
+
30
+ with respx.mock:
31
+ respx.post(ENDPOINT).mock(side_effect=post_side_effect)
32
+ respx.get("https://api.test/jobs/ok").mock(return_value=resp_200('{"score": 0.5}'))
33
+ async with make_client() as client:
34
+ results = await client.invoke_batch([{"id": i} for i in range(4)])
35
+
36
+ assert len(results) == 4
37
+ successes = [r for r in results if r.is_success]
38
+ failures = [r for r in results if not r.is_success]
39
+ assert len(successes) > 0
40
+ assert len(failures) > 0
41
+
42
+
43
+ async def test_stream_batch_yields_as_complete():
44
+ with respx.mock:
45
+ respx.post(ENDPOINT).mock(return_value=resp_202())
46
+ respx.get(JOB_URL).mock(return_value=resp_200('{"score": 0.3}'))
47
+ async with make_client() as client:
48
+ received = []
49
+ async for r in client.stream_batch([{"id": 1}, {"id": 2}]):
50
+ received.append(r)
51
+
52
+ assert len(received) == 2
53
+ assert all(r.is_success for r in received)
54
+
55
+
56
+ async def test_stream_batch_stop_on_first_failure():
57
+ from mquark_actionful import BatchOptions
58
+
59
+ call_count = 0
60
+
61
+ def post_side_effect(request: httpx.Request) -> httpx.Response:
62
+ nonlocal call_count
63
+ call_count += 1
64
+ if call_count == 1:
65
+ return resp_error(500, "first failure")
66
+ return resp_202()
67
+
68
+ with respx.mock:
69
+ respx.post(ENDPOINT).mock(side_effect=post_side_effect)
70
+ respx.get(JOB_URL).mock(return_value=resp_200())
71
+ opts = BatchOptions(max_concurrency=1, stop_on_first_failure=True)
72
+ async with make_client() as client:
73
+ results = []
74
+ async for r in client.stream_batch([{"id": i} for i in range(50)], opts):
75
+ results.append(r)
76
+
77
+ assert len(results) < 50
78
+
79
+
80
+ async def test_batch_respects_max_concurrency():
81
+ """No more than MaxConcurrency requests should be in flight at the same time."""
82
+ from mquark_actionful import BatchOptions
83
+
84
+ in_flight = 0
85
+ max_seen = 0
86
+
87
+ async def post_side_effect(request: httpx.Request) -> httpx.Response:
88
+ nonlocal in_flight, max_seen
89
+ in_flight += 1
90
+ max_seen = max(max_seen, in_flight)
91
+ await asyncio.sleep(0.01)
92
+ in_flight -= 1
93
+ return resp_202()
94
+
95
+ with respx.mock:
96
+ respx.post(ENDPOINT).mock(side_effect=post_side_effect)
97
+ respx.get(JOB_URL).mock(return_value=resp_200())
98
+ opts = BatchOptions(max_concurrency=3)
99
+ async with make_client() as client:
100
+ await client.invoke_batch([{"id": i} for i in range(10)], opts)
101
+
102
+ assert max_seen <= 3
@@ -0,0 +1,92 @@
1
+ import asyncio
2
+ import httpx
3
+ import pytest
4
+ import respx
5
+
6
+ from .conftest import ENDPOINT, JOB_URL, make_client, resp_200, resp_202
7
+
8
+
9
+ async def test_invoke_raw_fast_path_200():
10
+ with respx.mock:
11
+ respx.post(ENDPOINT).mock(return_value=resp_200('{"score": 0.9}'))
12
+ async with make_client() as client:
13
+ result = await client.invoke_raw('{"id": 1}')
14
+
15
+ assert result == '{"score": 0.9}'
16
+
17
+
18
+ async def test_invoke_raw_async_path_polls_until_200():
19
+ poll_count = 0
20
+
21
+ def poll_side_effect(request: httpx.Request) -> httpx.Response:
22
+ nonlocal poll_count
23
+ poll_count += 1
24
+ if poll_count < 3:
25
+ return httpx.Response(202, headers={"Retry-After": "0"})
26
+ return resp_200('{"score": 0.5}')
27
+
28
+ with respx.mock:
29
+ respx.post(ENDPOINT).mock(return_value=resp_202())
30
+ respx.get(JOB_URL).mock(side_effect=poll_side_effect)
31
+ async with make_client() as client:
32
+ result = await client.invoke_raw('{"id": 1}')
33
+
34
+ assert result == '{"score": 0.5}'
35
+ assert poll_count == 3
36
+
37
+
38
+ async def test_invoke_returns_dict():
39
+ with respx.mock:
40
+ respx.post(ENDPOINT).mock(return_value=resp_200('{"score": 0.85}'))
41
+ async with make_client() as client:
42
+ result = await client.invoke({"id": 1})
43
+
44
+ assert result == {"score": 0.85}
45
+
46
+
47
+ async def test_invoke_returns_string_for_plain_text():
48
+ with respx.mock:
49
+ respx.post(ENDPOINT).mock(return_value=resp_200("plain text result"))
50
+ async with make_client() as client:
51
+ result = await client.invoke_raw("plain text result")
52
+
53
+ assert result == "plain text result"
54
+
55
+
56
+ async def test_invoke_respects_retry_after():
57
+ """Server Retry-After wins when higher than PollInterval."""
58
+ poll_times: list[float] = []
59
+ poll_count = 0
60
+
61
+ def poll_side_effect(request: httpx.Request) -> httpx.Response:
62
+ nonlocal poll_count
63
+ poll_count += 1
64
+ poll_times.append(asyncio.get_event_loop().time())
65
+ if poll_count < 2:
66
+ return httpx.Response(202, headers={"Retry-After": "0"})
67
+ return resp_200('{"done": true}')
68
+
69
+ with respx.mock:
70
+ respx.post(ENDPOINT).mock(return_value=resp_202())
71
+ respx.get(JOB_URL).mock(side_effect=poll_side_effect)
72
+ async with make_client(poll_interval=0.001) as client:
73
+ await client.invoke_raw('{}')
74
+
75
+ assert poll_count == 2
76
+
77
+
78
+ async def test_invoke_cancellation():
79
+ async def slow_poll(request: httpx.Request) -> httpx.Response:
80
+ await asyncio.sleep(10)
81
+ return resp_200()
82
+
83
+ with respx.mock:
84
+ respx.post(ENDPOINT).mock(return_value=resp_202())
85
+ respx.get(JOB_URL).mock(side_effect=slow_poll)
86
+
87
+ async with make_client() as client:
88
+ with pytest.raises(asyncio.CancelledError):
89
+ task = asyncio.create_task(client.invoke_raw('{}'))
90
+ await asyncio.sleep(0.01)
91
+ task.cancel()
92
+ await task
@@ -0,0 +1,108 @@
1
+ import asyncio
2
+ import httpx
3
+ import respx
4
+
5
+ from .conftest import ENDPOINT, JOB_URL, make_client, resp_200, resp_202
6
+
7
+
8
+ async def test_process_streams_results():
9
+ async def input_stream():
10
+ for i in range(3):
11
+ yield {"id": i}
12
+
13
+ with respx.mock:
14
+ respx.post(ENDPOINT).mock(return_value=resp_202())
15
+ respx.get(JOB_URL).mock(return_value=resp_200('{"score": 0.3}'))
16
+ async with make_client() as client:
17
+ results = []
18
+ async for r in client.process(input_stream()):
19
+ results.append(r)
20
+
21
+ assert len(results) == 3
22
+ assert all(r.is_success for r in results)
23
+
24
+
25
+ async def test_pipeline_push_and_iterate():
26
+ with respx.mock:
27
+ respx.post(ENDPOINT).mock(return_value=resp_202())
28
+ respx.get(JOB_URL).mock(return_value=resp_200('{"score": 0.6}'))
29
+ async with make_client() as client:
30
+ pipeline = client.create_pipeline()
31
+
32
+ async def producer() -> None:
33
+ for i in range(5):
34
+ await pipeline.push({"id": i})
35
+ pipeline.complete()
36
+
37
+ asyncio.create_task(producer())
38
+
39
+ results = []
40
+ async for r in pipeline:
41
+ results.append(r)
42
+
43
+ assert len(results) == 5
44
+ assert all(r.is_success for r in results)
45
+ assert all(r.output == {"score": 0.6} for r in results)
46
+
47
+
48
+ async def test_pipeline_errors_captured_per_item():
49
+ from mquark_actionful import PipelineOptions
50
+
51
+ call_count = 0
52
+
53
+ def post_side_effect(request: httpx.Request) -> httpx.Response:
54
+ nonlocal call_count
55
+ call_count += 1
56
+ if call_count % 2 == 0:
57
+ return httpx.Response(500, text="forced error")
58
+ return resp_202("https://api.test/jobs/ok")
59
+
60
+ with respx.mock:
61
+ respx.post(ENDPOINT).mock(side_effect=post_side_effect)
62
+ respx.get("https://api.test/jobs/ok").mock(return_value=resp_200())
63
+ async with make_client() as client:
64
+ pipeline = client.create_pipeline(PipelineOptions(max_concurrency=1))
65
+
66
+ async def producer() -> None:
67
+ for i in range(4):
68
+ await pipeline.push({"id": i})
69
+ pipeline.complete()
70
+
71
+ asyncio.create_task(producer())
72
+
73
+ results = []
74
+ async for r in pipeline:
75
+ results.append(r)
76
+
77
+ assert len(results) == 4
78
+ assert any(r.is_success for r in results)
79
+ assert any(not r.is_success for r in results)
80
+
81
+
82
+ async def test_process_respects_max_concurrency():
83
+ from mquark_actionful import PipelineOptions
84
+
85
+ in_flight = 0
86
+ max_seen = 0
87
+
88
+ async def post_side_effect(request: httpx.Request) -> httpx.Response:
89
+ nonlocal in_flight, max_seen
90
+ in_flight += 1
91
+ max_seen = max(max_seen, in_flight)
92
+ await asyncio.sleep(0.01)
93
+ in_flight -= 1
94
+ return resp_202()
95
+
96
+ async def input_stream():
97
+ for i in range(10):
98
+ yield {"id": i}
99
+
100
+ with respx.mock:
101
+ respx.post(ENDPOINT).mock(side_effect=post_side_effect)
102
+ respx.get(JOB_URL).mock(return_value=resp_200())
103
+ opts = PipelineOptions(max_concurrency=3)
104
+ async with make_client() as client:
105
+ async for _ in client.process(input_stream(), opts):
106
+ pass
107
+
108
+ assert max_seen <= 3
@@ -0,0 +1,102 @@
1
+ import httpx
2
+ import pytest
3
+ import respx
4
+
5
+ from mquark_actionful import ActionfulError, InvocationStatus
6
+
7
+ from .conftest import ENDPOINT, JOB_URL, make_client, resp_200, resp_202, resp_error
8
+
9
+
10
+ async def test_submit_returns_ticket():
11
+ with respx.mock:
12
+ respx.post(ENDPOINT).mock(return_value=resp_202())
13
+ async with make_client() as client:
14
+ ticket = await client.submit({"id": 1})
15
+
16
+ assert ticket.job_id == "abc123"
17
+ assert ticket.poll_url == JOB_URL
18
+
19
+
20
+ async def test_submit_serialises_dict():
21
+ with respx.mock:
22
+ route = respx.post(ENDPOINT).mock(return_value=resp_202())
23
+ async with make_client() as client:
24
+ await client.submit({"order": 42})
25
+
26
+ assert route.called
27
+ import json
28
+ assert json.loads(route.calls[0].request.content) == {"order": 42}
29
+
30
+
31
+ async def test_submit_passes_raw_json_string():
32
+ with respx.mock:
33
+ route = respx.post(ENDPOINT).mock(return_value=resp_202())
34
+ async with make_client() as client:
35
+ await client.submit('{"raw": true}')
36
+
37
+ body = route.calls[0].request.content
38
+ assert b'"raw": true' in body
39
+
40
+
41
+ async def test_get_job_running_on_202():
42
+ with respx.mock:
43
+ respx.post(ENDPOINT).mock(return_value=resp_202())
44
+ respx.get(JOB_URL).mock(return_value=httpx.Response(202, headers={"Retry-After": "0"}))
45
+ async with make_client() as client:
46
+ ticket = await client.submit({"id": 1})
47
+ job = await client.get_job(ticket)
48
+
49
+ assert job.status == InvocationStatus.RUNNING
50
+ assert not job.is_terminal
51
+
52
+
53
+ async def test_get_job_succeeded_on_200():
54
+ with respx.mock:
55
+ respx.post(ENDPOINT).mock(return_value=resp_202())
56
+ respx.get(JOB_URL).mock(return_value=resp_200('{"score": 0.7}'))
57
+ async with make_client() as client:
58
+ ticket = await client.submit({"id": 1})
59
+ job = await client.get_job(ticket)
60
+
61
+ assert job.status == InvocationStatus.SUCCEEDED
62
+ assert job.result_json == '{"score": 0.7}'
63
+ assert job.is_terminal
64
+
65
+
66
+ async def test_get_job_accepts_poll_url_string():
67
+ with respx.mock:
68
+ respx.get(JOB_URL).mock(return_value=resp_200())
69
+ async with make_client() as client:
70
+ job = await client.get_job(JOB_URL)
71
+
72
+ assert job.status == InvocationStatus.SUCCEEDED
73
+
74
+
75
+ async def test_submit_raises_on_4xx():
76
+ with respx.mock:
77
+ respx.post(ENDPOINT).mock(return_value=resp_error(400, "bad input"))
78
+ async with make_client() as client:
79
+ with pytest.raises(ActionfulError) as exc_info:
80
+ await client.submit({"id": 1})
81
+
82
+ assert exc_info.value.status_code == 400
83
+ assert exc_info.value.body == "bad input"
84
+
85
+
86
+ async def test_submit_raises_with_retry_after_on_429():
87
+ with respx.mock:
88
+ respx.post(ENDPOINT).mock(return_value=httpx.Response(
89
+ 429, text="rate limited", headers={"Retry-After": "30"}
90
+ ))
91
+ async with make_client() as client:
92
+ with pytest.raises(ActionfulError) as exc_info:
93
+ await client.submit({"id": 1})
94
+
95
+ assert exc_info.value.status_code == 429
96
+ assert exc_info.value.retry_after == 30
97
+
98
+
99
+ async def test_missing_endpoint_url_raises():
100
+ with pytest.raises(ValueError, match="endpoint_url"):
101
+ from mquark_actionful import ActionfulClientOptions
102
+ ActionfulClientOptions(endpoint_url="", access_token="t", access_secret="s")