multipletools 0.1.0__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.
- multipletools-0.1.0/PKG-INFO +76 -0
- multipletools-0.1.0/README.md +55 -0
- multipletools-0.1.0/multipletools/__init__.py +32 -0
- multipletools-0.1.0/multipletools/_http.py +121 -0
- multipletools-0.1.0/multipletools/client.py +48 -0
- multipletools-0.1.0/multipletools/exceptions.py +55 -0
- multipletools-0.1.0/multipletools/jobs.py +93 -0
- multipletools-0.1.0/multipletools/models.py +84 -0
- multipletools-0.1.0/multipletools/py.typed +1 -0
- multipletools-0.1.0/multipletools/tools.py +20 -0
- multipletools-0.1.0/multipletools.egg-info/PKG-INFO +76 -0
- multipletools-0.1.0/multipletools.egg-info/SOURCES.txt +16 -0
- multipletools-0.1.0/multipletools.egg-info/dependency_links.txt +1 -0
- multipletools-0.1.0/multipletools.egg-info/requires.txt +4 -0
- multipletools-0.1.0/multipletools.egg-info/top_level.txt +1 -0
- multipletools-0.1.0/pyproject.toml +47 -0
- multipletools-0.1.0/setup.cfg +4 -0
- multipletools-0.1.0/tests/test_client.py +166 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: multipletools
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the Multiple Tools API
|
|
5
|
+
Author: Abhishek Kumbhar
|
|
6
|
+
Project-URL: Homepage, https://github.com/Abhiiishek44/multipletools
|
|
7
|
+
Project-URL: Repository, https://github.com/Abhiiishek44/multipletools
|
|
8
|
+
Project-URL: Issues, https://github.com/Abhiiishek44/multipletools/issues
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
Requires-Dist: httpx<1,>=0.27
|
|
19
|
+
Provides-Extra: test
|
|
20
|
+
Requires-Dist: pytest<9,>=8; extra == "test"
|
|
21
|
+
|
|
22
|
+
# Multiple Tools Python SDK
|
|
23
|
+
|
|
24
|
+
Typed synchronous Python client for the Multiple Tools HTTP API. The SDK talks
|
|
25
|
+
only to FastAPI and has no database, object-storage, Redis, Celery, or plugin
|
|
26
|
+
dependencies.
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install ./sdks/python
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from multipletools import Client
|
|
38
|
+
|
|
39
|
+
client = Client(
|
|
40
|
+
api_key="mt_live_...",
|
|
41
|
+
base_url="https://api.example.com",
|
|
42
|
+
timeout=60.0,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
job = client.jobs.create(
|
|
46
|
+
tool="pdf_to_word", # underscore aliases are normalized to pdf-to-word
|
|
47
|
+
file="document.pdf",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
job = client.jobs.get(job.id)
|
|
51
|
+
if job.status == "SUCCESS":
|
|
52
|
+
client.jobs.download(job.id, "converted.docx")
|
|
53
|
+
|
|
54
|
+
client.close()
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The client can also be used as a context manager. `jobs.create` generates an
|
|
58
|
+
idempotency key automatically; pass `idempotency_key=` to reuse one across an
|
|
59
|
+
application-level retry. Tool-specific options are sent with `options={...}`.
|
|
60
|
+
If the platform cannot infer a file's MIME type, pass `media_type=` explicitly.
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
with Client(api_key="mt_live_...", base_url="http://localhost:8000") as client:
|
|
64
|
+
tools = client.tools.list()
|
|
65
|
+
job = client.jobs.create(
|
|
66
|
+
tool="rotate-pdf",
|
|
67
|
+
file="document.pdf",
|
|
68
|
+
options={"angle": 90},
|
|
69
|
+
idempotency_key="conversion-123",
|
|
70
|
+
)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
HTTP failures raise typed exceptions from `multipletools.exceptions`, including
|
|
74
|
+
`AuthenticationError`, `PermissionDeniedError`, `ValidationError`,
|
|
75
|
+
`NotFoundError`, `ConflictError`, `RateLimitError`, `TimeoutError`, and
|
|
76
|
+
`ConnectionError`.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Multiple Tools Python SDK
|
|
2
|
+
|
|
3
|
+
Typed synchronous Python client for the Multiple Tools HTTP API. The SDK talks
|
|
4
|
+
only to FastAPI and has no database, object-storage, Redis, Celery, or plugin
|
|
5
|
+
dependencies.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install ./sdks/python
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from multipletools import Client
|
|
17
|
+
|
|
18
|
+
client = Client(
|
|
19
|
+
api_key="mt_live_...",
|
|
20
|
+
base_url="https://api.example.com",
|
|
21
|
+
timeout=60.0,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
job = client.jobs.create(
|
|
25
|
+
tool="pdf_to_word", # underscore aliases are normalized to pdf-to-word
|
|
26
|
+
file="document.pdf",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
job = client.jobs.get(job.id)
|
|
30
|
+
if job.status == "SUCCESS":
|
|
31
|
+
client.jobs.download(job.id, "converted.docx")
|
|
32
|
+
|
|
33
|
+
client.close()
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The client can also be used as a context manager. `jobs.create` generates an
|
|
37
|
+
idempotency key automatically; pass `idempotency_key=` to reuse one across an
|
|
38
|
+
application-level retry. Tool-specific options are sent with `options={...}`.
|
|
39
|
+
If the platform cannot infer a file's MIME type, pass `media_type=` explicitly.
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
with Client(api_key="mt_live_...", base_url="http://localhost:8000") as client:
|
|
43
|
+
tools = client.tools.list()
|
|
44
|
+
job = client.jobs.create(
|
|
45
|
+
tool="rotate-pdf",
|
|
46
|
+
file="document.pdf",
|
|
47
|
+
options={"angle": 90},
|
|
48
|
+
idempotency_key="conversion-123",
|
|
49
|
+
)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
HTTP failures raise typed exceptions from `multipletools.exceptions`, including
|
|
53
|
+
`AuthenticationError`, `PermissionDeniedError`, `ValidationError`,
|
|
54
|
+
`NotFoundError`, `ConflictError`, `RateLimitError`, `TimeoutError`, and
|
|
55
|
+
`ConnectionError`.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from multipletools.client import Client
|
|
2
|
+
from multipletools.exceptions import (
|
|
3
|
+
APIError,
|
|
4
|
+
AuthenticationError,
|
|
5
|
+
ConflictError,
|
|
6
|
+
ConnectionError,
|
|
7
|
+
MultipleToolsError,
|
|
8
|
+
NotFoundError,
|
|
9
|
+
PermissionDeniedError,
|
|
10
|
+
RateLimitError,
|
|
11
|
+
TimeoutError,
|
|
12
|
+
ValidationError,
|
|
13
|
+
)
|
|
14
|
+
from multipletools.models import Job, Tool
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"APIError",
|
|
18
|
+
"AuthenticationError",
|
|
19
|
+
"Client",
|
|
20
|
+
"ConflictError",
|
|
21
|
+
"ConnectionError",
|
|
22
|
+
"Job",
|
|
23
|
+
"MultipleToolsError",
|
|
24
|
+
"NotFoundError",
|
|
25
|
+
"PermissionDeniedError",
|
|
26
|
+
"RateLimitError",
|
|
27
|
+
"TimeoutError",
|
|
28
|
+
"Tool",
|
|
29
|
+
"ValidationError",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
from collections.abc import Iterator, Mapping
|
|
2
|
+
from contextlib import contextmanager
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from multipletools.exceptions import (
|
|
8
|
+
APIError,
|
|
9
|
+
AuthenticationError,
|
|
10
|
+
ConflictError,
|
|
11
|
+
ConnectionError,
|
|
12
|
+
NotFoundError,
|
|
13
|
+
PermissionDeniedError,
|
|
14
|
+
RateLimitError,
|
|
15
|
+
TimeoutError,
|
|
16
|
+
ValidationError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_ERROR_TYPES: dict[int, type[APIError]] = {
|
|
21
|
+
400: ValidationError,
|
|
22
|
+
401: AuthenticationError,
|
|
23
|
+
403: PermissionDeniedError,
|
|
24
|
+
404: NotFoundError,
|
|
25
|
+
409: ConflictError,
|
|
26
|
+
422: ValidationError,
|
|
27
|
+
429: RateLimitError,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class HttpClient:
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
*,
|
|
35
|
+
api_key: str,
|
|
36
|
+
base_url: str,
|
|
37
|
+
timeout: float | httpx.Timeout,
|
|
38
|
+
transport: httpx.BaseTransport | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
self._client = httpx.Client(
|
|
41
|
+
base_url=base_url.rstrip("/") + "/",
|
|
42
|
+
timeout=timeout,
|
|
43
|
+
transport=transport,
|
|
44
|
+
follow_redirects=False,
|
|
45
|
+
headers={
|
|
46
|
+
"Authorization": f"Bearer {api_key}",
|
|
47
|
+
"Accept": "application/json",
|
|
48
|
+
"User-Agent": "multipletools-python/0.1.0",
|
|
49
|
+
},
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def request_json(
|
|
53
|
+
self,
|
|
54
|
+
method: str,
|
|
55
|
+
path: str,
|
|
56
|
+
*,
|
|
57
|
+
headers: Mapping[str, str] | None = None,
|
|
58
|
+
data: Mapping[str, str] | None = None,
|
|
59
|
+
files: Mapping[str, Any] | None = None,
|
|
60
|
+
) -> Any:
|
|
61
|
+
try:
|
|
62
|
+
response = self._client.request(
|
|
63
|
+
method, path.lstrip("/"), headers=headers, data=data, files=files
|
|
64
|
+
)
|
|
65
|
+
except httpx.TimeoutException as error:
|
|
66
|
+
raise TimeoutError("The Multiple Tools API request timed out") from error
|
|
67
|
+
except httpx.RequestError as error:
|
|
68
|
+
raise ConnectionError(
|
|
69
|
+
f"Could not reach the Multiple Tools API: {error}"
|
|
70
|
+
) from error
|
|
71
|
+
raise_for_status(response)
|
|
72
|
+
try:
|
|
73
|
+
return response.json()
|
|
74
|
+
except ValueError as error:
|
|
75
|
+
raise APIError(
|
|
76
|
+
"The Multiple Tools API returned invalid JSON",
|
|
77
|
+
status_code=response.status_code,
|
|
78
|
+
request_id=response.headers.get("x-request-id"),
|
|
79
|
+
) from error
|
|
80
|
+
|
|
81
|
+
@contextmanager
|
|
82
|
+
def stream(self, method: str, path: str) -> Iterator[httpx.Response]:
|
|
83
|
+
try:
|
|
84
|
+
with self._client.stream(method, path.lstrip("/")) as response:
|
|
85
|
+
if not 200 <= response.status_code < 300:
|
|
86
|
+
response.read()
|
|
87
|
+
raise_for_status(response)
|
|
88
|
+
yield response
|
|
89
|
+
except httpx.TimeoutException as error:
|
|
90
|
+
raise TimeoutError("The Multiple Tools API request timed out") from error
|
|
91
|
+
except httpx.RequestError as error:
|
|
92
|
+
raise ConnectionError(
|
|
93
|
+
f"Could not reach the Multiple Tools API: {error}"
|
|
94
|
+
) from error
|
|
95
|
+
|
|
96
|
+
def close(self) -> None:
|
|
97
|
+
self._client.close()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def raise_for_status(response: httpx.Response) -> None:
|
|
101
|
+
if 200 <= response.status_code < 300:
|
|
102
|
+
return
|
|
103
|
+
details: Any = None
|
|
104
|
+
message = f"Multiple Tools API returned HTTP {response.status_code}"
|
|
105
|
+
try:
|
|
106
|
+
details = response.json()
|
|
107
|
+
if isinstance(details, dict):
|
|
108
|
+
detail = details.get("detail")
|
|
109
|
+
if isinstance(detail, str) and detail:
|
|
110
|
+
message = detail
|
|
111
|
+
elif detail is not None:
|
|
112
|
+
message = str(detail)
|
|
113
|
+
except ValueError:
|
|
114
|
+
pass
|
|
115
|
+
error_type = _ERROR_TYPES.get(response.status_code, APIError)
|
|
116
|
+
raise error_type(
|
|
117
|
+
message,
|
|
118
|
+
status_code=response.status_code,
|
|
119
|
+
request_id=response.headers.get("x-request-id"),
|
|
120
|
+
details=details,
|
|
121
|
+
)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from types import TracebackType
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
from multipletools._http import HttpClient
|
|
7
|
+
from multipletools.jobs import Jobs
|
|
8
|
+
from multipletools.tools import Tools
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_API_KEY_PATTERN = re.compile(r"^mt_live_[a-f0-9]{24}_[A-Za-z0-9_-]{32,128}$")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Client:
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
*,
|
|
18
|
+
api_key: str,
|
|
19
|
+
base_url: str = "http://localhost:8000",
|
|
20
|
+
timeout: float | httpx.Timeout = 30.0,
|
|
21
|
+
transport: httpx.BaseTransport | None = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
if not _API_KEY_PATTERN.fullmatch(api_key):
|
|
24
|
+
raise ValueError("api_key must use the mt_live_<key_id>_<secret> format")
|
|
25
|
+
if not base_url.startswith(("http://", "https://")):
|
|
26
|
+
raise ValueError("base_url must be an absolute HTTP or HTTPS URL")
|
|
27
|
+
self._http = HttpClient(
|
|
28
|
+
api_key=api_key,
|
|
29
|
+
base_url=base_url,
|
|
30
|
+
timeout=timeout,
|
|
31
|
+
transport=transport,
|
|
32
|
+
)
|
|
33
|
+
self.jobs = Jobs(self._http)
|
|
34
|
+
self.tools = Tools(self._http)
|
|
35
|
+
|
|
36
|
+
def close(self) -> None:
|
|
37
|
+
self._http.close()
|
|
38
|
+
|
|
39
|
+
def __enter__(self) -> "Client":
|
|
40
|
+
return self
|
|
41
|
+
|
|
42
|
+
def __exit__(
|
|
43
|
+
self,
|
|
44
|
+
exc_type: type[BaseException] | None,
|
|
45
|
+
exc_value: BaseException | None,
|
|
46
|
+
traceback: TracebackType | None,
|
|
47
|
+
) -> None:
|
|
48
|
+
self.close()
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class MultipleToolsError(Exception):
|
|
5
|
+
"""Base exception for all SDK failures."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConnectionError(MultipleToolsError):
|
|
9
|
+
"""The API could not be reached."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TimeoutError(MultipleToolsError):
|
|
13
|
+
"""The API request exceeded its configured timeout."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class APIError(MultipleToolsError):
|
|
17
|
+
"""The API returned a non-success response."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
message: str,
|
|
22
|
+
*,
|
|
23
|
+
status_code: int,
|
|
24
|
+
request_id: str | None = None,
|
|
25
|
+
details: Any = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
super().__init__(message)
|
|
28
|
+
self.message = message
|
|
29
|
+
self.status_code = status_code
|
|
30
|
+
self.request_id = request_id
|
|
31
|
+
self.details = details
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ValidationError(APIError):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AuthenticationError(APIError):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class PermissionDeniedError(APIError):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class NotFoundError(APIError):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ConflictError(APIError):
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class RateLimitError(APIError):
|
|
55
|
+
pass
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import mimetypes
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import BinaryIO, Mapping
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
from multipletools._http import HttpClient
|
|
10
|
+
from multipletools.models import Job
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
FileInput = str | os.PathLike[str] | BinaryIO
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Jobs:
|
|
17
|
+
def __init__(self, http: HttpClient) -> None:
|
|
18
|
+
self._http = http
|
|
19
|
+
|
|
20
|
+
def create(
|
|
21
|
+
self,
|
|
22
|
+
*,
|
|
23
|
+
tool: str,
|
|
24
|
+
file: FileInput,
|
|
25
|
+
options: Mapping[str, object] | None = None,
|
|
26
|
+
idempotency_key: str | None = None,
|
|
27
|
+
media_type: str | None = None,
|
|
28
|
+
) -> Job:
|
|
29
|
+
canonical_tool = tool.strip().replace("_", "-")
|
|
30
|
+
if not canonical_tool:
|
|
31
|
+
raise ValueError("tool must not be empty")
|
|
32
|
+
headers = {"Idempotency-Key": idempotency_key or uuid4().hex}
|
|
33
|
+
data = {"tool": canonical_tool}
|
|
34
|
+
if options:
|
|
35
|
+
data["options"] = json.dumps(options, separators=(",", ":"))
|
|
36
|
+
|
|
37
|
+
if isinstance(file, (str, os.PathLike)):
|
|
38
|
+
path = Path(file)
|
|
39
|
+
resolved_media_type = media_type or _media_type(path.name)
|
|
40
|
+
with path.open("rb") as stream:
|
|
41
|
+
payload = self._http.request_json(
|
|
42
|
+
"POST",
|
|
43
|
+
"/v1/jobs",
|
|
44
|
+
headers=headers,
|
|
45
|
+
data=data,
|
|
46
|
+
files={"file": (path.name, stream, resolved_media_type)},
|
|
47
|
+
)
|
|
48
|
+
else:
|
|
49
|
+
filename = Path(str(getattr(file, "name", "upload.bin"))).name
|
|
50
|
+
resolved_media_type = media_type or _media_type(filename)
|
|
51
|
+
payload = self._http.request_json(
|
|
52
|
+
"POST",
|
|
53
|
+
"/v1/jobs",
|
|
54
|
+
headers=headers,
|
|
55
|
+
data=data,
|
|
56
|
+
files={"file": (filename, file, resolved_media_type)},
|
|
57
|
+
)
|
|
58
|
+
return Job.from_dict(payload)
|
|
59
|
+
|
|
60
|
+
def get(self, job_id: str) -> Job:
|
|
61
|
+
payload = self._http.request_json("GET", f"/v1/jobs/{_job_id(job_id)}")
|
|
62
|
+
return Job.from_dict(payload)
|
|
63
|
+
|
|
64
|
+
def download(
|
|
65
|
+
self,
|
|
66
|
+
job_id: str,
|
|
67
|
+
destination: str | os.PathLike[str],
|
|
68
|
+
) -> Path:
|
|
69
|
+
target = Path(destination)
|
|
70
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
temporary = target.with_name(f".{target.name}.{uuid4().hex}.part")
|
|
72
|
+
try:
|
|
73
|
+
with self._http.stream(
|
|
74
|
+
"GET", f"/v1/jobs/{_job_id(job_id)}/output"
|
|
75
|
+
) as response:
|
|
76
|
+
with temporary.open("wb") as output:
|
|
77
|
+
for chunk in response.iter_bytes():
|
|
78
|
+
output.write(chunk)
|
|
79
|
+
temporary.replace(target)
|
|
80
|
+
except BaseException:
|
|
81
|
+
temporary.unlink(missing_ok=True)
|
|
82
|
+
raise
|
|
83
|
+
return target
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _job_id(job_id: str) -> str:
|
|
87
|
+
if not job_id:
|
|
88
|
+
raise ValueError("job_id must not be empty")
|
|
89
|
+
return quote(job_id, safe="")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _media_type(filename: str) -> str:
|
|
93
|
+
return mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import Any, Literal, Mapping, cast
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
JobStatus = Literal["QUEUED", "RUNNING", "SUCCESS", "FAILED"]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class Job:
|
|
11
|
+
id: str
|
|
12
|
+
tool_name: str
|
|
13
|
+
tool_version: str
|
|
14
|
+
status: JobStatus
|
|
15
|
+
progress: int
|
|
16
|
+
input_filename: str
|
|
17
|
+
output_filename: str | None
|
|
18
|
+
output_url: str | None
|
|
19
|
+
error: str | None
|
|
20
|
+
created_at: datetime
|
|
21
|
+
started_at: datetime | None
|
|
22
|
+
completed_at: datetime | None
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def from_dict(cls, data: Mapping[str, Any]) -> "Job":
|
|
26
|
+
status = str(data["status"])
|
|
27
|
+
if status not in {"QUEUED", "RUNNING", "SUCCESS", "FAILED"}:
|
|
28
|
+
raise ValueError(f"Unknown job status: {status}")
|
|
29
|
+
progress = int(data["progress"])
|
|
30
|
+
if not 0 <= progress <= 100:
|
|
31
|
+
raise ValueError("Job progress must be between 0 and 100")
|
|
32
|
+
return cls(
|
|
33
|
+
id=str(data["id"]),
|
|
34
|
+
tool_name=str(data["tool_name"]),
|
|
35
|
+
tool_version=str(data["tool_version"]),
|
|
36
|
+
status=cast(JobStatus, status),
|
|
37
|
+
progress=progress,
|
|
38
|
+
input_filename=str(data["input_filename"]),
|
|
39
|
+
output_filename=_optional_string(data.get("output_filename")),
|
|
40
|
+
output_url=_optional_string(data.get("output_url")),
|
|
41
|
+
error=_optional_string(data.get("error")),
|
|
42
|
+
created_at=_datetime(data["created_at"]),
|
|
43
|
+
started_at=_optional_datetime(data.get("started_at")),
|
|
44
|
+
completed_at=_optional_datetime(data.get("completed_at")),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class Tool:
|
|
50
|
+
name: str
|
|
51
|
+
version: str
|
|
52
|
+
description: str
|
|
53
|
+
input_suffixes: tuple[str, ...]
|
|
54
|
+
input_media_types: tuple[str, ...]
|
|
55
|
+
output_suffix: str
|
|
56
|
+
output_media_type: str
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def from_dict(cls, data: Mapping[str, Any]) -> "Tool":
|
|
60
|
+
return cls(
|
|
61
|
+
name=str(data["name"]),
|
|
62
|
+
version=str(data["version"]),
|
|
63
|
+
description=str(data["description"]),
|
|
64
|
+
input_suffixes=tuple(str(value) for value in data["input_suffixes"]),
|
|
65
|
+
input_media_types=tuple(
|
|
66
|
+
str(value) for value in data["input_media_types"]
|
|
67
|
+
),
|
|
68
|
+
output_suffix=str(data["output_suffix"]),
|
|
69
|
+
output_media_type=str(data["output_media_type"]),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _datetime(value: Any) -> datetime:
|
|
74
|
+
if not isinstance(value, str):
|
|
75
|
+
raise ValueError("Expected an ISO-8601 datetime string")
|
|
76
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _optional_datetime(value: Any) -> datetime | None:
|
|
80
|
+
return None if value is None else _datetime(value)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _optional_string(value: Any) -> str | None:
|
|
84
|
+
return None if value is None else str(value)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from multipletools._http import HttpClient
|
|
2
|
+
from multipletools.models import Tool
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Tools:
|
|
6
|
+
def __init__(self, http: HttpClient) -> None:
|
|
7
|
+
self._http = http
|
|
8
|
+
|
|
9
|
+
def list(self) -> list[Tool]:
|
|
10
|
+
payload = self._http.request_json("GET", "/v1/tools")
|
|
11
|
+
if not isinstance(payload, list):
|
|
12
|
+
raise ValueError("Expected the tools API to return a list")
|
|
13
|
+
return [Tool.from_dict(item) for item in payload]
|
|
14
|
+
|
|
15
|
+
def get(self, name: str) -> Tool:
|
|
16
|
+
canonical_name = name.replace("_", "-")
|
|
17
|
+
for tool in self.list():
|
|
18
|
+
if tool.name == canonical_name:
|
|
19
|
+
return tool
|
|
20
|
+
raise LookupError(f"Unknown tool: {name}")
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: multipletools
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the Multiple Tools API
|
|
5
|
+
Author: Abhishek Kumbhar
|
|
6
|
+
Project-URL: Homepage, https://github.com/Abhiiishek44/multipletools
|
|
7
|
+
Project-URL: Repository, https://github.com/Abhiiishek44/multipletools
|
|
8
|
+
Project-URL: Issues, https://github.com/Abhiiishek44/multipletools/issues
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
Requires-Dist: httpx<1,>=0.27
|
|
19
|
+
Provides-Extra: test
|
|
20
|
+
Requires-Dist: pytest<9,>=8; extra == "test"
|
|
21
|
+
|
|
22
|
+
# Multiple Tools Python SDK
|
|
23
|
+
|
|
24
|
+
Typed synchronous Python client for the Multiple Tools HTTP API. The SDK talks
|
|
25
|
+
only to FastAPI and has no database, object-storage, Redis, Celery, or plugin
|
|
26
|
+
dependencies.
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install ./sdks/python
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from multipletools import Client
|
|
38
|
+
|
|
39
|
+
client = Client(
|
|
40
|
+
api_key="mt_live_...",
|
|
41
|
+
base_url="https://api.example.com",
|
|
42
|
+
timeout=60.0,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
job = client.jobs.create(
|
|
46
|
+
tool="pdf_to_word", # underscore aliases are normalized to pdf-to-word
|
|
47
|
+
file="document.pdf",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
job = client.jobs.get(job.id)
|
|
51
|
+
if job.status == "SUCCESS":
|
|
52
|
+
client.jobs.download(job.id, "converted.docx")
|
|
53
|
+
|
|
54
|
+
client.close()
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The client can also be used as a context manager. `jobs.create` generates an
|
|
58
|
+
idempotency key automatically; pass `idempotency_key=` to reuse one across an
|
|
59
|
+
application-level retry. Tool-specific options are sent with `options={...}`.
|
|
60
|
+
If the platform cannot infer a file's MIME type, pass `media_type=` explicitly.
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
with Client(api_key="mt_live_...", base_url="http://localhost:8000") as client:
|
|
64
|
+
tools = client.tools.list()
|
|
65
|
+
job = client.jobs.create(
|
|
66
|
+
tool="rotate-pdf",
|
|
67
|
+
file="document.pdf",
|
|
68
|
+
options={"angle": 90},
|
|
69
|
+
idempotency_key="conversion-123",
|
|
70
|
+
)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
HTTP failures raise typed exceptions from `multipletools.exceptions`, including
|
|
74
|
+
`AuthenticationError`, `PermissionDeniedError`, `ValidationError`,
|
|
75
|
+
`NotFoundError`, `ConflictError`, `RateLimitError`, `TimeoutError`, and
|
|
76
|
+
`ConnectionError`.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
multipletools/__init__.py
|
|
4
|
+
multipletools/_http.py
|
|
5
|
+
multipletools/client.py
|
|
6
|
+
multipletools/exceptions.py
|
|
7
|
+
multipletools/jobs.py
|
|
8
|
+
multipletools/models.py
|
|
9
|
+
multipletools/py.typed
|
|
10
|
+
multipletools/tools.py
|
|
11
|
+
multipletools.egg-info/PKG-INFO
|
|
12
|
+
multipletools.egg-info/SOURCES.txt
|
|
13
|
+
multipletools.egg-info/dependency_links.txt
|
|
14
|
+
multipletools.egg-info/requires.txt
|
|
15
|
+
multipletools.egg-info/top_level.txt
|
|
16
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
multipletools
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "multipletools"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python SDK for the Multiple Tools API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Abhishek Kumbhar" }
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
dependencies = [
|
|
17
|
+
"httpx>=0.27,<1"
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
23
|
+
"Programming Language :: Python :: 3.10",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Programming Language :: Python :: 3.13",
|
|
27
|
+
"Typing :: Typed",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
test = [
|
|
32
|
+
"pytest>=8,<9"
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://github.com/Abhiiishek44/multipletools"
|
|
37
|
+
Repository = "https://github.com/Abhiiishek44/multipletools"
|
|
38
|
+
Issues = "https://github.com/Abhiiishek44/multipletools/issues"
|
|
39
|
+
|
|
40
|
+
[tool.setuptools.packages.find]
|
|
41
|
+
include = ["multipletools*"]
|
|
42
|
+
|
|
43
|
+
[tool.setuptools.package-data]
|
|
44
|
+
multipletools = ["py.typed"]
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import tempfile
|
|
4
|
+
import unittest
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from multipletools import Client
|
|
9
|
+
from multipletools.exceptions import AuthenticationError, ConflictError, TimeoutError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
API_KEY = "mt_live_0123456789abcdef01234567_" + "s" * 43
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def job_payload(**overrides: object) -> dict[str, object]:
|
|
16
|
+
payload: dict[str, object] = {
|
|
17
|
+
"id": "job-123",
|
|
18
|
+
"tool_name": "pdf-to-word",
|
|
19
|
+
"tool_version": "1.0.0",
|
|
20
|
+
"status": "QUEUED",
|
|
21
|
+
"progress": 0,
|
|
22
|
+
"input_filename": "document.pdf",
|
|
23
|
+
"output_filename": None,
|
|
24
|
+
"output_url": None,
|
|
25
|
+
"error": None,
|
|
26
|
+
"created_at": "2026-09-11T10:00:00Z",
|
|
27
|
+
"started_at": None,
|
|
28
|
+
"completed_at": None,
|
|
29
|
+
}
|
|
30
|
+
payload.update(overrides)
|
|
31
|
+
return payload
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ClientTests(unittest.TestCase):
|
|
35
|
+
def test_create_uses_generic_http_endpoint_and_normalizes_tool_alias(self) -> None:
|
|
36
|
+
captured: dict[str, object] = {}
|
|
37
|
+
|
|
38
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
39
|
+
captured["request"] = request
|
|
40
|
+
captured["body"] = request.read()
|
|
41
|
+
return httpx.Response(202, json=job_payload())
|
|
42
|
+
|
|
43
|
+
transport = httpx.MockTransport(handler)
|
|
44
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
45
|
+
source = Path(directory) / "document.pdf"
|
|
46
|
+
source.write_bytes(b"%PDF-test")
|
|
47
|
+
with Client(
|
|
48
|
+
api_key=API_KEY,
|
|
49
|
+
base_url="https://api.example.test",
|
|
50
|
+
transport=transport,
|
|
51
|
+
) as client:
|
|
52
|
+
job = client.jobs.create(
|
|
53
|
+
tool="pdf_to_word",
|
|
54
|
+
file=source,
|
|
55
|
+
options={"quality": "high"},
|
|
56
|
+
idempotency_key="request-123",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
request = captured["request"]
|
|
60
|
+
assert isinstance(request, httpx.Request)
|
|
61
|
+
body = captured["body"]
|
|
62
|
+
assert isinstance(body, bytes)
|
|
63
|
+
self.assertEqual(request.url.path, "/v1/jobs")
|
|
64
|
+
self.assertEqual(request.headers["authorization"], f"Bearer {API_KEY}")
|
|
65
|
+
self.assertEqual(request.headers["idempotency-key"], "request-123")
|
|
66
|
+
self.assertIn(b'pdf-to-word', body)
|
|
67
|
+
self.assertIn(b'document.pdf', body)
|
|
68
|
+
self.assertIn(json.dumps({"quality": "high"}, separators=(",", ":")).encode(), body)
|
|
69
|
+
self.assertEqual(job.id, "job-123")
|
|
70
|
+
|
|
71
|
+
def test_get_returns_typed_job(self) -> None:
|
|
72
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
73
|
+
self.assertEqual(request.url.path, "/v1/jobs/job-123")
|
|
74
|
+
return httpx.Response(
|
|
75
|
+
200,
|
|
76
|
+
json=job_payload(
|
|
77
|
+
status="SUCCESS",
|
|
78
|
+
progress=100,
|
|
79
|
+
output_filename="converted.docx",
|
|
80
|
+
output_url="/v1/jobs/job-123/output",
|
|
81
|
+
completed_at="2026-09-11T10:01:00+00:00",
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
with Client(api_key=API_KEY, transport=httpx.MockTransport(handler)) as client:
|
|
86
|
+
job = client.jobs.get("job-123")
|
|
87
|
+
|
|
88
|
+
self.assertEqual(job.status, "SUCCESS")
|
|
89
|
+
self.assertEqual(job.progress, 100)
|
|
90
|
+
self.assertEqual(job.completed_at.isoformat(), "2026-09-11T10:01:00+00:00")
|
|
91
|
+
|
|
92
|
+
def test_download_streams_to_destination(self) -> None:
|
|
93
|
+
content = b"converted-document"
|
|
94
|
+
|
|
95
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
96
|
+
self.assertEqual(request.url.path, "/v1/jobs/job-123/output")
|
|
97
|
+
return httpx.Response(200, content=content)
|
|
98
|
+
|
|
99
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
100
|
+
destination = Path(directory) / "nested" / "converted.docx"
|
|
101
|
+
with Client(api_key=API_KEY, transport=httpx.MockTransport(handler)) as client:
|
|
102
|
+
returned = client.jobs.download("job-123", destination)
|
|
103
|
+
self.assertEqual(returned, destination)
|
|
104
|
+
self.assertEqual(destination.read_bytes(), content)
|
|
105
|
+
self.assertEqual(list(destination.parent.glob("*.part")), [])
|
|
106
|
+
|
|
107
|
+
def test_failed_download_does_not_leave_a_partial_file(self) -> None:
|
|
108
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
109
|
+
return httpx.Response(409, json={"detail": "Job output is not available"})
|
|
110
|
+
|
|
111
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
112
|
+
destination = Path(directory) / "converted.docx"
|
|
113
|
+
with Client(api_key=API_KEY, transport=httpx.MockTransport(handler)) as client:
|
|
114
|
+
with self.assertRaises(ConflictError):
|
|
115
|
+
client.jobs.download("job-123", destination)
|
|
116
|
+
self.assertFalse(destination.exists())
|
|
117
|
+
self.assertEqual(list(destination.parent.glob("*.part")), [])
|
|
118
|
+
|
|
119
|
+
def test_api_errors_are_typed_and_include_details(self) -> None:
|
|
120
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
121
|
+
return httpx.Response(401, json={"detail": "Invalid API key"})
|
|
122
|
+
|
|
123
|
+
with Client(api_key=API_KEY, transport=httpx.MockTransport(handler)) as client:
|
|
124
|
+
with self.assertRaises(AuthenticationError) as caught:
|
|
125
|
+
client.jobs.get("job-123")
|
|
126
|
+
|
|
127
|
+
self.assertEqual(caught.exception.status_code, 401)
|
|
128
|
+
self.assertEqual(str(caught.exception), "Invalid API key")
|
|
129
|
+
|
|
130
|
+
def test_transport_timeouts_are_wrapped(self) -> None:
|
|
131
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
132
|
+
raise httpx.ReadTimeout("timed out", request=request)
|
|
133
|
+
|
|
134
|
+
with Client(api_key=API_KEY, transport=httpx.MockTransport(handler)) as client:
|
|
135
|
+
with self.assertRaises(TimeoutError):
|
|
136
|
+
client.jobs.get("job-123")
|
|
137
|
+
|
|
138
|
+
def test_tools_list_returns_typed_tools(self) -> None:
|
|
139
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
140
|
+
return httpx.Response(
|
|
141
|
+
200,
|
|
142
|
+
json=[
|
|
143
|
+
{
|
|
144
|
+
"name": "pdf-to-word",
|
|
145
|
+
"version": "1.0.0",
|
|
146
|
+
"description": "Convert PDF to Word",
|
|
147
|
+
"input_suffixes": [".pdf"],
|
|
148
|
+
"input_media_types": ["application/pdf"],
|
|
149
|
+
"output_suffix": ".docx",
|
|
150
|
+
"output_media_type": (
|
|
151
|
+
"application/vnd.openxmlformats-officedocument."
|
|
152
|
+
"wordprocessingml.document"
|
|
153
|
+
),
|
|
154
|
+
}
|
|
155
|
+
],
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
with Client(api_key=API_KEY, transport=httpx.MockTransport(handler)) as client:
|
|
159
|
+
tools = client.tools.list()
|
|
160
|
+
|
|
161
|
+
self.assertEqual(tools[0].name, "pdf-to-word")
|
|
162
|
+
self.assertEqual(tools[0].input_suffixes, (".pdf",))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
if __name__ == "__main__":
|
|
166
|
+
unittest.main()
|