crafvy 0.3.1__py3-none-any.whl
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.
- crafvy/__init__.py +42 -0
- crafvy/_client.py +199 -0
- crafvy/_errors.py +79 -0
- crafvy/_generated/__init__.py +1 -0
- crafvy/_generated/_calls.py +1587 -0
- crafvy/_generated/operations.py +1589 -0
- crafvy/_generated/resources.py +14285 -0
- crafvy/_generated/types.py +40550 -0
- crafvy/_runtime.py +44 -0
- crafvy/py.typed +0 -0
- crafvy-0.3.1.dist-info/METADATA +94 -0
- crafvy-0.3.1.dist-info/RECORD +13 -0
- crafvy-0.3.1.dist-info/WHEEL +4 -0
crafvy/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""The official Python SDK of the Crafvy API.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
from crafvy import Crafvy
|
|
6
|
+
|
|
7
|
+
crafvy = Crafvy("pb_...")
|
|
8
|
+
for project in crafvy.projects.list():
|
|
9
|
+
print(project["name"])
|
|
10
|
+
"""
|
|
11
|
+
from ._client import DEFAULT_BASE_URL, Crafvy, Transport, paginate, urllib_transport
|
|
12
|
+
from ._errors import (
|
|
13
|
+
AuthenticationError,
|
|
14
|
+
AuthorizationError,
|
|
15
|
+
ConflictError,
|
|
16
|
+
CrafvyError,
|
|
17
|
+
NotFoundError,
|
|
18
|
+
RateLimitError,
|
|
19
|
+
ValidationError,
|
|
20
|
+
)
|
|
21
|
+
from ._generated.operations import API_VERSION, OPERATIONS
|
|
22
|
+
from ._runtime import RequestOptions
|
|
23
|
+
|
|
24
|
+
__version__ = API_VERSION
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"API_VERSION",
|
|
28
|
+
"AuthenticationError",
|
|
29
|
+
"AuthorizationError",
|
|
30
|
+
"ConflictError",
|
|
31
|
+
"Crafvy",
|
|
32
|
+
"CrafvyError",
|
|
33
|
+
"DEFAULT_BASE_URL",
|
|
34
|
+
"NotFoundError",
|
|
35
|
+
"OPERATIONS",
|
|
36
|
+
"RateLimitError",
|
|
37
|
+
"RequestOptions",
|
|
38
|
+
"Transport",
|
|
39
|
+
"ValidationError",
|
|
40
|
+
"paginate",
|
|
41
|
+
"urllib_transport",
|
|
42
|
+
]
|
crafvy/_client.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""The client: authentication, the HTTP transport, retries and errors."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import random
|
|
7
|
+
import time
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
import uuid
|
|
12
|
+
from typing import Any, Callable, Dict, Iterator, List, Mapping, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
from ._errors import CrafvyError, error_for
|
|
15
|
+
from ._generated.operations import API_VERSION
|
|
16
|
+
from ._generated.resources import Resources
|
|
17
|
+
from ._runtime import RequestOptions
|
|
18
|
+
|
|
19
|
+
# (method, url, headers, body bytes or None, timeout seconds) -> (status, headers, body bytes)
|
|
20
|
+
Transport = Callable[[str, str, Dict[str, str], Optional[bytes], Optional[float]], Tuple[int, Mapping[str, str], bytes]]
|
|
21
|
+
|
|
22
|
+
DEFAULT_BASE_URL = "https://app.crafvy.com"
|
|
23
|
+
_IDEMPOTENT = {"GET", "HEAD", "PUT", "DELETE"}
|
|
24
|
+
_RETRYABLE_5XX = {500, 502, 503, 504}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def urllib_transport(method: str, url: str, headers: Dict[str, str], body: Optional[bytes], timeout: Optional[float]) -> Tuple[int, Mapping[str, str], bytes]:
|
|
28
|
+
"""The default transport: the standard library's urllib, no dependencies."""
|
|
29
|
+
req = urllib.request.Request(url, data=body, method=method, headers=headers)
|
|
30
|
+
try:
|
|
31
|
+
with urllib.request.urlopen(req, timeout=timeout) as res:
|
|
32
|
+
return res.status, dict(res.headers.items()), res.read()
|
|
33
|
+
except urllib.error.HTTPError as err:
|
|
34
|
+
return err.code, dict(err.headers.items()) if err.headers else {}, err.read() or b""
|
|
35
|
+
except TimeoutError as err:
|
|
36
|
+
raise CrafvyError(f"Request timed out after {timeout}s", 408) from err
|
|
37
|
+
except urllib.error.URLError as err:
|
|
38
|
+
if isinstance(err.reason, TimeoutError):
|
|
39
|
+
raise CrafvyError(f"Request timed out after {timeout}s", 408) from err
|
|
40
|
+
raise
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Crafvy(Resources):
|
|
44
|
+
"""The Crafvy API.
|
|
45
|
+
|
|
46
|
+
::
|
|
47
|
+
|
|
48
|
+
from crafvy import Crafvy
|
|
49
|
+
|
|
50
|
+
crafvy = Crafvy("pb_...") # or the CRAFVY_TOKEN environment variable
|
|
51
|
+
projects = crafvy.projects.list(workspace_id="...")
|
|
52
|
+
|
|
53
|
+
A token acts as its owner: their roles, workspaces, feature gates and plan
|
|
54
|
+
limits apply exactly as in the dashboard. Create one under Settings →
|
|
55
|
+
Integrations → API Access Tokens.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
token: Optional[str] = None,
|
|
61
|
+
*,
|
|
62
|
+
base_url: Optional[str] = None,
|
|
63
|
+
timeout: Optional[float] = 60.0,
|
|
64
|
+
retries: int = 3,
|
|
65
|
+
transport: Optional[Transport] = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
token = token or os.environ.get("CRAFVY_TOKEN")
|
|
68
|
+
if not token:
|
|
69
|
+
raise ValueError("Crafvy: pass a token, or set CRAFVY_TOKEN")
|
|
70
|
+
self._token = token
|
|
71
|
+
self._base_url = (base_url or os.environ.get("CRAFVY_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
72
|
+
self._timeout = timeout
|
|
73
|
+
self._retries = retries
|
|
74
|
+
self._transport = transport or urllib_transport
|
|
75
|
+
super().__init__(self._call)
|
|
76
|
+
|
|
77
|
+
def raw(self, method: str, path: str, *, query: Optional[Dict[str, Any]] = None, body: Any = None, options: Optional[RequestOptions] = None) -> Any:
|
|
78
|
+
"""Calls any path of the API directly — for an endpoint this SDK version has no method for yet."""
|
|
79
|
+
return self._call(method.upper(), path, query=query, body=body, options=options)
|
|
80
|
+
|
|
81
|
+
def _call(
|
|
82
|
+
self,
|
|
83
|
+
method: str,
|
|
84
|
+
path: str,
|
|
85
|
+
*,
|
|
86
|
+
query: Optional[Dict[str, Any]] = None,
|
|
87
|
+
body: Any = None,
|
|
88
|
+
multipart: bool = False,
|
|
89
|
+
options: Optional[RequestOptions] = None,
|
|
90
|
+
) -> Any:
|
|
91
|
+
options = options or {}
|
|
92
|
+
url = self._base_url + path
|
|
93
|
+
if query:
|
|
94
|
+
pairs = [(k, _query_value(v)) for k, v in query.items() if v is not None]
|
|
95
|
+
if pairs:
|
|
96
|
+
url += "?" + urllib.parse.urlencode(pairs)
|
|
97
|
+
headers = {
|
|
98
|
+
"Authorization": f"Bearer {self._token}",
|
|
99
|
+
"Accept": "application/json",
|
|
100
|
+
"User-Agent": f"crafvy-python/{API_VERSION}",
|
|
101
|
+
}
|
|
102
|
+
data: Optional[bytes] = None
|
|
103
|
+
if body is not None:
|
|
104
|
+
if multipart:
|
|
105
|
+
data, content_type = _multipart(body)
|
|
106
|
+
headers["Content-Type"] = content_type
|
|
107
|
+
else:
|
|
108
|
+
data = json.dumps(body).encode("utf-8")
|
|
109
|
+
headers["Content-Type"] = "application/json"
|
|
110
|
+
headers.update(options.get("headers", {}))
|
|
111
|
+
retries = options.get("retries", self._retries)
|
|
112
|
+
timeout = options.get("timeout", self._timeout)
|
|
113
|
+
|
|
114
|
+
attempt = 0
|
|
115
|
+
while True:
|
|
116
|
+
status, res_headers, raw = self._transport(method, url, headers, data, timeout)
|
|
117
|
+
retryable = status == 429 or (status in _RETRYABLE_5XX and method in _IDEMPOTENT)
|
|
118
|
+
if retryable and attempt < retries:
|
|
119
|
+
time.sleep(_delay(attempt, _header(res_headers, "Retry-After")))
|
|
120
|
+
attempt += 1
|
|
121
|
+
continue
|
|
122
|
+
parsed: Any = None
|
|
123
|
+
if raw:
|
|
124
|
+
try:
|
|
125
|
+
parsed = json.loads(raw.decode("utf-8"))
|
|
126
|
+
except ValueError:
|
|
127
|
+
parsed = raw.decode("utf-8", errors="replace")
|
|
128
|
+
if status >= 400:
|
|
129
|
+
ra = _header(res_headers, "Retry-After")
|
|
130
|
+
raise error_for(status, parsed, float(ra) if ra and ra.replace(".", "", 1).isdigit() else None)
|
|
131
|
+
return parsed
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _query_value(v: Any) -> str:
|
|
135
|
+
if isinstance(v, bool):
|
|
136
|
+
return "true" if v else "false"
|
|
137
|
+
if isinstance(v, (dict, list)):
|
|
138
|
+
return json.dumps(v)
|
|
139
|
+
return str(v)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _header(headers: Mapping[str, str], name: str) -> Optional[str]:
|
|
143
|
+
for k, v in headers.items():
|
|
144
|
+
if k.lower() == name.lower():
|
|
145
|
+
return v
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _delay(attempt: int, retry_after: Optional[str]) -> float:
|
|
150
|
+
if retry_after:
|
|
151
|
+
try:
|
|
152
|
+
return float(retry_after)
|
|
153
|
+
except ValueError:
|
|
154
|
+
pass
|
|
155
|
+
return 0.5 * (2**attempt) + random.random() * 0.1
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _multipart(fields: Mapping[str, Any]) -> Tuple[bytes, str]:
|
|
159
|
+
boundary = f"crafvy-{uuid.uuid4().hex}"
|
|
160
|
+
out: List[bytes] = []
|
|
161
|
+
for key, value in fields.items():
|
|
162
|
+
if value is None:
|
|
163
|
+
continue
|
|
164
|
+
if isinstance(value, (bytes, bytearray)):
|
|
165
|
+
out.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{key}"; filename="{key}"\r\nContent-Type: application/octet-stream\r\n\r\n'.encode())
|
|
166
|
+
out.append(bytes(value))
|
|
167
|
+
out.append(b"\r\n")
|
|
168
|
+
elif isinstance(value, tuple) and len(value) >= 2:
|
|
169
|
+
filename, content = value[0], value[1]
|
|
170
|
+
ctype = value[2] if len(value) > 2 else "application/octet-stream"
|
|
171
|
+
out.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{key}"; filename="{filename}"\r\nContent-Type: {ctype}\r\n\r\n'.encode())
|
|
172
|
+
out.append(content if isinstance(content, bytes) else str(content).encode())
|
|
173
|
+
out.append(b"\r\n")
|
|
174
|
+
else:
|
|
175
|
+
text = json.dumps(value) if isinstance(value, (dict, list)) else _query_value(value)
|
|
176
|
+
out.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{key}"\r\n\r\n{text}\r\n'.encode())
|
|
177
|
+
out.append(f"--{boundary}--\r\n".encode())
|
|
178
|
+
return b"".join(out), f"multipart/form-data; boundary={boundary}"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def paginate(fetch: Callable[..., Any], *, limit: int = 50, start_page: int = 1, **kwargs: Any) -> Iterator[Any]:
|
|
182
|
+
"""Every item of a page-based list, one page at a time behind the scenes.
|
|
183
|
+
|
|
184
|
+
::
|
|
185
|
+
|
|
186
|
+
for project in paginate(crafvy.projects.list, workspace_id=ws):
|
|
187
|
+
...
|
|
188
|
+
"""
|
|
189
|
+
page = start_page
|
|
190
|
+
while True:
|
|
191
|
+
items = fetch(page=page, limit=limit, **kwargs)
|
|
192
|
+
if isinstance(items, dict):
|
|
193
|
+
items = next((v for v in items.values() if isinstance(v, list)), [])
|
|
194
|
+
if not items:
|
|
195
|
+
return
|
|
196
|
+
yield from items
|
|
197
|
+
if len(items) < limit:
|
|
198
|
+
return
|
|
199
|
+
page += 1
|
crafvy/_errors.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Errors the SDK raises.
|
|
2
|
+
|
|
3
|
+
Every refusal the API sends is ``{"success": false, "error": "...", "code": "..."}``.
|
|
4
|
+
The SDK raises one of the classes below, carrying the HTTP ``status``, the API's
|
|
5
|
+
machine-readable ``code`` (``tokenPermissionMissing``, ``crmSpaceNotFound``, ...)
|
|
6
|
+
and the whole response ``body`` — branch on the code, not on the English text::
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
crafvy.tasks.create({"workspaceId": ws, "title": "Call Anna"})
|
|
10
|
+
except CrafvyError as err:
|
|
11
|
+
if err.code == "tasksSpaceRequired":
|
|
12
|
+
...
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any, Optional
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CrafvyError(Exception):
|
|
20
|
+
"""Any error answer of the API (and a timeout, as status 408)."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, message: str, status: int, body: Any = None, code: Optional[str] = None) -> None:
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
self.message = message
|
|
25
|
+
self.status = status
|
|
26
|
+
self.body = body
|
|
27
|
+
if code is None and isinstance(body, dict) and isinstance(body.get("code"), str):
|
|
28
|
+
code = body["code"]
|
|
29
|
+
self.code = code
|
|
30
|
+
|
|
31
|
+
def __repr__(self) -> str:
|
|
32
|
+
return f"{type(self).__name__}(status={self.status}, code={self.code!r}, message={self.message!r})"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ValidationError(CrafvyError):
|
|
36
|
+
"""400 — the request was refused as invalid."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AuthenticationError(CrafvyError):
|
|
40
|
+
"""401 — no token, or one that is not valid (any more)."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AuthorizationError(CrafvyError):
|
|
44
|
+
"""403 — not allowed: the owner's roles, or the token's permissions (``tokenPermissionMissing``, ``tokenScopeInsufficient``)."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class NotFoundError(CrafvyError):
|
|
48
|
+
"""404 — nothing there, or nothing the token's owner may see."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ConflictError(CrafvyError):
|
|
52
|
+
"""409 — it clashes with what is there now (a name taken, a field changed meanwhile)."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class RateLimitError(CrafvyError):
|
|
56
|
+
"""429 — too many requests, after the SDK's own retries. ``retry_after`` is in seconds, when the API said."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, message: str, body: Any = None, retry_after: Optional[float] = None) -> None:
|
|
59
|
+
super().__init__(message, 429, body)
|
|
60
|
+
self.retry_after = retry_after
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
_BY_STATUS = {
|
|
64
|
+
400: ValidationError,
|
|
65
|
+
401: AuthenticationError,
|
|
66
|
+
403: AuthorizationError,
|
|
67
|
+
404: NotFoundError,
|
|
68
|
+
409: ConflictError,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def error_for(status: int, body: Any, retry_after: Optional[float] = None) -> CrafvyError:
|
|
73
|
+
message = body.get("error") if isinstance(body, dict) and isinstance(body.get("error"), str) else f"HTTP {status}"
|
|
74
|
+
if status == 429:
|
|
75
|
+
return RateLimitError(message, body, retry_after)
|
|
76
|
+
cls = _BY_STATUS.get(status, CrafvyError)
|
|
77
|
+
if cls is CrafvyError:
|
|
78
|
+
return CrafvyError(message, status, body)
|
|
79
|
+
return cls(message, status, body)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Generated from sdks/openapi.json — do not edit."""
|