indox-client 0.2.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,11 @@
1
+ Copyright (c) 2024-2025 Indox
2
+
3
+ All rights reserved.
4
+
5
+ This software and associated documentation files (the "Software") are proprietary
6
+ and confidential. Unauthorized copying, modification, distribution, or use of
7
+ this Software, via any medium, is strictly prohibited.
8
+
9
+ The Software is provided "AS IS", without warranty of any kind, express or implied.
10
+
11
+ For licensing inquiries, contact: support@indox.org
@@ -0,0 +1,157 @@
1
+ Metadata-Version: 2.4
2
+ Name: indox-client
3
+ Version: 0.2.0
4
+ Summary: Python SDK for the Indox media and document conversion services.
5
+ Author-email: Indox <support@indox.org>
6
+ License-Expression: LicenseRef-Proprietary
7
+ Project-URL: Homepage, https://indox.org
8
+ Project-URL: Documentation, https://docs.indox.org
9
+ Project-URL: Repository, https://github.com/indox-org/indox_client
10
+ Project-URL: Issues, https://github.com/indox-org/indox_client/issues
11
+ Keywords: indox,document-conversion,media-conversion,sdk,api-client
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: requests>=2.28.0
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=7.0; extra == "dev"
26
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
27
+ Requires-Dist: responses>=0.23.0; extra == "dev"
28
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
29
+ Requires-Dist: mypy>=1.0; extra == "dev"
30
+ Requires-Dist: types-requests>=2.28; extra == "dev"
31
+ Dynamic: license-file
32
+
33
+ # indox-client
34
+
35
+ Python SDK for the Indox media and document conversion services.
36
+
37
+ ## Installation
38
+
39
+ ```bash
40
+ pip install indox-client
41
+ ```
42
+
43
+ ## Quick Start
44
+
45
+ ```python
46
+ from indox_client import IndoxClient
47
+
48
+ # Initialize with API key
49
+ client = IndoxClient(api_key="your-api-key")
50
+
51
+ # Or use environment variables
52
+ # export INDOX_API_KEY=your-api-key
53
+ client = IndoxClient.from_env()
54
+
55
+ # Convert a document
56
+ result = client.docs.convert_file(
57
+ file_path="document.pdf",
58
+ target_formats=["docx", "txt"]
59
+ )
60
+
61
+ # Wait for completion
62
+ completed = client.docs.wait_for_completion(result["conversion_id"])
63
+
64
+ # Download the result
65
+ client.docs.download(
66
+ conversion_id=result["conversion_id"],
67
+ output_path="output.docx",
68
+ fmt="docx"
69
+ )
70
+ ```
71
+
72
+ ## Features
73
+
74
+ - **Document Conversion**: Convert between PDF, DOCX, TXT, and more
75
+ - **Media Conversion**: Convert images and videos between formats
76
+ - **Async Support**: Long-polling for conversion status
77
+ - **Multiple Sources**: Upload files, URLs, or S3 keys
78
+
79
+ ## Usage
80
+
81
+ ### Document Conversion
82
+
83
+ ```python
84
+ # From local file
85
+ result = client.docs.convert_file(
86
+ file_path="input.pdf",
87
+ target_formats=["docx"]
88
+ )
89
+
90
+ # From URL
91
+ result = client.docs.convert_url(
92
+ file_url="https://example.com/document.pdf",
93
+ target_formats=["docx"]
94
+ )
95
+
96
+ # Check supported formats
97
+ formats = client.docs.supported_conversions()
98
+ ```
99
+
100
+ ### Media Conversion
101
+
102
+ ```python
103
+ # Convert image
104
+ result = client.media.convert_image(
105
+ file_path="image.png",
106
+ target_formats=["webp", "jpg"]
107
+ )
108
+
109
+ # Convert video
110
+ result = client.media.convert_video(
111
+ file_path="video.mp4",
112
+ target_formats=["webm"]
113
+ )
114
+
115
+ # Check supported formats
116
+ image_formats = client.media.image_formats()
117
+ video_formats = client.media.video_formats()
118
+ ```
119
+
120
+ ### Context Manager
121
+
122
+ ```python
123
+ with IndoxClient.from_env() as client:
124
+ result = client.docs.convert_file(
125
+ file_path="document.pdf",
126
+ target_formats=["docx"]
127
+ )
128
+ ```
129
+
130
+ ## Configuration
131
+
132
+ | Environment Variable | Description |
133
+ |---------------------|-------------|
134
+ | `INDOX_API_KEY` | Your API key |
135
+ | `INDOX_DOCS_URL` | Docs service URL (default: https://indox.org/docs) |
136
+ | `INDOX_MEDIA_URL` | Media service URL (default: https://indox.org/media) |
137
+
138
+ ## Error Handling
139
+
140
+ ```python
141
+ from indox_client import IndoxClient, IndoxHTTPError, IndoxClientError
142
+
143
+ try:
144
+ result = client.docs.convert_file(
145
+ file_path="document.pdf",
146
+ target_formats=["docx"]
147
+ )
148
+ except IndoxHTTPError as e:
149
+ print(f"HTTP {e.status_code}: {e}")
150
+ print(f"Request ID: {e.request_id}")
151
+ except IndoxClientError as e:
152
+ print(f"Client error: {e}")
153
+ ```
154
+
155
+ ## License
156
+
157
+ Proprietary - see LICENSE for details.
@@ -0,0 +1,125 @@
1
+ # indox-client
2
+
3
+ Python SDK for the Indox media and document conversion services.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install indox-client
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```python
14
+ from indox_client import IndoxClient
15
+
16
+ # Initialize with API key
17
+ client = IndoxClient(api_key="your-api-key")
18
+
19
+ # Or use environment variables
20
+ # export INDOX_API_KEY=your-api-key
21
+ client = IndoxClient.from_env()
22
+
23
+ # Convert a document
24
+ result = client.docs.convert_file(
25
+ file_path="document.pdf",
26
+ target_formats=["docx", "txt"]
27
+ )
28
+
29
+ # Wait for completion
30
+ completed = client.docs.wait_for_completion(result["conversion_id"])
31
+
32
+ # Download the result
33
+ client.docs.download(
34
+ conversion_id=result["conversion_id"],
35
+ output_path="output.docx",
36
+ fmt="docx"
37
+ )
38
+ ```
39
+
40
+ ## Features
41
+
42
+ - **Document Conversion**: Convert between PDF, DOCX, TXT, and more
43
+ - **Media Conversion**: Convert images and videos between formats
44
+ - **Async Support**: Long-polling for conversion status
45
+ - **Multiple Sources**: Upload files, URLs, or S3 keys
46
+
47
+ ## Usage
48
+
49
+ ### Document Conversion
50
+
51
+ ```python
52
+ # From local file
53
+ result = client.docs.convert_file(
54
+ file_path="input.pdf",
55
+ target_formats=["docx"]
56
+ )
57
+
58
+ # From URL
59
+ result = client.docs.convert_url(
60
+ file_url="https://example.com/document.pdf",
61
+ target_formats=["docx"]
62
+ )
63
+
64
+ # Check supported formats
65
+ formats = client.docs.supported_conversions()
66
+ ```
67
+
68
+ ### Media Conversion
69
+
70
+ ```python
71
+ # Convert image
72
+ result = client.media.convert_image(
73
+ file_path="image.png",
74
+ target_formats=["webp", "jpg"]
75
+ )
76
+
77
+ # Convert video
78
+ result = client.media.convert_video(
79
+ file_path="video.mp4",
80
+ target_formats=["webm"]
81
+ )
82
+
83
+ # Check supported formats
84
+ image_formats = client.media.image_formats()
85
+ video_formats = client.media.video_formats()
86
+ ```
87
+
88
+ ### Context Manager
89
+
90
+ ```python
91
+ with IndoxClient.from_env() as client:
92
+ result = client.docs.convert_file(
93
+ file_path="document.pdf",
94
+ target_formats=["docx"]
95
+ )
96
+ ```
97
+
98
+ ## Configuration
99
+
100
+ | Environment Variable | Description |
101
+ |---------------------|-------------|
102
+ | `INDOX_API_KEY` | Your API key |
103
+ | `INDOX_DOCS_URL` | Docs service URL (default: https://indox.org/docs) |
104
+ | `INDOX_MEDIA_URL` | Media service URL (default: https://indox.org/media) |
105
+
106
+ ## Error Handling
107
+
108
+ ```python
109
+ from indox_client import IndoxClient, IndoxHTTPError, IndoxClientError
110
+
111
+ try:
112
+ result = client.docs.convert_file(
113
+ file_path="document.pdf",
114
+ target_formats=["docx"]
115
+ )
116
+ except IndoxHTTPError as e:
117
+ print(f"HTTP {e.status_code}: {e}")
118
+ print(f"Request ID: {e.request_id}")
119
+ except IndoxClientError as e:
120
+ print(f"Client error: {e}")
121
+ ```
122
+
123
+ ## License
124
+
125
+ Proprietary - see LICENSE for details.
@@ -0,0 +1,67 @@
1
+ [build-system]
2
+ requires = ["setuptools>=67", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "indox-client"
7
+ version = "0.2.0"
8
+ description = "Python SDK for the Indox media and document conversion services."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "LicenseRef-Proprietary"
12
+ authors = [
13
+ {name = "Indox", email = "support@indox.org"}
14
+ ]
15
+ keywords = ["indox", "document-conversion", "media-conversion", "sdk", "api-client"]
16
+ dependencies = [
17
+ "requests>=2.28.0"
18
+ ]
19
+ classifiers = [
20
+ "Development Status :: 4 - Beta",
21
+ "Intended Audience :: Developers",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3.9",
24
+ "Programming Language :: Python :: 3.10",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Topic :: Software Development :: Libraries :: Python Modules",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://indox.org"
32
+ Documentation = "https://docs.indox.org"
33
+ Repository = "https://github.com/indox-org/indox_client"
34
+ Issues = "https://github.com/indox-org/indox_client/issues"
35
+
36
+ [project.optional-dependencies]
37
+ dev = [
38
+ "pytest>=7.0",
39
+ "pytest-cov>=4.0",
40
+ "responses>=0.23.0",
41
+ "ruff>=0.1.0",
42
+ "mypy>=1.0",
43
+ "types-requests>=2.28",
44
+ ]
45
+
46
+ [tool.setuptools]
47
+ package-dir = {"" = "src"}
48
+
49
+ [tool.setuptools.packages.find]
50
+ where = ["src"]
51
+ exclude = ["tests*"]
52
+
53
+ [tool.ruff]
54
+ line-length = 100
55
+ target-version = "py39"
56
+
57
+ [tool.ruff.lint]
58
+ select = ["E", "F", "I", "W"]
59
+
60
+ [tool.mypy]
61
+ python_version = "3.9"
62
+ strict = true
63
+ warn_return_any = true
64
+
65
+ [tool.pytest.ini_options]
66
+ testpaths = ["tests"]
67
+ addopts = "-v --cov=indox_client --cov-report=term-missing"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,16 @@
1
+ """Indox Python SDK for media and document conversion services."""
2
+
3
+ from .version import __version__
4
+ from .exceptions import IndoxClientError, IndoxHTTPError
5
+ from .client import IndoxClient
6
+ from .docs import DocsClient
7
+ from .media import MediaClient
8
+
9
+ __all__ = [
10
+ "__version__",
11
+ "IndoxClient",
12
+ "DocsClient",
13
+ "MediaClient",
14
+ "IndoxClientError",
15
+ "IndoxHTTPError",
16
+ ]
@@ -0,0 +1,131 @@
1
+ """Shared HTTP plumbing for DocsClient and MediaClient."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any, Iterable, Mapping, MutableMapping, Optional
7
+
8
+ import requests
9
+
10
+ from .exceptions import IndoxClientError, IndoxHTTPError
11
+ from .version import __version__
12
+
13
+
14
+ DEFAULT_TIMEOUT = (5, 60) # (connect, read)
15
+
16
+
17
+ class BaseServiceClient:
18
+ """Convenience wrapper around `requests` with Indox defaults."""
19
+
20
+ def __init__(
21
+ self,
22
+ *,
23
+ base_url: str,
24
+ api_key: str,
25
+ timeout: Optional[tuple[float, float] | float] = None,
26
+ session: Optional[requests.Session] = None,
27
+ ) -> None:
28
+ if not base_url:
29
+ raise ValueError("base_url is required")
30
+ if not api_key:
31
+ raise ValueError("api_key is required")
32
+
33
+ self.base_url = base_url.rstrip("/")
34
+ self.api_key = api_key.strip()
35
+ self.timeout = timeout or DEFAULT_TIMEOUT
36
+ self._session = session or requests.Session()
37
+ self._user_agent = f"indox-client/{__version__}"
38
+
39
+ # --------------------------------------------------------------------- #
40
+ # Internal helpers
41
+ # --------------------------------------------------------------------- #
42
+ def _build_url(self, path: str) -> str:
43
+ if path.startswith("http://") or path.startswith("https://"):
44
+ return path
45
+ if not path.startswith("/"):
46
+ path = f"/{path}"
47
+ return f"{self.base_url}{path}"
48
+
49
+ def _headers(self, extra: Optional[Mapping[str, str]] = None) -> MutableMapping[str, str]:
50
+ headers: MutableMapping[str, str] = {
51
+ "Authorization": f"Bearer {self.api_key}",
52
+ "Accept": "application/json",
53
+ "User-Agent": self._user_agent,
54
+ }
55
+ if extra:
56
+ headers.update({k: v for k, v in extra.items() if v is not None})
57
+ return headers
58
+
59
+ def _request(
60
+ self,
61
+ method: str,
62
+ path: str,
63
+ *,
64
+ params: Optional[Mapping[str, Any]] = None,
65
+ json_body: Optional[Mapping[str, Any]] = None,
66
+ data: Optional[Mapping[str, Any]] = None,
67
+ files: Optional[Mapping[str, Any]] = None,
68
+ headers: Optional[Mapping[str, str]] = None,
69
+ timeout: Optional[tuple[float, float] | float] = None,
70
+ expected: Iterable[int] = (200, 201, 202),
71
+ stream: bool = False,
72
+ ) -> Any:
73
+ url = self._build_url(path)
74
+ final_headers = self._headers(headers)
75
+
76
+ try:
77
+ response = self._session.request(
78
+ method=method.upper(),
79
+ url=url,
80
+ params=params,
81
+ json=json_body,
82
+ data=data,
83
+ files=files,
84
+ headers=final_headers,
85
+ timeout=timeout or self.timeout,
86
+ stream=stream,
87
+ )
88
+ except requests.RequestException as exc:
89
+ raise IndoxClientError(f"Request to {url} failed: {exc}") from exc
90
+
91
+ if response.status_code not in expected:
92
+ payload = _safe_json(response)
93
+ request_id = response.headers.get("X-Request-ID")
94
+ message = payload.get("detail") if isinstance(payload, dict) else response.text
95
+ raise IndoxHTTPError(
96
+ response.status_code,
97
+ message or f"Unexpected status {response.status_code}",
98
+ payload=payload if isinstance(payload, Mapping) else {"raw": response.text},
99
+ request_id=request_id,
100
+ url=url,
101
+ )
102
+
103
+ if stream:
104
+ return response
105
+
106
+ if not response.content:
107
+ return {}
108
+
109
+ return _safe_json(response)
110
+
111
+ def close(self) -> None:
112
+ self._session.close()
113
+
114
+ # ------------------------------------------------------------------ #
115
+ # Convenience wrappers for subclasses
116
+ # ------------------------------------------------------------------ #
117
+ def _post_json(self, path: str, payload: Mapping[str, Any], **kwargs) -> Any:
118
+ return self._request("POST", path, json_body=payload, **kwargs)
119
+
120
+ def _post_multipart(self, path: str, data: Mapping[str, Any], files: Mapping[str, Any], **kwargs) -> Any:
121
+ return self._request("POST", path, data=data, files=files, **kwargs)
122
+
123
+ def _get(self, path: str, params: Optional[Mapping[str, Any]] = None, **kwargs) -> Any:
124
+ return self._request("GET", path, params=params, **kwargs)
125
+
126
+
127
+ def _safe_json(response: requests.Response) -> Any:
128
+ try:
129
+ return response.json()
130
+ except json.JSONDecodeError:
131
+ return {"raw": response.text}
@@ -0,0 +1,79 @@
1
+ """Facade that exposes DocsClient and MediaClient under one object."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Optional
7
+
8
+ from .docs import DocsClient
9
+ from .media import MediaClient
10
+
11
+
12
+ DEFAULT_DOCS_BASE_URL = "https://indox.org/docs"
13
+ DEFAULT_MEDIA_BASE_URL = "https://indox.org/media"
14
+
15
+
16
+ class IndoxClient:
17
+ """Convenience wrapper that bundles docs + media clients."""
18
+
19
+ def __init__(
20
+ self,
21
+ *,
22
+ api_key: str,
23
+ docs_base_url: str = DEFAULT_DOCS_BASE_URL,
24
+ media_base_url: str = DEFAULT_MEDIA_BASE_URL,
25
+ timeout: Optional[tuple[float, float] | float] = None,
26
+ ) -> None:
27
+ docs_url = (docs_base_url or DEFAULT_DOCS_BASE_URL or "").strip().rstrip("/")
28
+ media_url = (media_base_url or DEFAULT_MEDIA_BASE_URL or "").strip().rstrip("/")
29
+
30
+ if not docs_url:
31
+ raise ValueError("docs_base_url is required")
32
+ if not media_url:
33
+ raise ValueError("media_base_url is required")
34
+
35
+ self.docs = DocsClient(base_url=docs_url, api_key=api_key, timeout=timeout)
36
+ self.media = MediaClient(base_url=media_url, api_key=api_key, timeout=timeout)
37
+
38
+ @classmethod
39
+ def from_env(
40
+ cls,
41
+ *,
42
+ api_key: Optional[str] = None,
43
+ docs_base_url: Optional[str] = None,
44
+ media_base_url: Optional[str] = None,
45
+ timeout: Optional[tuple[float, float] | float] = None,
46
+ ) -> "IndoxClient":
47
+ """Instantiate the client using standard environment variables."""
48
+ key = (api_key or os.getenv("INDOX_API_KEY") or "").strip()
49
+ docs_url = (
50
+ docs_base_url
51
+ or os.getenv("INDOX_DOCS_URL")
52
+ or DEFAULT_DOCS_BASE_URL
53
+ ).strip()
54
+ media_url = (
55
+ media_base_url
56
+ or os.getenv("INDOX_MEDIA_URL")
57
+ or DEFAULT_MEDIA_BASE_URL
58
+ ).strip()
59
+
60
+ if not key:
61
+ raise ValueError("api_key or INDOX_API_KEY is required")
62
+
63
+ return cls(
64
+ api_key=key,
65
+ docs_base_url=docs_url,
66
+ media_base_url=media_url,
67
+ timeout=timeout,
68
+ )
69
+
70
+ def close(self) -> None:
71
+ """Close the underlying HTTP sessions."""
72
+ self.docs.close()
73
+ self.media.close()
74
+
75
+ def __enter__(self) -> "IndoxClient":
76
+ return self
77
+
78
+ def __exit__(self, exc_type, exc, tb) -> None:
79
+ self.close()
@@ -0,0 +1,139 @@
1
+ """Client for the Indox document conversion service."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any, Dict, Iterable, Mapping, MutableMapping, Optional
9
+
10
+ from .base import BaseServiceClient
11
+ from .exceptions import IndoxClientError
12
+
13
+
14
+ class DocsClient(BaseServiceClient):
15
+ """High-level wrapper around `/api/v1/docs/*` endpoints."""
16
+
17
+ def supported_conversions(self) -> Mapping[str, Any]:
18
+ """Return the formats/operations advertised by the service."""
19
+ return self._get("/api/v1/docs/formats/")
20
+
21
+ def convert_file(
22
+ self,
23
+ *,
24
+ file_path: str | os.PathLike[str],
25
+ target_formats: Iterable[str],
26
+ destination: str = "aws",
27
+ redirect_url: Optional[str] = None,
28
+ engine: Optional[str] = None,
29
+ options: Optional[Mapping[str, Any]] = None,
30
+ s3_key: Optional[str] = None,
31
+ ) -> Mapping[str, Any]:
32
+ """Upload a local file for conversion."""
33
+ formats = _clean_formats(target_formats)
34
+ if not formats:
35
+ raise IndoxClientError("target_formats is required")
36
+
37
+ payload: Dict[str, Any] = {
38
+ "target_formats": formats,
39
+ "destination": destination,
40
+ }
41
+ if redirect_url:
42
+ payload["redirect_url"] = redirect_url
43
+ if engine:
44
+ payload["engine"] = engine
45
+ if s3_key:
46
+ payload["s3_key"] = s3_key
47
+ if options is not None:
48
+ payload["options"] = json.dumps(options)
49
+
50
+ path = Path(file_path)
51
+ if not path.exists():
52
+ raise FileNotFoundError(path)
53
+
54
+ with path.open("rb") as fh:
55
+ files = {"file": (path.name, fh)}
56
+ return self._post_multipart("/api/v1/docs/convert/", data=payload, files=files)
57
+
58
+ def convert_url(
59
+ self,
60
+ *,
61
+ file_url: Optional[str] = None,
62
+ s3_key: Optional[str] = None,
63
+ target_formats: Iterable[str],
64
+ destination: str = "aws",
65
+ redirect_url: Optional[str] = None,
66
+ engine: Optional[str] = None,
67
+ options: Optional[Mapping[str, Any]] = None,
68
+ ) -> Mapping[str, Any]:
69
+ """Trigger a conversion for a remote file or S3 object."""
70
+ formats = _clean_formats(target_formats)
71
+ if not formats:
72
+ raise IndoxClientError("target_formats is required")
73
+ if not file_url and not s3_key:
74
+ raise IndoxClientError("Provide file_url or s3_key")
75
+
76
+ payload: Dict[str, Any] = {
77
+ "target_formats": formats,
78
+ "destination": destination,
79
+ }
80
+ if file_url:
81
+ payload["url"] = file_url
82
+ if s3_key:
83
+ payload["s3_key"] = s3_key
84
+ if redirect_url:
85
+ payload["redirect_url"] = redirect_url
86
+ if engine:
87
+ payload["engine"] = engine
88
+ if options is not None:
89
+ payload["options"] = options
90
+
91
+ return self._post_json("/api/v1/docs/convert/json/", payload)
92
+
93
+ def get_conversion(self, conversion_id: str) -> Mapping[str, Any]:
94
+ """Fetch metadata for a conversion or batch."""
95
+ return self._get(f"/api/v1/conversion/{conversion_id}/")
96
+
97
+ def wait_for_completion(
98
+ self,
99
+ conversion_id: str,
100
+ *,
101
+ timeout_seconds: float = 60.0,
102
+ poll_interval: float = 0.5,
103
+ ) -> Mapping[str, Any]:
104
+ """Block until a conversion completes or the timeout elapses."""
105
+ params = {"timeout": timeout_seconds, "interval": poll_interval}
106
+ return self._get(f"/api/v1/conversion/wait/{conversion_id}/", params=params)
107
+
108
+ def download(
109
+ self,
110
+ *,
111
+ conversion_id: str,
112
+ output_path: str | os.PathLike[str],
113
+ fmt: Optional[str] = None,
114
+ ) -> Path:
115
+ """Download the converted artifact to disk."""
116
+ params = {"format": fmt} if fmt else None
117
+ response = self._request(
118
+ "GET",
119
+ f"/api/v1/docs/{conversion_id}/download/",
120
+ params=params,
121
+ stream=True,
122
+ expected=(200,),
123
+ )
124
+ target = Path(output_path)
125
+ target.parent.mkdir(parents=True, exist_ok=True)
126
+ with target.open("wb") as fh:
127
+ for chunk in response.iter_content(chunk_size=8192):
128
+ if chunk:
129
+ fh.write(chunk)
130
+ return target
131
+
132
+
133
+ def _clean_formats(formats: Iterable[str]) -> list[str]:
134
+ cleaned: list[str] = []
135
+ for fmt in formats:
136
+ token = (fmt or "").strip().lower().lstrip(".")
137
+ if token:
138
+ cleaned.append(token)
139
+ return cleaned
@@ -0,0 +1,41 @@
1
+ """Custom exception hierarchy used by the Indox client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Mapping, MutableMapping, Optional
6
+
7
+
8
+ class IndoxClientError(Exception):
9
+ """Base class for all SDK errors."""
10
+
11
+ def __init__(self, message: str, *, payload: Optional[Mapping[str, Any]] = None):
12
+ super().__init__(message)
13
+ self.payload = payload or {}
14
+
15
+
16
+ class IndoxHTTPError(IndoxClientError):
17
+ """Raised when the remote service returns a non-success HTTP status."""
18
+
19
+ def __init__(
20
+ self,
21
+ status_code: int,
22
+ message: str,
23
+ *,
24
+ payload: Optional[Mapping[str, Any]] = None,
25
+ request_id: Optional[str] = None,
26
+ url: Optional[str] = None,
27
+ ):
28
+ super().__init__(message, payload=payload)
29
+ self.status_code = status_code
30
+ self.request_id = request_id
31
+ self.url = url
32
+
33
+ def to_dict(self) -> MutableMapping[str, Any]:
34
+ """Return a dict representation that is easy to log/serialize."""
35
+ return {
36
+ "status_code": self.status_code,
37
+ "message": str(self),
38
+ "request_id": self.request_id,
39
+ "url": self.url,
40
+ "payload": self.payload,
41
+ }
@@ -0,0 +1,175 @@
1
+ """Client for the Indox media service (images + videos)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any, Iterable, Mapping, Optional
9
+
10
+ from .base import BaseServiceClient
11
+ from .exceptions import IndoxClientError
12
+
13
+
14
+ class MediaClient(BaseServiceClient):
15
+ """Wrapper around `/api/v1/image/*`, `/api/v1/video/*`, and download routes."""
16
+
17
+ def image_formats(self) -> Mapping[str, Any]:
18
+ """List supported image formats from `/api/v1/image/formats/`."""
19
+ return self._get("/api/v1/image/formats/")
20
+
21
+ def video_formats(self) -> Mapping[str, Any]:
22
+ """List supported video formats from `/api/v1/video/formats/`."""
23
+ return self._get("/api/v1/video/formats/")
24
+
25
+ def image_operations(self) -> Mapping[str, Any]:
26
+ """Fetch the advertised image operations for discovery."""
27
+ return self._get("/api/v1/image/operations/")
28
+
29
+ def video_operations(self) -> Mapping[str, Any]:
30
+ """Fetch the advertised video operations for discovery."""
31
+ return self._get("/api/v1/video/operations/")
32
+
33
+ def convert_image(
34
+ self,
35
+ file_path: str | os.PathLike[str] | None = None,
36
+ target_formats: Iterable[str] = (),
37
+ *,
38
+ file_url: Optional[str] = None,
39
+ s3_key: Optional[str] = None,
40
+ destination: str = "aws",
41
+ redirect_url: Optional[str] = None,
42
+ engine: Optional[str] = None,
43
+ options: Optional[Mapping[str, Any]] = None,
44
+ ) -> Mapping[str, Any]:
45
+ """Start an image conversion job."""
46
+ return self._convert_media(
47
+ endpoint="/api/v1/image/convert/",
48
+ file_path=file_path,
49
+ file_url=file_url,
50
+ s3_key=s3_key,
51
+ target_formats=target_formats,
52
+ destination=destination,
53
+ redirect_url=redirect_url,
54
+ engine=engine,
55
+ options=options,
56
+ )
57
+
58
+ def convert_video(
59
+ self,
60
+ file_path: str | os.PathLike[str] | None = None,
61
+ target_formats: Iterable[str] = (),
62
+ *,
63
+ file_url: Optional[str] = None,
64
+ s3_key: Optional[str] = None,
65
+ destination: str = "aws",
66
+ redirect_url: Optional[str] = None,
67
+ engine: Optional[str] = None,
68
+ options: Optional[Mapping[str, Any]] = None,
69
+ ) -> Mapping[str, Any]:
70
+ """Start a video conversion job."""
71
+ return self._convert_media(
72
+ endpoint="/api/v1/video/convert/",
73
+ file_path=file_path,
74
+ file_url=file_url,
75
+ s3_key=s3_key,
76
+ target_formats=target_formats,
77
+ destination=destination,
78
+ redirect_url=redirect_url,
79
+ engine=engine,
80
+ options=options,
81
+ )
82
+
83
+ def get_image_conversion(self, conversion_id: str) -> Mapping[str, Any]:
84
+ return self._get(f"/api/v1/image/conversion/{conversion_id}/")
85
+
86
+ def get_video_conversion(self, conversion_id: str) -> Mapping[str, Any]:
87
+ return self._get(f"/api/v1/video/conversion/{conversion_id}/")
88
+
89
+ def wait_for_conversion(
90
+ self,
91
+ conversion_id: str,
92
+ *,
93
+ timeout_seconds: float = 60.0,
94
+ poll_interval: float = 0.5,
95
+ ) -> Mapping[str, Any]:
96
+ params = {"timeout": timeout_seconds, "interval": poll_interval}
97
+ return self._get(f"/api/v1/conversion/wait/{conversion_id}/", params=params)
98
+
99
+ def download_media(
100
+ self,
101
+ *,
102
+ conversion_id: str,
103
+ output_path: str | os.PathLike[str],
104
+ fmt: Optional[str] = None,
105
+ ) -> Path:
106
+ params = {"format": fmt} if fmt else None
107
+ response = self._request(
108
+ "GET",
109
+ f"/api/v1/media/{conversion_id}/download/",
110
+ params=params,
111
+ stream=True,
112
+ expected=(200,),
113
+ )
114
+ target = Path(output_path)
115
+ target.parent.mkdir(parents=True, exist_ok=True)
116
+ with target.open("wb") as fh:
117
+ for chunk in response.iter_content(chunk_size=8192):
118
+ if chunk:
119
+ fh.write(chunk)
120
+ return target
121
+
122
+ def _convert_media(
123
+ self,
124
+ *,
125
+ endpoint: str,
126
+ file_path: str | os.PathLike[str] | None,
127
+ file_url: Optional[str],
128
+ s3_key: Optional[str],
129
+ target_formats: Iterable[str],
130
+ destination: str,
131
+ redirect_url: Optional[str],
132
+ engine: Optional[str],
133
+ options: Optional[Mapping[str, Any]],
134
+ ) -> Mapping[str, Any]:
135
+ formats = _clean_formats(target_formats)
136
+ if not formats:
137
+ raise IndoxClientError("target_formats is required")
138
+
139
+ payload: dict[str, Any] = {
140
+ "target_formats": formats,
141
+ "destination": destination,
142
+ }
143
+ if file_url:
144
+ payload["file_url"] = file_url
145
+ if s3_key:
146
+ payload["s3_key"] = s3_key
147
+ if redirect_url:
148
+ payload["redirect_url"] = redirect_url
149
+ if engine:
150
+ payload["engine"] = engine
151
+ if options is not None:
152
+ payload["options"] = json.dumps(options) if file_path else options
153
+
154
+ has_upload = file_path is not None
155
+ if has_upload:
156
+ path = Path(file_path)
157
+ if not path.exists():
158
+ raise FileNotFoundError(path)
159
+ with path.open("rb") as fh:
160
+ files = {"file": (path.name, fh)}
161
+ return self._post_multipart(endpoint, data=payload, files=files)
162
+
163
+ if not file_url and not s3_key:
164
+ raise IndoxClientError("Provide file_path, file_url, or s3_key")
165
+
166
+ return self._post_json(endpoint, payload)
167
+
168
+
169
+ def _clean_formats(formats: Iterable[str]) -> list[str]:
170
+ cleaned: list[str] = []
171
+ for fmt in formats:
172
+ token = (fmt or "").strip().lower().lstrip(".")
173
+ if token:
174
+ cleaned.append(token)
175
+ return cleaned
@@ -0,0 +1,3 @@
1
+ """Central place for the package version so every module can import it."""
2
+
3
+ __version__ = "0.1.1"
@@ -0,0 +1,157 @@
1
+ Metadata-Version: 2.4
2
+ Name: indox-client
3
+ Version: 0.2.0
4
+ Summary: Python SDK for the Indox media and document conversion services.
5
+ Author-email: Indox <support@indox.org>
6
+ License-Expression: LicenseRef-Proprietary
7
+ Project-URL: Homepage, https://indox.org
8
+ Project-URL: Documentation, https://docs.indox.org
9
+ Project-URL: Repository, https://github.com/indox-org/indox_client
10
+ Project-URL: Issues, https://github.com/indox-org/indox_client/issues
11
+ Keywords: indox,document-conversion,media-conversion,sdk,api-client
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: requests>=2.28.0
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=7.0; extra == "dev"
26
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
27
+ Requires-Dist: responses>=0.23.0; extra == "dev"
28
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
29
+ Requires-Dist: mypy>=1.0; extra == "dev"
30
+ Requires-Dist: types-requests>=2.28; extra == "dev"
31
+ Dynamic: license-file
32
+
33
+ # indox-client
34
+
35
+ Python SDK for the Indox media and document conversion services.
36
+
37
+ ## Installation
38
+
39
+ ```bash
40
+ pip install indox-client
41
+ ```
42
+
43
+ ## Quick Start
44
+
45
+ ```python
46
+ from indox_client import IndoxClient
47
+
48
+ # Initialize with API key
49
+ client = IndoxClient(api_key="your-api-key")
50
+
51
+ # Or use environment variables
52
+ # export INDOX_API_KEY=your-api-key
53
+ client = IndoxClient.from_env()
54
+
55
+ # Convert a document
56
+ result = client.docs.convert_file(
57
+ file_path="document.pdf",
58
+ target_formats=["docx", "txt"]
59
+ )
60
+
61
+ # Wait for completion
62
+ completed = client.docs.wait_for_completion(result["conversion_id"])
63
+
64
+ # Download the result
65
+ client.docs.download(
66
+ conversion_id=result["conversion_id"],
67
+ output_path="output.docx",
68
+ fmt="docx"
69
+ )
70
+ ```
71
+
72
+ ## Features
73
+
74
+ - **Document Conversion**: Convert between PDF, DOCX, TXT, and more
75
+ - **Media Conversion**: Convert images and videos between formats
76
+ - **Async Support**: Long-polling for conversion status
77
+ - **Multiple Sources**: Upload files, URLs, or S3 keys
78
+
79
+ ## Usage
80
+
81
+ ### Document Conversion
82
+
83
+ ```python
84
+ # From local file
85
+ result = client.docs.convert_file(
86
+ file_path="input.pdf",
87
+ target_formats=["docx"]
88
+ )
89
+
90
+ # From URL
91
+ result = client.docs.convert_url(
92
+ file_url="https://example.com/document.pdf",
93
+ target_formats=["docx"]
94
+ )
95
+
96
+ # Check supported formats
97
+ formats = client.docs.supported_conversions()
98
+ ```
99
+
100
+ ### Media Conversion
101
+
102
+ ```python
103
+ # Convert image
104
+ result = client.media.convert_image(
105
+ file_path="image.png",
106
+ target_formats=["webp", "jpg"]
107
+ )
108
+
109
+ # Convert video
110
+ result = client.media.convert_video(
111
+ file_path="video.mp4",
112
+ target_formats=["webm"]
113
+ )
114
+
115
+ # Check supported formats
116
+ image_formats = client.media.image_formats()
117
+ video_formats = client.media.video_formats()
118
+ ```
119
+
120
+ ### Context Manager
121
+
122
+ ```python
123
+ with IndoxClient.from_env() as client:
124
+ result = client.docs.convert_file(
125
+ file_path="document.pdf",
126
+ target_formats=["docx"]
127
+ )
128
+ ```
129
+
130
+ ## Configuration
131
+
132
+ | Environment Variable | Description |
133
+ |---------------------|-------------|
134
+ | `INDOX_API_KEY` | Your API key |
135
+ | `INDOX_DOCS_URL` | Docs service URL (default: https://indox.org/docs) |
136
+ | `INDOX_MEDIA_URL` | Media service URL (default: https://indox.org/media) |
137
+
138
+ ## Error Handling
139
+
140
+ ```python
141
+ from indox_client import IndoxClient, IndoxHTTPError, IndoxClientError
142
+
143
+ try:
144
+ result = client.docs.convert_file(
145
+ file_path="document.pdf",
146
+ target_formats=["docx"]
147
+ )
148
+ except IndoxHTTPError as e:
149
+ print(f"HTTP {e.status_code}: {e}")
150
+ print(f"Request ID: {e.request_id}")
151
+ except IndoxClientError as e:
152
+ print(f"Client error: {e}")
153
+ ```
154
+
155
+ ## License
156
+
157
+ Proprietary - see LICENSE for details.
@@ -0,0 +1,16 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/indox_client/__init__.py
5
+ src/indox_client/base.py
6
+ src/indox_client/client.py
7
+ src/indox_client/docs.py
8
+ src/indox_client/exceptions.py
9
+ src/indox_client/media.py
10
+ src/indox_client/version.py
11
+ src/indox_client.egg-info/PKG-INFO
12
+ src/indox_client.egg-info/SOURCES.txt
13
+ src/indox_client.egg-info/dependency_links.txt
14
+ src/indox_client.egg-info/requires.txt
15
+ src/indox_client.egg-info/top_level.txt
16
+ tests/test_client.py
@@ -0,0 +1,9 @@
1
+ requests>=2.28.0
2
+
3
+ [dev]
4
+ pytest>=7.0
5
+ pytest-cov>=4.0
6
+ responses>=0.23.0
7
+ ruff>=0.1.0
8
+ mypy>=1.0
9
+ types-requests>=2.28
@@ -0,0 +1 @@
1
+ indox_client
@@ -0,0 +1,128 @@
1
+ """Unit tests for indox_client."""
2
+
3
+ import pytest
4
+ import responses
5
+ from indox_client import IndoxClient, IndoxClientError, IndoxHTTPError
6
+
7
+
8
+ class TestIndoxClient:
9
+ """Test IndoxClient initialization."""
10
+
11
+ def test_init_requires_api_key(self):
12
+ with pytest.raises(ValueError, match="api_key is required"):
13
+ IndoxClient(api_key="")
14
+
15
+ def test_init_with_api_key(self):
16
+ client = IndoxClient(api_key="test-key")
17
+ assert client.docs.api_key == "test-key"
18
+ assert client.media.api_key == "test-key"
19
+
20
+ def test_context_manager(self):
21
+ with IndoxClient(api_key="test-key") as client:
22
+ assert client.docs is not None
23
+ assert client.media is not None
24
+
25
+
26
+ class TestDocsClient:
27
+ """Test DocsClient methods."""
28
+
29
+ @responses.activate
30
+ def test_supported_conversions(self):
31
+ responses.add(
32
+ responses.GET,
33
+ "https://indox.org/docs/api/v1/docs/formats/",
34
+ json={"formats": ["pdf", "docx"]},
35
+ status=200,
36
+ )
37
+ client = IndoxClient(api_key="test-key")
38
+ result = client.docs.supported_conversions()
39
+ assert result["formats"] == ["pdf", "docx"]
40
+
41
+ @responses.activate
42
+ def test_convert_url(self):
43
+ responses.add(
44
+ responses.POST,
45
+ "https://indox.org/docs/api/v1/docs/convert/json/",
46
+ json={"conversion_id": "abc123", "status": "pending"},
47
+ status=202,
48
+ )
49
+ client = IndoxClient(api_key="test-key")
50
+ result = client.docs.convert_url(
51
+ file_url="https://example.com/doc.pdf",
52
+ target_formats=["docx"],
53
+ )
54
+ assert result["conversion_id"] == "abc123"
55
+
56
+ def test_convert_url_requires_source(self):
57
+ client = IndoxClient(api_key="test-key")
58
+ with pytest.raises(IndoxClientError, match="Provide file_url or s3_key"):
59
+ client.docs.convert_url(target_formats=["docx"])
60
+
61
+ def test_convert_url_requires_formats(self):
62
+ client = IndoxClient(api_key="test-key")
63
+ with pytest.raises(IndoxClientError, match="target_formats is required"):
64
+ client.docs.convert_url(file_url="https://example.com/doc.pdf", target_formats=[])
65
+
66
+
67
+ class TestMediaClient:
68
+ """Test MediaClient methods."""
69
+
70
+ @responses.activate
71
+ def test_image_formats(self):
72
+ responses.add(
73
+ responses.GET,
74
+ "https://indox.org/media/api/v1/image/formats/",
75
+ json={"formats": ["png", "jpg", "webp"]},
76
+ status=200,
77
+ )
78
+ client = IndoxClient(api_key="test-key")
79
+ result = client.media.image_formats()
80
+ assert "png" in result["formats"]
81
+
82
+ @responses.activate
83
+ def test_convert_image_from_url(self):
84
+ responses.add(
85
+ responses.POST,
86
+ "https://indox.org/media/api/v1/image/convert/",
87
+ json={"conversion_id": "img123", "status": "pending"},
88
+ status=202,
89
+ )
90
+ client = IndoxClient(api_key="test-key")
91
+ result = client.media.convert_image(
92
+ file_url="https://example.com/image.png",
93
+ target_formats=["webp"],
94
+ )
95
+ assert result["conversion_id"] == "img123"
96
+
97
+
98
+ class TestHTTPErrors:
99
+ """Test error handling."""
100
+
101
+ @responses.activate
102
+ def test_http_error_raised(self):
103
+ responses.add(
104
+ responses.GET,
105
+ "https://indox.org/docs/api/v1/docs/formats/",
106
+ json={"detail": "Unauthorized"},
107
+ status=401,
108
+ )
109
+ client = IndoxClient(api_key="bad-key")
110
+ with pytest.raises(IndoxHTTPError) as exc_info:
111
+ client.docs.supported_conversions()
112
+ assert exc_info.value.status_code == 401
113
+
114
+ @responses.activate
115
+ def test_http_error_to_dict(self):
116
+ responses.add(
117
+ responses.GET,
118
+ "https://indox.org/docs/api/v1/docs/formats/",
119
+ json={"detail": "Not found"},
120
+ status=404,
121
+ headers={"X-Request-ID": "req-123"},
122
+ )
123
+ client = IndoxClient(api_key="test-key")
124
+ with pytest.raises(IndoxHTTPError) as exc_info:
125
+ client.docs.supported_conversions()
126
+ error_dict = exc_info.value.to_dict()
127
+ assert error_dict["status_code"] == 404
128
+ assert error_dict["request_id"] == "req-123"