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.
- reversal_sdk-1.0.1/.gitignore +75 -0
- reversal_sdk-1.0.1/PKG-INFO +112 -0
- reversal_sdk-1.0.1/README.md +84 -0
- reversal_sdk-1.0.1/pyproject.toml +43 -0
- reversal_sdk-1.0.1/reversal_sdk/__init__.py +30 -0
- reversal_sdk-1.0.1/reversal_sdk/client.py +259 -0
- reversal_sdk-1.0.1/reversal_sdk/exceptions.py +32 -0
- reversal_sdk-1.0.1/reversal_sdk/models.py +58 -0
- reversal_sdk-1.0.1/tests/test_client.py +225 -0
|
@@ -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}
|