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 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."""