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.
- mquark_actionful_client-1.0.2/.gitignore +34 -0
- mquark_actionful_client-1.0.2/PKG-INFO +63 -0
- mquark_actionful_client-1.0.2/README.md +48 -0
- mquark_actionful_client-1.0.2/pyproject.toml +30 -0
- mquark_actionful_client-1.0.2/src/mquark_actionful/__init__.py +24 -0
- mquark_actionful_client-1.0.2/src/mquark_actionful/_client.py +341 -0
- mquark_actionful_client-1.0.2/src/mquark_actionful/_types.py +93 -0
- mquark_actionful_client-1.0.2/src/mquark_actionful/py.typed +0 -0
- mquark_actionful_client-1.0.2/tests/__init__.py +0 -0
- mquark_actionful_client-1.0.2/tests/conftest.py +30 -0
- mquark_actionful_client-1.0.2/tests/test_batch.py +102 -0
- mquark_actionful_client-1.0.2/tests/test_invoke.py +92 -0
- mquark_actionful_client-1.0.2/tests/test_pipeline.py +108 -0
- mquark_actionful_client-1.0.2/tests/test_submit.py +102 -0
|
@@ -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
|
|
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")
|