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.
@@ -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})"