reversal-sdk 1.0.1__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,75 @@
1
+ # ── Python ────────────────────────────────────
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+ *.pyd
6
+ *.so
7
+ *.egg
8
+ *.egg-info/
9
+ dist/
10
+ build/
11
+ .eggs/
12
+ *.whl
13
+
14
+ # ── Virtual environments ──────────────────────
15
+ .venv/
16
+ venv/
17
+ env/
18
+ ENV/
19
+ .env/
20
+
21
+ # ── Environment variables ─────────────────────
22
+ .env
23
+ .env.local
24
+ .env.*.local
25
+
26
+ # ── IDE ───────────────────────────────────────
27
+ .vscode/settings.json
28
+ .idea/
29
+ *.swp
30
+ *.swo
31
+ .DS_Store
32
+ Thumbs.db
33
+
34
+ # ── Test / coverage artefacts ─────────────────
35
+ .pytest_cache/
36
+ .coverage
37
+ htmlcov/
38
+ coverage.xml
39
+ *.cover
40
+
41
+ # ── Jupyter ───────────────────────────────────
42
+ .ipynb_checkpoints/
43
+ *.ipynb
44
+
45
+ # ── Misc ──────────────────────────────────────
46
+ *.log
47
+ *.tmp
48
+ *.bak
49
+
50
+ # ── Reversal Engine — runtime data ────────────
51
+ reversal.db
52
+ reversal*.db
53
+ uploads/
54
+
55
+ # ── SDK build artefacts ───────────────────────
56
+ sdk/python/dist/
57
+ sdk/python/build/
58
+ sdk/python/*.egg-info/
59
+ sdk/typescript/dist/
60
+ sdk/typescript/node_modules/
61
+
62
+ # ── Test assets (images, screenshots) ─────────
63
+ tests/*.png
64
+ tests/*.jpg
65
+ tests/*.jpeg
66
+ tests/*.webp
67
+ tests/*.gif
68
+
69
+ # ── Prometheus / metrics ──────────────────────
70
+ *.prom
71
+
72
+ # ── macOS / Linux ─────────────────────────────
73
+ .DS_Store
74
+ Thumbs.db
75
+ *.swp
@@ -0,0 +1,112 @@
1
+ Metadata-Version: 2.4
2
+ Name: reversal-sdk
3
+ Version: 1.0.1
4
+ Summary: Official Python SDK for the Reversal Engine API
5
+ Project-URL: Homepage, https://github.com/Etytabs/REVERSAL
6
+ Project-URL: Repository, https://github.com/Etytabs/REVERSAL
7
+ Project-URL: Issues, https://github.com/Etytabs/REVERSAL/issues
8
+ Project-URL: Changelog, https://github.com/Etytabs/REVERSAL/releases
9
+ License: MIT
10
+ Keywords: agent,ai,html,json,llm,mcp,pdf,reversal
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
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 :: Internet :: WWW/HTTP
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.9
22
+ Requires-Dist: requests>=2.28
23
+ Provides-Extra: dev
24
+ Requires-Dist: pydantic>=2; extra == 'dev'
25
+ Requires-Dist: pytest>=7; extra == 'dev'
26
+ Requires-Dist: responses>=0.25; extra == 'dev'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # Reversal SDK — Python
30
+
31
+ Client Python officiel pour l'API [Reversal Engine](https://github.com/Etytabs/REVERSAL).
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ pip install reversal-sdk
37
+ ```
38
+
39
+ Ou depuis les sources :
40
+
41
+ ```bash
42
+ pip install ./sdk/python
43
+ ```
44
+
45
+ ## Démarrage rapide
46
+
47
+ ```python
48
+ from reversal_sdk import ReversalClient
49
+
50
+ client = ReversalClient(api_key="sk-rev-...")
51
+
52
+ # Analyser une URL
53
+ result = client.reverse("https://example.com")
54
+ print(result["content_type"], result["content"])
55
+
56
+ # Analyser un fichier local (upload automatique)
57
+ result = client.reverse("rapport.pdf")
58
+
59
+ # Mode asynchrone — soumettre un job
60
+ job = client.submit_job("gros-fichier.pdf")
61
+ # … attendre le résultat en polling
62
+ result = client.wait_for_job(job["job_id"])
63
+
64
+ # Ou streamer les événements en temps réel
65
+ for event in client.stream_job(job["job_id"]):
66
+ print(event["event"], event["data"])
67
+
68
+ # Uploader un fichier manuellement
69
+ info = client.upload("document.pdf")
70
+ result = client.reverse(f"file:{info['file_id']}")
71
+
72
+ # Batch (jusqu'à 10 sources)
73
+ results = client.batch(["https://a.com", "https://b.com"])
74
+
75
+ # Infos compte
76
+ me = client.me()
77
+ print(me["plan"], me["usage"], "/", me["quota"])
78
+ ```
79
+
80
+ ## Référence API
81
+
82
+ | Méthode | Description |
83
+ |---|---|
84
+ | `reverse(source, *, async_mode, extra)` | Parse une source, retourne le résultat structuré |
85
+ | `submit_job(source)` | Équivalent à `reverse(..., async_mode=True)` |
86
+ | `wait_for_job(job_id, *, poll_interval, timeout)` | Polling jusqu'à `done` ou `failed` |
87
+ | `get_job(job_id)` | Récupère l'état courant d'un job |
88
+ | `stream_job(job_id)` | Générateur d'événements SSE |
89
+ | `upload(path)` | Upload un fichier local |
90
+ | `batch(sources, *, async_mode)` | Parse plusieurs sources en une requête |
91
+ | `detect(source)` | Détecte le type sans parser |
92
+ | `delete_file(file_id)` | Supprime un fichier uploadé |
93
+ | `me()` | Infos compte (plan, usage, quota) |
94
+
95
+ ## Exceptions
96
+
97
+ ```python
98
+ from reversal_sdk import (
99
+ ReversalError, # base
100
+ AuthError, # 401 / 403
101
+ NotFoundError, # 404
102
+ RateLimitError, # 429
103
+ QuotaError, # 402
104
+ ServerError, # 5xx
105
+ )
106
+ ```
107
+
108
+ ## Variables d'environnement
109
+
110
+ | Variable | Description |
111
+ |---|---|
112
+ | `REVERSAL_API_KEY` | Clé API (alternative à `api_key=`) |
@@ -0,0 +1,84 @@
1
+ # Reversal SDK — Python
2
+
3
+ Client Python officiel pour l'API [Reversal Engine](https://github.com/Etytabs/REVERSAL).
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install reversal-sdk
9
+ ```
10
+
11
+ Ou depuis les sources :
12
+
13
+ ```bash
14
+ pip install ./sdk/python
15
+ ```
16
+
17
+ ## Démarrage rapide
18
+
19
+ ```python
20
+ from reversal_sdk import ReversalClient
21
+
22
+ client = ReversalClient(api_key="sk-rev-...")
23
+
24
+ # Analyser une URL
25
+ result = client.reverse("https://example.com")
26
+ print(result["content_type"], result["content"])
27
+
28
+ # Analyser un fichier local (upload automatique)
29
+ result = client.reverse("rapport.pdf")
30
+
31
+ # Mode asynchrone — soumettre un job
32
+ job = client.submit_job("gros-fichier.pdf")
33
+ # … attendre le résultat en polling
34
+ result = client.wait_for_job(job["job_id"])
35
+
36
+ # Ou streamer les événements en temps réel
37
+ for event in client.stream_job(job["job_id"]):
38
+ print(event["event"], event["data"])
39
+
40
+ # Uploader un fichier manuellement
41
+ info = client.upload("document.pdf")
42
+ result = client.reverse(f"file:{info['file_id']}")
43
+
44
+ # Batch (jusqu'à 10 sources)
45
+ results = client.batch(["https://a.com", "https://b.com"])
46
+
47
+ # Infos compte
48
+ me = client.me()
49
+ print(me["plan"], me["usage"], "/", me["quota"])
50
+ ```
51
+
52
+ ## Référence API
53
+
54
+ | Méthode | Description |
55
+ |---|---|
56
+ | `reverse(source, *, async_mode, extra)` | Parse une source, retourne le résultat structuré |
57
+ | `submit_job(source)` | Équivalent à `reverse(..., async_mode=True)` |
58
+ | `wait_for_job(job_id, *, poll_interval, timeout)` | Polling jusqu'à `done` ou `failed` |
59
+ | `get_job(job_id)` | Récupère l'état courant d'un job |
60
+ | `stream_job(job_id)` | Générateur d'événements SSE |
61
+ | `upload(path)` | Upload un fichier local |
62
+ | `batch(sources, *, async_mode)` | Parse plusieurs sources en une requête |
63
+ | `detect(source)` | Détecte le type sans parser |
64
+ | `delete_file(file_id)` | Supprime un fichier uploadé |
65
+ | `me()` | Infos compte (plan, usage, quota) |
66
+
67
+ ## Exceptions
68
+
69
+ ```python
70
+ from reversal_sdk import (
71
+ ReversalError, # base
72
+ AuthError, # 401 / 403
73
+ NotFoundError, # 404
74
+ RateLimitError, # 429
75
+ QuotaError, # 402
76
+ ServerError, # 5xx
77
+ )
78
+ ```
79
+
80
+ ## Variables d'environnement
81
+
82
+ | Variable | Description |
83
+ |---|---|
84
+ | `REVERSAL_API_KEY` | Clé API (alternative à `api_key=`) |
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "reversal-sdk"
7
+ version = "1.0.1"
8
+ description = "Official Python SDK for the Reversal Engine API"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.9"
12
+ keywords = ["ai", "llm", "mcp", "pdf", "html", "json", "agent", "reversal"]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.9",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Topic :: Software Development :: Libraries :: Python Modules",
23
+ "Topic :: Internet :: WWW/HTTP",
24
+ ]
25
+ dependencies = [
26
+ "requests>=2.28",
27
+ ]
28
+
29
+ [project.optional-dependencies]
30
+ dev = [
31
+ "pytest>=7",
32
+ "responses>=0.25",
33
+ "pydantic>=2",
34
+ ]
35
+
36
+ [project.urls]
37
+ Homepage = "https://github.com/Etytabs/REVERSAL"
38
+ Repository = "https://github.com/Etytabs/REVERSAL"
39
+ Issues = "https://github.com/Etytabs/REVERSAL/issues"
40
+ Changelog = "https://github.com/Etytabs/REVERSAL/releases"
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["reversal_sdk"]
@@ -0,0 +1,30 @@
1
+ """Reversal SDK — Python client package."""
2
+
3
+ from .client import ReversalClient
4
+ from .exceptions import (
5
+ AuthError,
6
+ NotFoundError,
7
+ QuotaError,
8
+ RateLimitError,
9
+ ReversalError,
10
+ ServerError,
11
+ )
12
+ from .models import BatchResult, FileUpload, JobInfo, ReverseResult, SSEEvent, UserInfo
13
+
14
+ __all__ = [
15
+ "ReversalClient",
16
+ # exceptions
17
+ "ReversalError",
18
+ "AuthError",
19
+ "NotFoundError",
20
+ "RateLimitError",
21
+ "QuotaError",
22
+ "ServerError",
23
+ # models
24
+ "JobInfo",
25
+ "ReverseResult",
26
+ "FileUpload",
27
+ "BatchResult",
28
+ "SSEEvent",
29
+ "UserInfo",
30
+ ]
@@ -0,0 +1,259 @@
1
+ """
2
+ Reversal SDK — Python client.
3
+
4
+ Usage::
5
+
6
+ from reversal_sdk import ReversalClient
7
+
8
+ client = ReversalClient(api_key="sk-rev-...")
9
+ result = client.reverse("https://example.com")
10
+ result = client.reverse("path/to/file.pdf", async_mode=True)
11
+ job = client.submit_job("path/to/big.pdf")
12
+ result = client.wait_for_job(job["job_id"])
13
+ fid = client.upload("path/to/file.pdf")
14
+ client.batch(["https://a.com", "https://b.com"])
15
+ for event in client.stream_job(job["job_id"]):
16
+ print(event)
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ import time
24
+ from pathlib import Path
25
+ from typing import Any, Generator, Iterator
26
+ from urllib.parse import urljoin
27
+
28
+ from .exceptions import AuthError, NotFoundError, QuotaError, RateLimitError, ReversalError, ServerError
29
+
30
+ try:
31
+ import requests as _requests
32
+ _REQUESTS_OK = True
33
+ except ImportError: # pragma: no cover
34
+ _REQUESTS_OK = False
35
+
36
+ _DEFAULT_BASE_URL = "https://api.reversal.dev/v1"
37
+ _DEFAULT_TIMEOUT = 30
38
+ _DEFAULT_POLL_INTERVAL = 2.0
39
+ _DEFAULT_POLL_MAX_WAIT = 300.0
40
+
41
+
42
+ class ReversalClient:
43
+ """Synchronous HTTP client for the Reversal Engine API.
44
+
45
+ Parameters
46
+ ----------
47
+ api_key:
48
+ Your Reversal API key (``sk-rev-...``). Falls back to the
49
+ ``REVERSAL_API_KEY`` environment variable when omitted.
50
+ base_url:
51
+ Base URL of the API server (default: ``https://api.reversal.dev/v1``).
52
+ Override for self-hosted deployments.
53
+ timeout:
54
+ Default HTTP timeout in seconds (default: ``30``).
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ api_key: str | None = None,
60
+ base_url: str = _DEFAULT_BASE_URL,
61
+ timeout: int | float = _DEFAULT_TIMEOUT,
62
+ ) -> None:
63
+ if not _REQUESTS_OK: # pragma: no cover
64
+ raise ImportError("The 'requests' package is required. Run: pip install requests")
65
+
66
+ self._api_key = api_key or os.environ.get("REVERSAL_API_KEY", "")
67
+ if not self._api_key:
68
+ raise AuthError("No API key provided. Pass api_key= or set REVERSAL_API_KEY.")
69
+
70
+ self._base_url = base_url.rstrip("/")
71
+ self._timeout = timeout
72
+ self._session = _requests.Session()
73
+ self._session.headers.update({"Authorization": f"Bearer {self._api_key}"})
74
+
75
+ # ------------------------------------------------------------------
76
+ # Public API
77
+ # ------------------------------------------------------------------
78
+
79
+ def reverse(
80
+ self,
81
+ source: str,
82
+ *,
83
+ async_mode: bool = False,
84
+ extra: dict[str, Any] | None = None,
85
+ ) -> dict[str, Any]:
86
+ """Parse *source* (URL, ``file:<file_id>``, or local path) and return
87
+ the structured result.
88
+
89
+ When *async_mode* is ``True`` the server queues the job and this
90
+ method returns the raw job dict; use :meth:`wait_for_job` to poll.
91
+ """
92
+ payload: dict[str, Any] = {"source": self._resolve_source(source), "async_mode": async_mode}
93
+ if extra:
94
+ payload.update(extra)
95
+ return self._post("/reverse", json=payload)
96
+
97
+ def submit_job(self, source: str, **extra: Any) -> dict[str, Any]:
98
+ """Queue a heavy job and return immediately with job metadata.
99
+
100
+ Equivalent to ``reverse(source, async_mode=True)``.
101
+ """
102
+ return self.reverse(source, async_mode=True, extra=extra or None)
103
+
104
+ def wait_for_job(
105
+ self,
106
+ job_id: str,
107
+ *,
108
+ poll_interval: float = _DEFAULT_POLL_INTERVAL,
109
+ timeout: float = _DEFAULT_POLL_MAX_WAIT,
110
+ ) -> dict[str, Any]:
111
+ """Poll ``GET /jobs/{job_id}`` until *done* or *failed*, then return.
112
+
113
+ Raises :class:`~reversal_sdk.exceptions.ReversalError` on timeout or
114
+ if the job fails.
115
+ """
116
+ deadline = time.monotonic() + timeout
117
+ while True:
118
+ job = self.get_job(job_id)
119
+ status = job.get("status", "")
120
+ if status == "done":
121
+ return job
122
+ if status == "failed":
123
+ raise ReversalError(
124
+ f"Job {job_id} failed: {job.get('error', 'unknown error')}",
125
+ response=job,
126
+ )
127
+ if time.monotonic() > deadline:
128
+ raise ReversalError(f"Timeout waiting for job {job_id} after {timeout}s")
129
+ time.sleep(poll_interval)
130
+
131
+ def get_job(self, job_id: str) -> dict[str, Any]:
132
+ """Fetch the current state of a job."""
133
+ return self._get(f"/jobs/{job_id}")
134
+
135
+ def stream_job(self, job_id: str) -> Iterator[dict[str, Any]]:
136
+ """Open an SSE stream for *job_id* and yield parsed event dicts.
137
+
138
+ Each yielded dict has ``event`` (str) and ``data`` (dict) keys.
139
+
140
+ Example::
141
+
142
+ for event in client.stream_job(job_id):
143
+ print(event["event"], event["data"]["status"])
144
+ """
145
+ url = self._url(f"/jobs/{job_id}/stream")
146
+ with self._session.get(url, stream=True, timeout=self._timeout,
147
+ headers={"Accept": "text/event-stream"}) as resp:
148
+ self._raise_for_status(resp)
149
+ yield from _parse_sse_stream(resp.iter_lines(decode_unicode=True))
150
+
151
+ def upload(self, path: str | Path) -> dict[str, Any]:
152
+ """Upload a local file and return ``{"file_id": ..., "filename": ..., "size": ...}``."""
153
+ path = Path(path)
154
+ with path.open("rb") as fh:
155
+ return self._post("/upload", files={"file": (path.name, fh, "application/octet-stream")})
156
+
157
+ def batch(
158
+ self,
159
+ sources: list[str],
160
+ *,
161
+ async_mode: bool = False,
162
+ ) -> dict[str, Any]:
163
+ """Parse up to 10 *sources* in one request and return aggregated results."""
164
+ resolved = [self._resolve_source(s) for s in sources]
165
+ return self._post("/batch", json={"sources": resolved, "async_mode": async_mode})
166
+
167
+ def detect(self, source: str) -> dict[str, Any]:
168
+ """Detect the content type of *source* without full parsing."""
169
+ return self._post("/detect", json={"source": self._resolve_source(source)})
170
+
171
+ def delete_file(self, file_id: str) -> dict[str, Any]:
172
+ """Delete a previously uploaded file."""
173
+ return self._delete(f"/files/{file_id}")
174
+
175
+ def me(self) -> dict[str, Any]:
176
+ """Return the current user's account information (plan, usage, quota)."""
177
+ return self._get("/me")
178
+
179
+ # ------------------------------------------------------------------
180
+ # Internal helpers
181
+ # ------------------------------------------------------------------
182
+
183
+ def _url(self, path: str) -> str:
184
+ return f"{self._base_url}{path}"
185
+
186
+ def _resolve_source(self, source: str) -> str:
187
+ """If *source* is an existing local path, upload it first."""
188
+ p = Path(source)
189
+ if p.exists() and p.is_file():
190
+ result = self.upload(p)
191
+ return f"file:{result['file_id']}"
192
+ return source
193
+
194
+ def _get(self, path: str, **kwargs: Any) -> dict[str, Any]:
195
+ resp = self._session.get(self._url(path), timeout=self._timeout, **kwargs)
196
+ self._raise_for_status(resp)
197
+ return resp.json()
198
+
199
+ def _post(self, path: str, **kwargs: Any) -> dict[str, Any]:
200
+ resp = self._session.post(self._url(path), timeout=self._timeout, **kwargs)
201
+ self._raise_for_status(resp)
202
+ return resp.json()
203
+
204
+ def _delete(self, path: str, **kwargs: Any) -> dict[str, Any]:
205
+ resp = self._session.delete(self._url(path), timeout=self._timeout, **kwargs)
206
+ self._raise_for_status(resp)
207
+ return resp.json()
208
+
209
+ @staticmethod
210
+ def _raise_for_status(resp: Any) -> None: # noqa: ANN401
211
+ if resp.status_code < 400:
212
+ return
213
+ body: dict[str, Any] = {}
214
+ try:
215
+ body = resp.json()
216
+ except Exception:
217
+ pass
218
+ detail = body.get("detail", resp.text or "Unknown error")
219
+ code = resp.status_code
220
+ if code in (401, 403):
221
+ raise AuthError(detail, status_code=code, response=body)
222
+ if code == 404:
223
+ raise NotFoundError(detail, status_code=code, response=body)
224
+ if code == 402:
225
+ raise QuotaError(detail, status_code=code, response=body)
226
+ if code == 429:
227
+ raise RateLimitError(detail, status_code=code, response=body)
228
+ if code >= 500:
229
+ raise ServerError(detail, status_code=code, response=body)
230
+ raise ReversalError(detail, status_code=code, response=body)
231
+
232
+
233
+ # ---------------------------------------------------------------------------
234
+ # SSE parser (minimal, no external dependency)
235
+ # ---------------------------------------------------------------------------
236
+
237
+ def _parse_sse_stream(lines: Iterator[str]) -> Generator[dict[str, Any], None, None]:
238
+ """Parse a stream of SSE text lines into event dicts."""
239
+ event_name = "message"
240
+ data_buf: list[str] = []
241
+
242
+ for line in lines:
243
+ if not line:
244
+ # blank line = dispatch event
245
+ if data_buf:
246
+ raw = "\n".join(data_buf)
247
+ try:
248
+ parsed = json.loads(raw)
249
+ except json.JSONDecodeError:
250
+ parsed = raw # type: ignore[assignment]
251
+ yield {"event": event_name, "data": parsed}
252
+ event_name = "message"
253
+ data_buf = []
254
+ continue
255
+
256
+ if line.startswith("event:"):
257
+ event_name = line[len("event:"):].strip()
258
+ elif line.startswith("data:"):
259
+ data_buf.append(line[len("data:"):].strip())
@@ -0,0 +1,32 @@
1
+ """Exceptions raised by the Reversal SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class ReversalError(Exception):
7
+ """Base exception for all SDK errors."""
8
+
9
+ def __init__(self, message: str, status_code: int | None = None, response: dict | None = None) -> None:
10
+ super().__init__(message)
11
+ self.status_code = status_code
12
+ self.response = response or {}
13
+
14
+
15
+ class AuthError(ReversalError):
16
+ """Raised when the API key is missing, invalid, or expired (HTTP 401/403)."""
17
+
18
+
19
+ class NotFoundError(ReversalError):
20
+ """Raised when a resource is not found (HTTP 404)."""
21
+
22
+
23
+ class RateLimitError(ReversalError):
24
+ """Raised when the rate limit for the current plan is exceeded (HTTP 429)."""
25
+
26
+
27
+ class QuotaError(ReversalError):
28
+ """Raised when the monthly quota is exhausted (HTTP 402)."""
29
+
30
+
31
+ class ServerError(ReversalError):
32
+ """Raised for unexpected server-side errors (HTTP 5xx)."""
@@ -0,0 +1,58 @@
1
+ """Pydantic response models for the Reversal SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ try:
8
+ from pydantic import BaseModel, Field
9
+ _PYDANTIC = True
10
+ except ImportError: # pragma: no cover
11
+ _PYDANTIC = False
12
+
13
+
14
+ if _PYDANTIC:
15
+ class JobInfo(BaseModel):
16
+ job_id: str
17
+ status: str # "pending" | "running" | "done" | "failed"
18
+ result: dict[str, Any] | None = None
19
+ error: str | None = None
20
+ content_type: str | None = None
21
+ created_at: str | None = None
22
+ updated_at: str | None = None
23
+
24
+ class ReverseResult(BaseModel):
25
+ content_type: str
26
+ content: Any
27
+ metadata: dict[str, Any] = Field(default_factory=dict)
28
+
29
+ class FileUpload(BaseModel):
30
+ file_id: str
31
+ filename: str
32
+ size: int
33
+
34
+ class BatchResult(BaseModel):
35
+ results: list[dict[str, Any]]
36
+
37
+ class SSEEvent(BaseModel):
38
+ event: str # "status" | "result" | "error" | "timeout"
39
+ data: dict[str, Any]
40
+
41
+ class UserInfo(BaseModel):
42
+ user_id: int
43
+ email: str
44
+ plan: str
45
+ usage: int
46
+ quota: int
47
+
48
+ else: # pragma: no cover — fallback when pydantic is not installed
49
+ class _Namespace:
50
+ def __init__(self, **kwargs: Any) -> None:
51
+ for k, v in kwargs.items():
52
+ setattr(self, k, v)
53
+
54
+ def __repr__(self) -> str:
55
+ attrs = ", ".join(f"{k}={v!r}" for k, v in self.__dict__.items())
56
+ return f"{self.__class__.__name__}({attrs})"
57
+
58
+ JobInfo = ReverseResult = FileUpload = BatchResult = SSEEvent = UserInfo = _Namespace # type: ignore[misc,assignment]
@@ -0,0 +1,225 @@
1
+ """Tests for the Python Reversal SDK client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import pytest
7
+
8
+ try:
9
+ import responses as resp_mock
10
+ _RESPONSES_OK = True
11
+ except ImportError:
12
+ _RESPONSES_OK = False
13
+
14
+ from reversal_sdk import ReversalClient
15
+ from reversal_sdk.exceptions import (
16
+ AuthError,
17
+ NotFoundError,
18
+ QuotaError,
19
+ RateLimitError,
20
+ ReversalError,
21
+ ServerError,
22
+ )
23
+
24
+ pytestmark = pytest.mark.skipif(not _RESPONSES_OK, reason="'responses' package required")
25
+
26
+ BASE = "https://api.reversal.dev/v1"
27
+
28
+
29
+ @pytest.fixture
30
+ def client():
31
+ return ReversalClient(api_key="sk-rev-test", base_url=BASE)
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Helper
36
+ # ---------------------------------------------------------------------------
37
+
38
+ def _add(method, path, body, status=200):
39
+ resp_mock.add(method, f"{BASE}{path}", json=body, status=status)
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # No API key
44
+ # ---------------------------------------------------------------------------
45
+
46
+ def test_no_api_key_raises():
47
+ import os
48
+ old = os.environ.pop("REVERSAL_API_KEY", None)
49
+ try:
50
+ with pytest.raises(AuthError):
51
+ ReversalClient(api_key="")
52
+ finally:
53
+ if old is not None:
54
+ os.environ["REVERSAL_API_KEY"] = old
55
+
56
+
57
+ def test_env_api_key(monkeypatch):
58
+ monkeypatch.setenv("REVERSAL_API_KEY", "sk-rev-env")
59
+ c = ReversalClient()
60
+ assert c._api_key == "sk-rev-env"
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # reverse
65
+ # ---------------------------------------------------------------------------
66
+
67
+ @resp_mock.activate
68
+ def test_reverse_url(client):
69
+ _add(resp_mock.POST, "/reverse", {"content_type": "webpage", "content": "hello", "metadata": {}})
70
+ result = client.reverse("https://example.com")
71
+ assert result["content_type"] == "webpage"
72
+ assert resp_mock.calls[0].request.headers["Authorization"] == "Bearer sk-rev-test"
73
+
74
+
75
+ @resp_mock.activate
76
+ def test_reverse_async_mode(client):
77
+ _add(resp_mock.POST, "/reverse", {"job_id": "job-1", "status": "pending"})
78
+ result = client.reverse("https://example.com", async_mode=True)
79
+ assert result["job_id"] == "job-1"
80
+ sent = json.loads(resp_mock.calls[0].request.body)
81
+ assert sent["async_mode"] is True
82
+
83
+
84
+ @resp_mock.activate
85
+ def test_reverse_local_file_uploads_first(client, tmp_path):
86
+ f = tmp_path / "doc.pdf"
87
+ f.write_bytes(b"%PDF fake")
88
+ _add(resp_mock.POST, "/upload", {"file_id": "fid-42", "filename": "doc.pdf", "size": 9})
89
+ _add(resp_mock.POST, "/reverse", {"content_type": "pdf", "content": {}, "metadata": {}})
90
+ result = client.reverse(str(f))
91
+ assert result["content_type"] == "pdf"
92
+ sent = json.loads(resp_mock.calls[1].request.body)
93
+ assert sent["source"] == "file:fid-42"
94
+
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # upload
98
+ # ---------------------------------------------------------------------------
99
+
100
+ @resp_mock.activate
101
+ def test_upload(client, tmp_path):
102
+ f = tmp_path / "test.png"
103
+ f.write_bytes(b"\x89PNG")
104
+ _add(resp_mock.POST, "/upload", {"file_id": "fid-99", "filename": "test.png", "size": 4})
105
+ result = client.upload(f)
106
+ assert result["file_id"] == "fid-99"
107
+
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # batch
111
+ # ---------------------------------------------------------------------------
112
+
113
+ @resp_mock.activate
114
+ def test_batch(client):
115
+ _add(resp_mock.POST, "/batch", {"results": [{"content_type": "url"}, {"content_type": "url"}]})
116
+ result = client.batch(["https://a.com", "https://b.com"])
117
+ assert len(result["results"]) == 2
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # get_job / wait_for_job
122
+ # ---------------------------------------------------------------------------
123
+
124
+ @resp_mock.activate
125
+ def test_get_job(client):
126
+ _add(resp_mock.GET, "/jobs/job-1", {"job_id": "job-1", "status": "running"})
127
+ job = client.get_job("job-1")
128
+ assert job["status"] == "running"
129
+
130
+
131
+ @resp_mock.activate
132
+ def test_wait_for_job_done(client):
133
+ _add(resp_mock.GET, "/jobs/job-1", {"job_id": "job-1", "status": "done", "result": {"x": 1}})
134
+ job = client.wait_for_job("job-1", poll_interval=0)
135
+ assert job["result"] == {"x": 1}
136
+
137
+
138
+ @resp_mock.activate
139
+ def test_wait_for_job_failed(client):
140
+ _add(resp_mock.GET, "/jobs/job-1", {"job_id": "job-1", "status": "failed", "error": "boom"})
141
+ with pytest.raises(ReversalError, match="boom"):
142
+ client.wait_for_job("job-1", poll_interval=0)
143
+
144
+
145
+ @resp_mock.activate
146
+ def test_wait_for_job_timeout(client):
147
+ _add(resp_mock.GET, "/jobs/job-1", {"job_id": "job-1", "status": "pending"})
148
+ with pytest.raises(ReversalError, match="Timeout"):
149
+ client.wait_for_job("job-1", poll_interval=0, timeout=0)
150
+
151
+
152
+ # ---------------------------------------------------------------------------
153
+ # delete_file
154
+ # ---------------------------------------------------------------------------
155
+
156
+ @resp_mock.activate
157
+ def test_delete_file(client):
158
+ _add(resp_mock.DELETE, "/files/fid-1", {"deleted": True})
159
+ result = client.delete_file("fid-1")
160
+ assert result["deleted"] is True
161
+
162
+
163
+ # ---------------------------------------------------------------------------
164
+ # me / detect
165
+ # ---------------------------------------------------------------------------
166
+
167
+ @resp_mock.activate
168
+ def test_me(client):
169
+ _add(resp_mock.GET, "/me", {"user_id": 1, "email": "a@b.com", "plan": "pro", "usage": 10, "quota": 600})
170
+ info = client.me()
171
+ assert info["plan"] == "pro"
172
+
173
+
174
+ @resp_mock.activate
175
+ def test_detect(client):
176
+ _add(resp_mock.POST, "/detect", {"content_type": "pdf"})
177
+ r = client.detect("https://example.com/file.pdf")
178
+ assert r["content_type"] == "pdf"
179
+
180
+
181
+ # ---------------------------------------------------------------------------
182
+ # Error mapping
183
+ # ---------------------------------------------------------------------------
184
+
185
+ @pytest.mark.parametrize("code,exc", [
186
+ (401, AuthError),
187
+ (403, AuthError),
188
+ (404, NotFoundError),
189
+ (402, QuotaError),
190
+ (429, RateLimitError),
191
+ (500, ServerError),
192
+ (400, ReversalError),
193
+ ])
194
+ @resp_mock.activate
195
+ def test_error_mapping(client, code, exc):
196
+ _add(resp_mock.GET, "/me", {"detail": "error"}, status=code)
197
+ with pytest.raises(exc):
198
+ client.me()
199
+
200
+
201
+ # ---------------------------------------------------------------------------
202
+ # SSE parser
203
+ # ---------------------------------------------------------------------------
204
+
205
+ def test_parse_sse_stream():
206
+ from reversal_sdk.client import _parse_sse_stream
207
+ lines = iter([
208
+ "event: status",
209
+ 'data: {"status": "running"}',
210
+ "",
211
+ "event: result",
212
+ 'data: {"status": "done", "result": {}}',
213
+ "",
214
+ ])
215
+ events = list(_parse_sse_stream(lines))
216
+ assert events[0] == {"event": "status", "data": {"status": "running"}}
217
+ assert events[1] == {"event": "result", "data": {"status": "done", "result": {}}}
218
+
219
+
220
+ def test_parse_sse_stream_no_event():
221
+ from reversal_sdk.client import _parse_sse_stream
222
+ lines = iter(['data: {"x": 1}', ""])
223
+ events = list(_parse_sse_stream(lines))
224
+ assert events[0]["event"] == "message"
225
+ assert events[0]["data"] == {"x": 1}