doconda 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- doconda-0.1.0/.gitignore +65 -0
- doconda-0.1.0/LICENSE +21 -0
- doconda-0.1.0/PKG-INFO +83 -0
- doconda-0.1.0/README.md +66 -0
- doconda-0.1.0/package.json +10 -0
- doconda-0.1.0/pyproject.toml +37 -0
- doconda-0.1.0/src/doconda/__init__.py +6 -0
- doconda-0.1.0/src/doconda/_client.py +320 -0
- doconda-0.1.0/src/doconda/_tools.py +283 -0
- doconda-0.1.0/src/doconda/py.typed +0 -0
- doconda-0.1.0/tests/test_client.py +192 -0
- doconda-0.1.0/uv.lock +323 -0
doconda-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Logs
|
|
2
|
+
logs
|
|
3
|
+
*.log
|
|
4
|
+
npm-debug.log*
|
|
5
|
+
yarn-debug.log*
|
|
6
|
+
yarn-error.log*
|
|
7
|
+
.pnpm-debug.log*
|
|
8
|
+
lerna-debug.log*
|
|
9
|
+
|
|
10
|
+
node_modules
|
|
11
|
+
dist
|
|
12
|
+
dist-ssr
|
|
13
|
+
*.local
|
|
14
|
+
|
|
15
|
+
# env files (can opt-in for committing if needed)
|
|
16
|
+
.env*
|
|
17
|
+
!.env.example
|
|
18
|
+
|
|
19
|
+
# turbo
|
|
20
|
+
.turbo
|
|
21
|
+
|
|
22
|
+
# Editor directories and files
|
|
23
|
+
.vscode/*
|
|
24
|
+
!.vscode/extensions.json
|
|
25
|
+
.idea
|
|
26
|
+
.DS_Store
|
|
27
|
+
*.suo
|
|
28
|
+
*.ntvs*
|
|
29
|
+
*.njsproj
|
|
30
|
+
*.sln
|
|
31
|
+
*.sw?
|
|
32
|
+
|
|
33
|
+
# typescript
|
|
34
|
+
*.tsbuildinfo
|
|
35
|
+
|
|
36
|
+
# python
|
|
37
|
+
.venv
|
|
38
|
+
__pycache__
|
|
39
|
+
.pytest_cache
|
|
40
|
+
.ruff_cache
|
|
41
|
+
|
|
42
|
+
# next
|
|
43
|
+
.next
|
|
44
|
+
out
|
|
45
|
+
next-env.d.ts
|
|
46
|
+
|
|
47
|
+
# ===== Infra =====
|
|
48
|
+
infra/**/.terraform/
|
|
49
|
+
*.tfstate
|
|
50
|
+
*.tfstate.*
|
|
51
|
+
sensitive.auto.tfvars.json
|
|
52
|
+
ci.auto.tfvars.json
|
|
53
|
+
backend.hcl
|
|
54
|
+
|
|
55
|
+
# SlidesCarnival originals (large): see engine/themes/README.md
|
|
56
|
+
engine/themes/sources/
|
|
57
|
+
|
|
58
|
+
# Local template library (large, not in git)
|
|
59
|
+
/library/
|
|
60
|
+
infra/**/.build/
|
|
61
|
+
evals/.cache/
|
|
62
|
+
evals/runs/
|
|
63
|
+
evals/.venv/
|
|
64
|
+
evals/.env
|
|
65
|
+
evals/manual/
|
doconda-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Doconda
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
doconda-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: doconda
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for Doconda: generate, review and edit Word, PowerPoint, Excel and PDF documents from AI agents (Amazon Bedrock, Vertex AI, OpenAI, Claude, LangChain).
|
|
5
|
+
Project-URL: Homepage, https://doconda.com
|
|
6
|
+
Project-URL: Documentation, https://docs.doconda.com/en/sdk/python
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: ai-agents,anthropic,bedrock,document-generation,docx,excel,langchain,llm,mcp,openai,pdf,powerpoint,pptx,tools,vertex-ai,word,xlsx
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Office/Business
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Requires-Dist: httpx>=0.27
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# doconda
|
|
19
|
+
|
|
20
|
+
Generate, review and edit **Word (DOCX), PowerPoint (PPTX), Excel (XLSX) and PDF** documents from your AI agent or chatbot, with one call. Editable files with real Word styles, reviewed before they are delivered.
|
|
21
|
+
|
|
22
|
+
Use it when your agent has to hand the user a document and you don't want to maintain python-docx, python-pptx, openpyxl or templates. Works with Amazon Bedrock, Vertex AI, OpenAI, Claude, Gemini, LangChain, CrewAI and any model with tool calling.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install doconda
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from doconda import Doconda
|
|
30
|
+
|
|
31
|
+
doconda = Doconda() # reads DOCONDA_API_KEY
|
|
32
|
+
doc = doconda.documents.create(format="docx", prompt="Report on the housing law")
|
|
33
|
+
print(doc["outputs"]) # links to the Word file and the PDF
|
|
34
|
+
|
|
35
|
+
open("report.docx", "wb").write(doconda.documents.download(doc["id"], "docx"))
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## As tools for your agent
|
|
39
|
+
|
|
40
|
+
`doconda.tools()` returns ready-made tools (name, description, JSON Schema, `run`) to create, review and edit documents. With Amazon Bedrock (Converse API):
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import json
|
|
44
|
+
import boto3
|
|
45
|
+
from doconda import Doconda
|
|
46
|
+
|
|
47
|
+
bedrock = boto3.client("bedrock-runtime")
|
|
48
|
+
tools = Doconda().tools()
|
|
49
|
+
tool_config = {
|
|
50
|
+
"tools": [
|
|
51
|
+
{"toolSpec": {"name": t.name, "description": t.description, "inputSchema": {"json": t.input_schema}}}
|
|
52
|
+
for t in tools
|
|
53
|
+
]
|
|
54
|
+
}
|
|
55
|
+
messages = [{"role": "user", "content": [{"text": "Make an Excel with the event budget"}]}]
|
|
56
|
+
|
|
57
|
+
while True:
|
|
58
|
+
res = bedrock.converse(modelId=MODEL_ID, messages=messages, toolConfig=tool_config)
|
|
59
|
+
message = res["output"]["message"]
|
|
60
|
+
messages.append(message)
|
|
61
|
+
if res["stopReason"] != "tool_use":
|
|
62
|
+
break
|
|
63
|
+
results = []
|
|
64
|
+
for block in message["content"]:
|
|
65
|
+
if "toolUse" in block:
|
|
66
|
+
use = block["toolUse"]
|
|
67
|
+
out = next(t for t in tools if t.name == use["name"]).run(use["input"])
|
|
68
|
+
results.append(
|
|
69
|
+
{"toolResult": {"toolUseId": use["toolUseId"], "content": [{"text": json.dumps(out)}]}}
|
|
70
|
+
)
|
|
71
|
+
messages.append({"role": "user", "content": results})
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The same tools plug into Vertex AI / Gemini, OpenAI, Claude, LangChain or CrewAI: see the guides.
|
|
75
|
+
|
|
76
|
+
## What else
|
|
77
|
+
|
|
78
|
+
- `documents.create(operation="review", file=…)` and `operation="edit"`: review and fix, or change, an existing Word, PowerPoint, Excel or PDF.
|
|
79
|
+
- `documents.stream(…)`: the document's events as they happen.
|
|
80
|
+
- `extract(file)`: any file to Markdown for your model (free).
|
|
81
|
+
- `files.upload(path_or_bytes)`, `webhooks.create(url)` and `verify_webhook(body, headers, secret)`.
|
|
82
|
+
|
|
83
|
+
Docs: https://docs.doconda.com/en/sdk/python · Guides per stack: https://docs.doconda.com/en/agents/introduction · Get an API key: https://doconda.com
|
doconda-0.1.0/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# doconda
|
|
2
|
+
|
|
3
|
+
Generate, review and edit **Word (DOCX), PowerPoint (PPTX), Excel (XLSX) and PDF** documents from your AI agent or chatbot, with one call. Editable files with real Word styles, reviewed before they are delivered.
|
|
4
|
+
|
|
5
|
+
Use it when your agent has to hand the user a document and you don't want to maintain python-docx, python-pptx, openpyxl or templates. Works with Amazon Bedrock, Vertex AI, OpenAI, Claude, Gemini, LangChain, CrewAI and any model with tool calling.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install doconda
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from doconda import Doconda
|
|
13
|
+
|
|
14
|
+
doconda = Doconda() # reads DOCONDA_API_KEY
|
|
15
|
+
doc = doconda.documents.create(format="docx", prompt="Report on the housing law")
|
|
16
|
+
print(doc["outputs"]) # links to the Word file and the PDF
|
|
17
|
+
|
|
18
|
+
open("report.docx", "wb").write(doconda.documents.download(doc["id"], "docx"))
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## As tools for your agent
|
|
22
|
+
|
|
23
|
+
`doconda.tools()` returns ready-made tools (name, description, JSON Schema, `run`) to create, review and edit documents. With Amazon Bedrock (Converse API):
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
import json
|
|
27
|
+
import boto3
|
|
28
|
+
from doconda import Doconda
|
|
29
|
+
|
|
30
|
+
bedrock = boto3.client("bedrock-runtime")
|
|
31
|
+
tools = Doconda().tools()
|
|
32
|
+
tool_config = {
|
|
33
|
+
"tools": [
|
|
34
|
+
{"toolSpec": {"name": t.name, "description": t.description, "inputSchema": {"json": t.input_schema}}}
|
|
35
|
+
for t in tools
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
messages = [{"role": "user", "content": [{"text": "Make an Excel with the event budget"}]}]
|
|
39
|
+
|
|
40
|
+
while True:
|
|
41
|
+
res = bedrock.converse(modelId=MODEL_ID, messages=messages, toolConfig=tool_config)
|
|
42
|
+
message = res["output"]["message"]
|
|
43
|
+
messages.append(message)
|
|
44
|
+
if res["stopReason"] != "tool_use":
|
|
45
|
+
break
|
|
46
|
+
results = []
|
|
47
|
+
for block in message["content"]:
|
|
48
|
+
if "toolUse" in block:
|
|
49
|
+
use = block["toolUse"]
|
|
50
|
+
out = next(t for t in tools if t.name == use["name"]).run(use["input"])
|
|
51
|
+
results.append(
|
|
52
|
+
{"toolResult": {"toolUseId": use["toolUseId"], "content": [{"text": json.dumps(out)}]}}
|
|
53
|
+
)
|
|
54
|
+
messages.append({"role": "user", "content": results})
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The same tools plug into Vertex AI / Gemini, OpenAI, Claude, LangChain or CrewAI: see the guides.
|
|
58
|
+
|
|
59
|
+
## What else
|
|
60
|
+
|
|
61
|
+
- `documents.create(operation="review", file=…)` and `operation="edit"`: review and fix, or change, an existing Word, PowerPoint, Excel or PDF.
|
|
62
|
+
- `documents.stream(…)`: the document's events as they happen.
|
|
63
|
+
- `extract(file)`: any file to Markdown for your model (free).
|
|
64
|
+
- `files.upload(path_or_bytes)`, `webhooks.create(url)` and `verify_webhook(body, headers, secret)`.
|
|
65
|
+
|
|
66
|
+
Docs: https://docs.doconda.com/en/sdk/python · Guides per stack: https://docs.doconda.com/en/agents/introduction · Get an API key: https://doconda.com
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@doconda/sdk-python",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Thin wrapper so turbo runs the Python SDK's checks. The SDK itself is managed with uv.",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "uv run pytest -q",
|
|
8
|
+
"lint": "uv run ruff check . && uv run ruff format --check ."
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "doconda"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Python SDK for Doconda: generate, review and edit Word, PowerPoint, Excel and PDF documents from AI agents (Amazon Bedrock, Vertex AI, OpenAI, Claude, LangChain)."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.9"
|
|
9
|
+
dependencies = ["httpx>=0.27"]
|
|
10
|
+
keywords = [
|
|
11
|
+
"docx", "pptx", "xlsx", "pdf", "word", "powerpoint", "excel", "document-generation", "ai-agents", "llm",
|
|
12
|
+
"tools", "bedrock", "vertex-ai", "openai", "anthropic", "langchain", "mcp",
|
|
13
|
+
]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Topic :: Office/Business",
|
|
18
|
+
"Typing :: Typed",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://doconda.com"
|
|
23
|
+
Documentation = "https://docs.doconda.com/en/sdk/python"
|
|
24
|
+
|
|
25
|
+
[dependency-groups]
|
|
26
|
+
dev = ["pytest>=8", "ruff>=0.12"]
|
|
27
|
+
|
|
28
|
+
[build-system]
|
|
29
|
+
requires = ["hatchling"]
|
|
30
|
+
build-backend = "hatchling.build"
|
|
31
|
+
|
|
32
|
+
[tool.ruff]
|
|
33
|
+
line-length = 110
|
|
34
|
+
target-version = "py39"
|
|
35
|
+
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from collections.abc import Iterator, Mapping
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import IO, Any, Union
|
|
14
|
+
from urllib.parse import quote
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
# ===== Types =====
|
|
19
|
+
|
|
20
|
+
FileLike = Union[str, bytes, os.PathLike, IO[bytes]]
|
|
21
|
+
|
|
22
|
+
# ===== Constants =====
|
|
23
|
+
|
|
24
|
+
TERMINAL = {"ready", "ready_with_warnings", "needs_review", "failed", "canceled"}
|
|
25
|
+
TERMINAL_EVENTS = {"document.ready", "document.failed", "document.canceled"}
|
|
26
|
+
TIMEOUT = httpx.Timeout(60.0, read=600.0)
|
|
27
|
+
STREAM_TIMEOUT = httpx.Timeout(60.0, read=None)
|
|
28
|
+
|
|
29
|
+
# ===== Helpers =====
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _sse(response: httpx.Response) -> Iterator[dict]:
|
|
33
|
+
data: list[str] = []
|
|
34
|
+
for line in response.iter_lines():
|
|
35
|
+
if not line:
|
|
36
|
+
if data:
|
|
37
|
+
yield json.loads("\n".join(data))
|
|
38
|
+
data = []
|
|
39
|
+
elif line.startswith("data:"):
|
|
40
|
+
data.append(line[5:].removeprefix(" "))
|
|
41
|
+
if data:
|
|
42
|
+
yield json.loads("\n".join(data))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _read(file: FileLike, filename: str | None) -> tuple[bytes, str]:
|
|
46
|
+
if isinstance(file, bytes):
|
|
47
|
+
if not filename:
|
|
48
|
+
raise ValueError("Doconda: pass filename (with its extension) when uploading bytes.")
|
|
49
|
+
return file, filename
|
|
50
|
+
if isinstance(file, (str, os.PathLike)):
|
|
51
|
+
path = Path(file)
|
|
52
|
+
return path.read_bytes(), filename or path.name
|
|
53
|
+
name = filename or Path(getattr(file, "name", "") or "").name
|
|
54
|
+
if not name:
|
|
55
|
+
raise ValueError("Doconda: pass filename (with its extension) for this file object.")
|
|
56
|
+
return file.read(), name
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _file_input(file: FileLike | Mapping[str, str], filename: str | None) -> Any:
|
|
60
|
+
if isinstance(file, Mapping):
|
|
61
|
+
return dict(file)
|
|
62
|
+
if isinstance(file, str) and re.match(r"^(file|doc)_", file):
|
|
63
|
+
return file
|
|
64
|
+
if isinstance(file, str) and re.match(r"^https?://", file):
|
|
65
|
+
return {"url": file, **({"filename": filename} if filename else {})}
|
|
66
|
+
data, name = _read(file, filename)
|
|
67
|
+
return {"data": base64.b64encode(data).decode(), "filename": name}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _query(params: Mapping[str, Any]) -> dict[str, Any]:
|
|
71
|
+
return {k: v for k, v in params.items() if v is not None}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ===== Public API =====
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class DocondaError(Exception):
|
|
78
|
+
def __init__(self, status: int, body: Any):
|
|
79
|
+
body = body if isinstance(body, dict) else {}
|
|
80
|
+
super().__init__(body.get("title") or f"Doconda API answered {status}")
|
|
81
|
+
self.status = status
|
|
82
|
+
self.code = body.get("code") or "http_error"
|
|
83
|
+
self.request_id = body.get("request_id")
|
|
84
|
+
self.errors = body.get("errors") or []
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Doconda:
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
api_key: str | None = None,
|
|
91
|
+
*,
|
|
92
|
+
base_url: str | None = None,
|
|
93
|
+
max_retries: int = 2,
|
|
94
|
+
http_client: httpx.Client | None = None,
|
|
95
|
+
):
|
|
96
|
+
key = api_key or os.environ.get("DOCONDA_API_KEY")
|
|
97
|
+
if not key:
|
|
98
|
+
raise ValueError("Doconda: pass api_key or set DOCONDA_API_KEY.")
|
|
99
|
+
self.base_url = (
|
|
100
|
+
base_url or os.environ.get("DOCONDA_BASE_URL") or "https://api.eu.doconda.com/v1"
|
|
101
|
+
).rstrip("/")
|
|
102
|
+
self.max_retries = max_retries
|
|
103
|
+
self._key = key
|
|
104
|
+
self._http = http_client or httpx.Client(timeout=TIMEOUT)
|
|
105
|
+
self.documents = Documents(self)
|
|
106
|
+
self.files = Files(self)
|
|
107
|
+
self.webhooks = Webhooks(self)
|
|
108
|
+
|
|
109
|
+
def tools(self, *, local_files: bool = False) -> list:
|
|
110
|
+
from ._tools import doconda_tools
|
|
111
|
+
|
|
112
|
+
return doconda_tools(self, local_files=local_files)
|
|
113
|
+
|
|
114
|
+
def extract(self, file: FileLike | Mapping[str, str], *, filename: str | None = None) -> dict:
|
|
115
|
+
result = self.json("POST", "/extract", json={"file": _file_input(file, filename)})
|
|
116
|
+
while "markdown" not in result:
|
|
117
|
+
time.sleep(2)
|
|
118
|
+
result = self.json("GET", f"/extract/{result['id']}")
|
|
119
|
+
return result
|
|
120
|
+
|
|
121
|
+
def request(
|
|
122
|
+
self,
|
|
123
|
+
method: str,
|
|
124
|
+
path: str,
|
|
125
|
+
*,
|
|
126
|
+
json: Any = None,
|
|
127
|
+
files: Any = None,
|
|
128
|
+
params: Mapping[str, Any] | None = None,
|
|
129
|
+
headers: Mapping[str, str] | None = None,
|
|
130
|
+
stream: bool = False,
|
|
131
|
+
) -> httpx.Response:
|
|
132
|
+
all_headers = {"authorization": f"Bearer {self._key}", **(headers or {})}
|
|
133
|
+
for attempt in range(self.max_retries + 1):
|
|
134
|
+
try:
|
|
135
|
+
request = self._http.build_request(
|
|
136
|
+
method,
|
|
137
|
+
f"{self.base_url}{path}",
|
|
138
|
+
json=json,
|
|
139
|
+
files=files,
|
|
140
|
+
params=params,
|
|
141
|
+
headers=all_headers,
|
|
142
|
+
timeout=STREAM_TIMEOUT if stream else self._http.timeout,
|
|
143
|
+
)
|
|
144
|
+
response = self._http.send(request, stream=stream)
|
|
145
|
+
except httpx.TransportError:
|
|
146
|
+
if attempt >= self.max_retries:
|
|
147
|
+
raise
|
|
148
|
+
time.sleep(0.5 * 2**attempt)
|
|
149
|
+
continue
|
|
150
|
+
if response.is_success:
|
|
151
|
+
return response
|
|
152
|
+
if stream:
|
|
153
|
+
response.read()
|
|
154
|
+
retryable = response.status_code == 429 or response.status_code >= 500
|
|
155
|
+
if not retryable or attempt >= self.max_retries:
|
|
156
|
+
try:
|
|
157
|
+
body = response.json()
|
|
158
|
+
except ValueError:
|
|
159
|
+
body = None
|
|
160
|
+
raise DocondaError(response.status_code, body)
|
|
161
|
+
wait = response.headers.get("retry-after")
|
|
162
|
+
time.sleep(float(wait) if wait and wait.isdigit() else 0.5 * 2**attempt)
|
|
163
|
+
raise AssertionError("unreachable")
|
|
164
|
+
|
|
165
|
+
def json(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
166
|
+
response = self.request(method, path, **kwargs)
|
|
167
|
+
return None if response.status_code == 204 else response.json()
|
|
168
|
+
|
|
169
|
+
def close(self) -> None:
|
|
170
|
+
self._http.close()
|
|
171
|
+
|
|
172
|
+
def __enter__(self):
|
|
173
|
+
return self
|
|
174
|
+
|
|
175
|
+
def __exit__(self, *_: object) -> None:
|
|
176
|
+
self.close()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class Documents:
|
|
180
|
+
def __init__(self, client: Doconda):
|
|
181
|
+
self._c = client
|
|
182
|
+
|
|
183
|
+
def create(self, *, idempotency_key: str | None = None, **body: Any) -> dict:
|
|
184
|
+
doc = self._post(body, idempotency_key)
|
|
185
|
+
return doc if doc["status"] in TERMINAL else self.wait(doc["id"])
|
|
186
|
+
|
|
187
|
+
def create_in_background(self, *, idempotency_key: str | None = None, **body: Any) -> dict:
|
|
188
|
+
return self._post({**body, "background": True}, idempotency_key)
|
|
189
|
+
|
|
190
|
+
def stream(self, *, idempotency_key: str | None = None, **body: Any) -> Iterator[dict]:
|
|
191
|
+
response = self._c.request(
|
|
192
|
+
"POST",
|
|
193
|
+
"/documents",
|
|
194
|
+
json={**body, "stream": True},
|
|
195
|
+
headers={"idempotency-key": idempotency_key or str(uuid.uuid4()), "accept": "text/event-stream"},
|
|
196
|
+
stream=True,
|
|
197
|
+
)
|
|
198
|
+
try:
|
|
199
|
+
yield from _sse(response)
|
|
200
|
+
finally:
|
|
201
|
+
response.close()
|
|
202
|
+
|
|
203
|
+
def get(self, document_id: str) -> dict:
|
|
204
|
+
return self._c.json("GET", f"/documents/{document_id}")
|
|
205
|
+
|
|
206
|
+
def page(self, *, limit: int | None = None, cursor: str | None = None, status: str | None = None) -> dict:
|
|
207
|
+
return self._c.json(
|
|
208
|
+
"GET", "/documents", params=_query({"limit": limit, "cursor": cursor, "status": status})
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
def list(self, *, status: str | None = None) -> Iterator[dict]:
|
|
212
|
+
cursor = None
|
|
213
|
+
while True:
|
|
214
|
+
page = self.page(limit=100, cursor=cursor, status=status)
|
|
215
|
+
yield from page["data"]
|
|
216
|
+
cursor = page.get("next_cursor")
|
|
217
|
+
if not cursor:
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
def cancel(self, document_id: str) -> dict:
|
|
221
|
+
return self._c.json(
|
|
222
|
+
"POST", f"/documents/{document_id}/cancel", headers={"idempotency-key": str(uuid.uuid4())}
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def delete(self, document_id: str) -> None:
|
|
226
|
+
self._c.json("DELETE", f"/documents/{document_id}")
|
|
227
|
+
|
|
228
|
+
def events(self, document_id: str, *, after: int = 0) -> Iterator[dict]:
|
|
229
|
+
response = self._c.request(
|
|
230
|
+
"GET", f"/documents/{document_id}/events", params=_query({"after": after or None}), stream=True
|
|
231
|
+
)
|
|
232
|
+
try:
|
|
233
|
+
yield from _sse(response)
|
|
234
|
+
finally:
|
|
235
|
+
response.close()
|
|
236
|
+
|
|
237
|
+
def wait(self, document_id: str) -> dict:
|
|
238
|
+
after = 0
|
|
239
|
+
while True:
|
|
240
|
+
for event in self.events(document_id, after=after):
|
|
241
|
+
after = event["sequence"]
|
|
242
|
+
if event["type"] in TERMINAL_EVENTS:
|
|
243
|
+
return self.get(document_id)
|
|
244
|
+
doc = self.get(document_id)
|
|
245
|
+
if doc["status"] in TERMINAL:
|
|
246
|
+
return doc
|
|
247
|
+
|
|
248
|
+
def outputs(self, document_id: str) -> list:
|
|
249
|
+
return self._c.json("GET", f"/documents/{document_id}/outputs")["data"]
|
|
250
|
+
|
|
251
|
+
def download(self, document_id: str, kind: str) -> bytes:
|
|
252
|
+
output = next((o for o in self.outputs(document_id) if o["kind"] == kind), None)
|
|
253
|
+
if output is None:
|
|
254
|
+
raise ValueError(f"Doconda: document {document_id} has no {kind}.")
|
|
255
|
+
response = httpx.get(output["url"], timeout=TIMEOUT, follow_redirects=True)
|
|
256
|
+
response.raise_for_status()
|
|
257
|
+
return response.content
|
|
258
|
+
|
|
259
|
+
def report(self, document_id: str) -> dict:
|
|
260
|
+
return self._c.json("GET", f"/documents/{document_id}/report")
|
|
261
|
+
|
|
262
|
+
def data(self, document_id: str, collection: str | None = None) -> list:
|
|
263
|
+
path = f"/documents/{document_id}/data" + (f"/{quote(collection, safe='')}" if collection else "")
|
|
264
|
+
return self._c.json("GET", path)["data"]
|
|
265
|
+
|
|
266
|
+
def _post(self, body: dict, idempotency_key: str | None) -> dict:
|
|
267
|
+
return self._c.json(
|
|
268
|
+
"POST", "/documents", json=body, headers={"idempotency-key": idempotency_key or str(uuid.uuid4())}
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class Files:
|
|
273
|
+
def __init__(self, client: Doconda):
|
|
274
|
+
self._c = client
|
|
275
|
+
|
|
276
|
+
def upload(self, file: FileLike, filename: str | None = None) -> dict:
|
|
277
|
+
data, name = _read(file, filename)
|
|
278
|
+
return self._c.json("POST", "/files", files={"file": (name, data)})
|
|
279
|
+
|
|
280
|
+
def get(self, file_id: str) -> dict:
|
|
281
|
+
return self._c.json("GET", f"/files/{file_id}")
|
|
282
|
+
|
|
283
|
+
def delete(self, file_id: str) -> None:
|
|
284
|
+
self._c.json("DELETE", f"/files/{file_id}")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class Webhooks:
|
|
288
|
+
def __init__(self, client: Doconda):
|
|
289
|
+
self._c = client
|
|
290
|
+
|
|
291
|
+
def create(self, url: str, *, event_types: list[str] | None = None) -> dict:
|
|
292
|
+
return self._c.json(
|
|
293
|
+
"POST", "/webhook-endpoints", json=_query({"url": url, "event_types": event_types})
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
def list(self) -> list:
|
|
297
|
+
return self._c.json("GET", "/webhook-endpoints")["data"]
|
|
298
|
+
|
|
299
|
+
def delete(self, endpoint_id: str) -> None:
|
|
300
|
+
self._c.json("DELETE", f"/webhook-endpoints/{endpoint_id}")
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def verify_webhook(
|
|
304
|
+
body: str | bytes, headers: Mapping[str, str], secret: str, tolerance_seconds: int = 300
|
|
305
|
+
) -> dict:
|
|
306
|
+
lower = {k.lower(): v for k, v in headers.items()}
|
|
307
|
+
msg_id, timestamp, signatures = (
|
|
308
|
+
lower.get(h) for h in ("webhook-id", "webhook-timestamp", "webhook-signature")
|
|
309
|
+
)
|
|
310
|
+
if not msg_id or not timestamp or not signatures:
|
|
311
|
+
raise ValueError("Doconda webhook: missing webhook-id, -timestamp or -signature.")
|
|
312
|
+
if abs(time.time() - int(timestamp)) > tolerance_seconds:
|
|
313
|
+
raise ValueError("Doconda webhook: the timestamp is too old or in the future.")
|
|
314
|
+
text = body.decode() if isinstance(body, bytes) else body
|
|
315
|
+
key = base64.b64decode(secret.removeprefix("whsec_"))
|
|
316
|
+
mac = hmac.new(key, f"{msg_id}.{timestamp}.{text}".encode(), hashlib.sha256).digest()
|
|
317
|
+
expected = base64.b64encode(mac).decode()
|
|
318
|
+
if not any(s.startswith("v1,") and hmac.compare_digest(s[3:], expected) for s in signatures.split(" ")):
|
|
319
|
+
raise ValueError("Doconda webhook: the signature does not match.")
|
|
320
|
+
return json.loads(text)
|