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.
@@ -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,4 @@
1
+ httpx<1,>=0.27
2
+
3
+ [test]
4
+ pytest<9,>=8
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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()