pyquickbase 0.1.0a1__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.
- pyquickbase/__init__.py +73 -0
- pyquickbase/_async/__init__.py +0 -0
- pyquickbase/_async/_client.py +151 -0
- pyquickbase/_async/_resources.py +511 -0
- pyquickbase/_async/_transport.py +74 -0
- pyquickbase/_auth.py +19 -0
- pyquickbase/_config.py +93 -0
- pyquickbase/_models.py +241 -0
- pyquickbase/_query.py +315 -0
- pyquickbase/_response.py +90 -0
- pyquickbase/_retry.py +66 -0
- pyquickbase/_schema.py +35 -0
- pyquickbase/_sync/__init__.py +0 -0
- pyquickbase/_sync/_client.py +152 -0
- pyquickbase/_sync/_resources.py +506 -0
- pyquickbase/_sync/_transport.py +75 -0
- pyquickbase/_version.py +1 -0
- pyquickbase/_writes.py +104 -0
- pyquickbase/errors.py +110 -0
- pyquickbase/py.typed +0 -0
- pyquickbase-0.1.0a1.dist-info/METADATA +223 -0
- pyquickbase-0.1.0a1.dist-info/RECORD +24 -0
- pyquickbase-0.1.0a1.dist-info/WHEEL +4 -0
- pyquickbase-0.1.0a1.dist-info/licenses/LICENSE +21 -0
pyquickbase/_response.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Turn HTTP responses and failures into results or exceptions. No network access."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from ._retry import parse_retry_after
|
|
8
|
+
from .errors import (
|
|
9
|
+
APIError,
|
|
10
|
+
AuthenticationError,
|
|
11
|
+
BadRequestError,
|
|
12
|
+
InvalidResponseError,
|
|
13
|
+
NetworkError,
|
|
14
|
+
NotFoundError,
|
|
15
|
+
PermissionDeniedError,
|
|
16
|
+
RateLimitError,
|
|
17
|
+
RequestTimeoutError,
|
|
18
|
+
ServerError,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
REQUEST_ID_HEADER = "qb-api-ray"
|
|
22
|
+
_MAX_DETAIL_CHARS = 500
|
|
23
|
+
_ERRORS_BY_STATUS: dict[int, type[APIError]] = {
|
|
24
|
+
400: BadRequestError,
|
|
25
|
+
401: AuthenticationError,
|
|
26
|
+
403: PermissionDeniedError,
|
|
27
|
+
404: NotFoundError,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _error_details(response: httpx.Response) -> tuple[str | None, str | None]:
|
|
32
|
+
"""Return Quickbase's message and description, falling back to the raw body."""
|
|
33
|
+
try:
|
|
34
|
+
body: Any = response.json()
|
|
35
|
+
except ValueError:
|
|
36
|
+
return (response.text[:_MAX_DETAIL_CHARS] or None), None
|
|
37
|
+
if not isinstance(body, dict):
|
|
38
|
+
return None, None
|
|
39
|
+
details: dict[str, Any] = body # pyright: ignore[reportUnknownVariableType]
|
|
40
|
+
message, description = details.get("message"), details.get("description")
|
|
41
|
+
return (
|
|
42
|
+
message[:_MAX_DETAIL_CHARS] if isinstance(message, str) and message else None,
|
|
43
|
+
description[:_MAX_DETAIL_CHARS] if isinstance(description, str) and description else None,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def error_from_response(response: httpx.Response, method: str, path: str) -> APIError:
|
|
48
|
+
message, description = _error_details(response)
|
|
49
|
+
request_id = response.headers.get(REQUEST_ID_HEADER)
|
|
50
|
+
status = response.status_code
|
|
51
|
+
summary = f"Quickbase returned {status} for {method} {path}"
|
|
52
|
+
detail = ": ".join(part for part in (message, description) if part)
|
|
53
|
+
if detail:
|
|
54
|
+
summary += f": {detail}"
|
|
55
|
+
if request_id:
|
|
56
|
+
summary += f" (request ID: {request_id})"
|
|
57
|
+
common = {
|
|
58
|
+
"status_code": status,
|
|
59
|
+
"method": method,
|
|
60
|
+
"path": path,
|
|
61
|
+
"message": message,
|
|
62
|
+
"description": description,
|
|
63
|
+
"request_id": request_id,
|
|
64
|
+
}
|
|
65
|
+
if status == 429:
|
|
66
|
+
retry_after = parse_retry_after(response.headers.get("retry-after"))
|
|
67
|
+
return RateLimitError(summary, retry_after=retry_after, **common)
|
|
68
|
+
error_type = ServerError if status >= 500 else _ERRORS_BY_STATUS.get(status, APIError)
|
|
69
|
+
return error_type(summary, **common) # type: ignore[arg-type] # pyright: ignore[reportArgumentType]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def parse_response(response: httpx.Response, method: str, path: str) -> Any:
|
|
73
|
+
"""Return the decoded JSON body, None for an empty body, or raise the matching error."""
|
|
74
|
+
if not response.is_success:
|
|
75
|
+
raise error_from_response(response, method, path)
|
|
76
|
+
if not response.content:
|
|
77
|
+
return None
|
|
78
|
+
try:
|
|
79
|
+
return response.json()
|
|
80
|
+
except ValueError as exc:
|
|
81
|
+
raise InvalidResponseError(
|
|
82
|
+
f"Quickbase returned a non-JSON body for {method} {path}."
|
|
83
|
+
) from exc
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def error_from_transport(exc: httpx.TransportError, method: str, path: str) -> NetworkError:
|
|
87
|
+
reason = type(exc).__name__
|
|
88
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
89
|
+
return RequestTimeoutError(f"{method} {path} timed out ({reason}).")
|
|
90
|
+
return NetworkError(f"Could not complete {method} {path} ({reason}).")
|
pyquickbase/_retry.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""When to retry and how long to wait. Quickbase documents retrying only on HTTP 429.
|
|
2
|
+
|
|
3
|
+
Connection failures are also retried, because the request never reached the server.
|
|
4
|
+
Other failures are not: a timed-out or failed write may already have been applied.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import random
|
|
8
|
+
import re
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from email.utils import parsedate_to_datetime
|
|
12
|
+
|
|
13
|
+
from .errors import ConfigurationError
|
|
14
|
+
|
|
15
|
+
_SECONDS = re.compile(r"\d+(\.\d+)?")
|
|
16
|
+
_MAX_EXPONENT = 32 # keeps 2**attempt from overflowing a float
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
20
|
+
class RetryPolicy:
|
|
21
|
+
"""How to retry rate-limited requests and connection failures.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
max_retries: Retries after the first attempt. Use 0 to disable retrying.
|
|
25
|
+
base_delay: Starting backoff in seconds, when Quickbase doesn't specify a wait.
|
|
26
|
+
max_delay: Longest backoff in seconds.
|
|
27
|
+
max_retry_after: Raise `RateLimitError` rather than obey a longer `Retry-After`.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
max_retries: int = 4
|
|
31
|
+
base_delay: float = 1.0
|
|
32
|
+
max_delay: float = 30.0
|
|
33
|
+
max_retry_after: float = 60.0
|
|
34
|
+
|
|
35
|
+
def __post_init__(self) -> None:
|
|
36
|
+
if self.max_retries < 0:
|
|
37
|
+
raise ConfigurationError("max_retries can't be negative.")
|
|
38
|
+
if self.base_delay < 0 or self.max_delay < 0 or self.max_retry_after < 0:
|
|
39
|
+
raise ConfigurationError("Retry delays can't be negative.")
|
|
40
|
+
|
|
41
|
+
def backoff(self, attempt: int) -> float:
|
|
42
|
+
"""Exponential backoff with jitter, so parallel clients don't retry in lockstep."""
|
|
43
|
+
ceiling = min(self.max_delay, self.base_delay * 2 ** min(attempt, _MAX_EXPONENT))
|
|
44
|
+
return random.uniform(ceiling / 2, ceiling)
|
|
45
|
+
|
|
46
|
+
def rate_limit_delay(self, attempt: int, retry_after: float | None) -> float | None:
|
|
47
|
+
"""Seconds to wait after a 429, or None if the wait is too long to be worth it."""
|
|
48
|
+
if retry_after is None:
|
|
49
|
+
return self.backoff(attempt)
|
|
50
|
+
return retry_after if retry_after <= self.max_retry_after else None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def parse_retry_after(value: str | None, now: datetime | None = None) -> float | None:
|
|
54
|
+
"""Parse a `Retry-After` header given as seconds or an HTTP date."""
|
|
55
|
+
if value is None:
|
|
56
|
+
return None
|
|
57
|
+
value = value.strip()
|
|
58
|
+
if _SECONDS.fullmatch(value):
|
|
59
|
+
return float(value)
|
|
60
|
+
try:
|
|
61
|
+
when = parsedate_to_datetime(value)
|
|
62
|
+
except (TypeError, ValueError):
|
|
63
|
+
return None
|
|
64
|
+
if when.tzinfo is None:
|
|
65
|
+
when = when.replace(tzinfo=UTC)
|
|
66
|
+
return max(0.0, (when - (now or datetime.now(UTC))).total_seconds())
|
pyquickbase/_schema.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""A small time-limited cache for app, table, and field metadata. No network access."""
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SchemaCache:
|
|
9
|
+
"""Safe to share across threads, so one sync client can serve several of them."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, ttl: float) -> None:
|
|
12
|
+
self._ttl = ttl
|
|
13
|
+
self._lock = threading.Lock()
|
|
14
|
+
self._entries: dict[str, tuple[float, Any]] = {}
|
|
15
|
+
|
|
16
|
+
def get(self, key: str) -> Any:
|
|
17
|
+
"""Return the cached value, or None if it's missing or expired."""
|
|
18
|
+
with self._lock:
|
|
19
|
+
entry = self._entries.get(key)
|
|
20
|
+
if entry is None or time.monotonic() - entry[0] >= self._ttl:
|
|
21
|
+
return None
|
|
22
|
+
return entry[1]
|
|
23
|
+
|
|
24
|
+
def set(self, key: str, value: Any) -> None:
|
|
25
|
+
with self._lock:
|
|
26
|
+
self._entries[key] = (time.monotonic(), value)
|
|
27
|
+
|
|
28
|
+
def delete(self, *keys: str) -> None:
|
|
29
|
+
with self._lock:
|
|
30
|
+
for key in keys:
|
|
31
|
+
self._entries.pop(key, None)
|
|
32
|
+
|
|
33
|
+
def clear(self) -> None:
|
|
34
|
+
with self._lock:
|
|
35
|
+
self._entries.clear()
|
|
File without changes
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# Generated from the async code by scripts/generate_sync.py. Do not edit.
|
|
2
|
+
"""The async Quickbase client."""
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Collection, Mapping
|
|
6
|
+
from types import TracebackType
|
|
7
|
+
from typing import Any, Self
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from .._auth import UserTokenAuth
|
|
12
|
+
from .._config import (
|
|
13
|
+
DEFAULT_BASE_URL,
|
|
14
|
+
DEFAULT_TIMEOUT,
|
|
15
|
+
build_user_agent,
|
|
16
|
+
normalize_allowed_writes,
|
|
17
|
+
normalize_realm,
|
|
18
|
+
read_env,
|
|
19
|
+
validate_base_url,
|
|
20
|
+
validate_token,
|
|
21
|
+
)
|
|
22
|
+
from .._retry import RetryPolicy
|
|
23
|
+
from .._schema import SchemaCache
|
|
24
|
+
from ..errors import ConfigurationError
|
|
25
|
+
from ._resources import App
|
|
26
|
+
from ._transport import Transport
|
|
27
|
+
|
|
28
|
+
DEFAULT_SCHEMA_TTL = 300.0
|
|
29
|
+
_APP_ID = re.compile(r"[A-Za-z0-9]+")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Quickbase:
|
|
33
|
+
"""A Quickbase API client. Use it as a context manager so connections get closed.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
realm: Your realm, as `acme`, `acme.quickbase.com`, or a realm URL.
|
|
37
|
+
token: A Quickbase user token.
|
|
38
|
+
base_url: The API root. Must use HTTPS, except for loopback addresses.
|
|
39
|
+
retry: How to retry rate limits and connection failures.
|
|
40
|
+
timeout: Request timeout in seconds. Ignored if you pass `http_client`.
|
|
41
|
+
user_agent: Identifies your application, placed before the SDK's own User-Agent.
|
|
42
|
+
http_client: Your own configured httpx client, for proxies, custom TLS, event hooks,
|
|
43
|
+
or custom transports. You remain responsible for closing it.
|
|
44
|
+
schema_ttl: Seconds to cache app, table, and field metadata. Use 0 to disable caching.
|
|
45
|
+
read_only: Refuse any request that could change data, raising `ReadOnlyError`. Only GET
|
|
46
|
+
requests and record queries are sent, including through `request()`.
|
|
47
|
+
allowed_writes: Like `read_only`, but also allow these `(method, path)` pairs, such as
|
|
48
|
+
`{("POST", "/records")}`. Paths must match exactly.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
*,
|
|
54
|
+
realm: str,
|
|
55
|
+
token: str,
|
|
56
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
57
|
+
retry: RetryPolicy | None = None,
|
|
58
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
59
|
+
user_agent: str | None = None,
|
|
60
|
+
http_client: httpx.Client | None = None,
|
|
61
|
+
schema_ttl: float = DEFAULT_SCHEMA_TTL,
|
|
62
|
+
read_only: bool = False,
|
|
63
|
+
allowed_writes: Collection[tuple[str, str]] | None = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
self._realm = normalize_realm(realm)
|
|
66
|
+
self._transport = Transport(
|
|
67
|
+
realm=self._realm,
|
|
68
|
+
auth=UserTokenAuth(validate_token(token)),
|
|
69
|
+
base_url=validate_base_url(base_url),
|
|
70
|
+
retry=RetryPolicy() if retry is None else retry,
|
|
71
|
+
timeout=timeout,
|
|
72
|
+
user_agent=build_user_agent(user_agent),
|
|
73
|
+
http_client=http_client,
|
|
74
|
+
allowed_writes=normalize_allowed_writes(read_only, allowed_writes),
|
|
75
|
+
)
|
|
76
|
+
self._schema = SchemaCache(schema_ttl)
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def from_env(
|
|
80
|
+
cls,
|
|
81
|
+
*,
|
|
82
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
83
|
+
retry: RetryPolicy | None = None,
|
|
84
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
85
|
+
user_agent: str | None = None,
|
|
86
|
+
http_client: httpx.Client | None = None,
|
|
87
|
+
schema_ttl: float = DEFAULT_SCHEMA_TTL,
|
|
88
|
+
read_only: bool = False,
|
|
89
|
+
allowed_writes: Collection[tuple[str, str]] | None = None,
|
|
90
|
+
) -> Self:
|
|
91
|
+
"""Create a client from the `QB_REALM` and `QB_USER_TOKEN` environment variables."""
|
|
92
|
+
realm, token = read_env()
|
|
93
|
+
return cls(
|
|
94
|
+
realm=realm,
|
|
95
|
+
token=token,
|
|
96
|
+
base_url=base_url,
|
|
97
|
+
retry=retry,
|
|
98
|
+
timeout=timeout,
|
|
99
|
+
user_agent=user_agent,
|
|
100
|
+
http_client=http_client,
|
|
101
|
+
schema_ttl=schema_ttl,
|
|
102
|
+
read_only=read_only,
|
|
103
|
+
allowed_writes=allowed_writes,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def realm(self) -> str:
|
|
108
|
+
"""The full realm hostname, such as `acme.quickbase.com`."""
|
|
109
|
+
return self._realm
|
|
110
|
+
|
|
111
|
+
def app(self, app_id: str) -> App:
|
|
112
|
+
"""Get an app by ID, the `bq...` part of its URL. This makes no network calls."""
|
|
113
|
+
if not _APP_ID.fullmatch(app_id):
|
|
114
|
+
raise ConfigurationError("An app ID contains only letters and numbers.")
|
|
115
|
+
return App(self._transport, self._schema, app_id)
|
|
116
|
+
|
|
117
|
+
def clear_schema_cache(self) -> None:
|
|
118
|
+
"""Forget cached metadata, for example after changing a table's fields."""
|
|
119
|
+
self._schema.clear()
|
|
120
|
+
|
|
121
|
+
def request(
|
|
122
|
+
self,
|
|
123
|
+
method: str,
|
|
124
|
+
path: str,
|
|
125
|
+
*,
|
|
126
|
+
params: Mapping[str, Any] | None = None,
|
|
127
|
+
json: Any = None,
|
|
128
|
+
) -> Any:
|
|
129
|
+
"""Call any Quickbase endpoint directly and return its decoded JSON body.
|
|
130
|
+
|
|
131
|
+
This is the escape hatch for endpoints without a dedicated method. `path` is relative
|
|
132
|
+
to the API root, such as `/tables/abc123`, and the body is None if the response is empty.
|
|
133
|
+
"""
|
|
134
|
+
return self._transport.request(method, path, params=params, json=json)
|
|
135
|
+
|
|
136
|
+
def close(self) -> None:
|
|
137
|
+
"""Close connections. Safe to call more than once."""
|
|
138
|
+
self._transport.close()
|
|
139
|
+
|
|
140
|
+
def __enter__(self) -> Self:
|
|
141
|
+
return self
|
|
142
|
+
|
|
143
|
+
def __exit__(
|
|
144
|
+
self,
|
|
145
|
+
exc_type: type[BaseException] | None,
|
|
146
|
+
exc: BaseException | None,
|
|
147
|
+
traceback: TracebackType | None,
|
|
148
|
+
) -> None:
|
|
149
|
+
self.close()
|
|
150
|
+
|
|
151
|
+
def __repr__(self) -> str:
|
|
152
|
+
return f"{type(self).__name__}(realm={self._realm!r})"
|