threecommon 0.1.0__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.
- threecommon/__init__.py +79 -0
- threecommon/_core/__init__.py +6 -0
- threecommon/_core/headers.py +41 -0
- threecommon/_core/http_client.py +424 -0
- threecommon/_core/parse.py +77 -0
- threecommon/_core/retry.py +80 -0
- threecommon/_core/telemetry.py +77 -0
- threecommon/_core/url.py +31 -0
- threecommon/_generated/__init__.py +8 -0
- threecommon/_generated/models.py +614 -0
- threecommon/api_version.py +15 -0
- threecommon/client.py +184 -0
- threecommon/config.py +140 -0
- threecommon/errors/__init__.py +35 -0
- threecommon/errors/base.py +81 -0
- threecommon/errors/classes.py +75 -0
- threecommon/events/__init__.py +28 -0
- threecommon/events/service.py +170 -0
- threecommon/events/types.py +124 -0
- threecommon/filters/__init__.py +51 -0
- threecommon/filters/builder.py +188 -0
- threecommon/filters/types.py +69 -0
- threecommon/helpers.py +19 -0
- threecommon/invoices/__init__.py +40 -0
- threecommon/invoices/service.py +266 -0
- threecommon/invoices/types.py +195 -0
- threecommon/pagination/__init__.py +12 -0
- threecommon/pagination/auto_paginator.py +98 -0
- threecommon/py.typed +0 -0
- threecommon/version.py +10 -0
- threecommon-0.1.0.dist-info/METADATA +399 -0
- threecommon-0.1.0.dist-info/RECORD +34 -0
- threecommon-0.1.0.dist-info/WHEEL +4 -0
- threecommon-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Retry policy + backoff math.
|
|
2
|
+
|
|
3
|
+
Pure module — no I/O, no timing. The sync/async HTTP clients call
|
|
4
|
+
[compute_backoff][threecommon._core.retry.compute_backoff] and pass the
|
|
5
|
+
result to ``time.sleep`` / ``asyncio.sleep`` themselves.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import secrets
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from threecommon.config import RetryDelay
|
|
14
|
+
|
|
15
|
+
#: HTTP status codes the SDK considers retryable for idempotent methods.
|
|
16
|
+
RETRYABLE_STATUSES: frozenset[int] = frozenset({408, 425, 429, 500, 502, 503, 504})
|
|
17
|
+
|
|
18
|
+
#: HTTP methods the SDK retries automatically. ``POST`` and ``DELETE`` opt
|
|
19
|
+
#: in via an explicit idempotency key.
|
|
20
|
+
IDEMPOTENT_METHODS: frozenset[str] = frozenset({"GET", "PATCH", "PUT"})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class RetryPolicy:
|
|
25
|
+
"""Bundled retry configuration for the HTTP client."""
|
|
26
|
+
|
|
27
|
+
max_retries: int
|
|
28
|
+
initial_seconds: float
|
|
29
|
+
max_seconds: float
|
|
30
|
+
jitter: bool
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def from_delay(cls, max_retries: int, delay: RetryDelay) -> RetryPolicy:
|
|
34
|
+
"""Build a [RetryPolicy] from the public [RetryDelay]."""
|
|
35
|
+
return cls(
|
|
36
|
+
max_retries=max_retries,
|
|
37
|
+
initial_seconds=delay.initial_seconds,
|
|
38
|
+
max_seconds=delay.max_seconds,
|
|
39
|
+
jitter=delay.jitter,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def is_idempotent(method: str, *, has_idempotency_key: bool) -> bool:
|
|
44
|
+
"""Whether the SDK may safely retry a request with this method."""
|
|
45
|
+
return has_idempotency_key or method.upper() in IDEMPOTENT_METHODS
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def is_retryable_status(status: int) -> bool:
|
|
49
|
+
"""Whether ``status`` is in the retry set (alongside method idempotency)."""
|
|
50
|
+
return status in RETRYABLE_STATUSES
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def compute_backoff(
|
|
54
|
+
*,
|
|
55
|
+
attempt: int,
|
|
56
|
+
retry_after_seconds: float | None,
|
|
57
|
+
policy: RetryPolicy,
|
|
58
|
+
) -> float:
|
|
59
|
+
"""Return the next sleep duration in seconds.
|
|
60
|
+
|
|
61
|
+
When ``retry_after_seconds`` is provided (e.g. parsed from a
|
|
62
|
+
``Retry-After`` header) it takes precedence, capped at
|
|
63
|
+
``policy.max_seconds``. Otherwise: exponential ``2**attempt * initial``,
|
|
64
|
+
capped, with optional full-jitter randomization.
|
|
65
|
+
"""
|
|
66
|
+
if retry_after_seconds is not None and retry_after_seconds >= 0:
|
|
67
|
+
return min(retry_after_seconds, policy.max_seconds)
|
|
68
|
+
|
|
69
|
+
safe_attempt = max(attempt, 0)
|
|
70
|
+
exp: float = policy.initial_seconds * (2**safe_attempt)
|
|
71
|
+
capped: float = min(exp, policy.max_seconds)
|
|
72
|
+
|
|
73
|
+
if not policy.jitter:
|
|
74
|
+
return capped
|
|
75
|
+
if capped <= 0:
|
|
76
|
+
return 0.0
|
|
77
|
+
# Full jitter: pick uniformly in [0, capped). secrets.randbits avoids
|
|
78
|
+
# the math.random global-state lock and isn't security-sensitive here.
|
|
79
|
+
bits = secrets.randbits(32)
|
|
80
|
+
return float(capped * (bits / (1 << 32)))
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Threecommon-Client-Telemetry header builder + last-request tracker.
|
|
2
|
+
|
|
3
|
+
The header carries SDK version, language, and the previous request's
|
|
4
|
+
latency snapshot.
|
|
5
|
+
|
|
6
|
+
both sync and async paths share one [Telemetry][threecommon._core.telemetry.Telemetry]
|
|
7
|
+
instance per client.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import threading
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class _Snapshot:
|
|
19
|
+
method: str
|
|
20
|
+
path: str
|
|
21
|
+
status: int
|
|
22
|
+
duration_ms: int
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Telemetry:
|
|
26
|
+
"""Tracks one previous-request snapshot and emits the header value."""
|
|
27
|
+
|
|
28
|
+
__slots__ = ("_enabled", "_last", "_lock")
|
|
29
|
+
|
|
30
|
+
def __init__(self, *, enabled: bool) -> None:
|
|
31
|
+
self._enabled = enabled
|
|
32
|
+
self._last: _Snapshot | None = None
|
|
33
|
+
self._lock = threading.Lock()
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def enabled(self) -> bool:
|
|
37
|
+
with self._lock:
|
|
38
|
+
return self._enabled
|
|
39
|
+
|
|
40
|
+
def disable(self) -> None:
|
|
41
|
+
"""Turn telemetry off and clear the cached snapshot."""
|
|
42
|
+
with self._lock:
|
|
43
|
+
self._enabled = False
|
|
44
|
+
self._last = None
|
|
45
|
+
|
|
46
|
+
def record(self, *, method: str, path: str, status: int, duration_seconds: float) -> None:
|
|
47
|
+
"""Store a snapshot of the just-completed request. No-op when disabled."""
|
|
48
|
+
with self._lock:
|
|
49
|
+
if not self._enabled:
|
|
50
|
+
return
|
|
51
|
+
self._last = _Snapshot(
|
|
52
|
+
method=method,
|
|
53
|
+
path=path,
|
|
54
|
+
status=status,
|
|
55
|
+
duration_ms=int(duration_seconds * 1000),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def header_value(self, *, sdk_version: str, api_version: str) -> str | None:
|
|
59
|
+
"""Return the JSON header value, or ``None`` to omit the header."""
|
|
60
|
+
with self._lock:
|
|
61
|
+
if not self._enabled:
|
|
62
|
+
return None
|
|
63
|
+
last = self._last
|
|
64
|
+
|
|
65
|
+
payload: dict[str, object] = {
|
|
66
|
+
"lang": "python",
|
|
67
|
+
"sdk": sdk_version,
|
|
68
|
+
"api": api_version,
|
|
69
|
+
}
|
|
70
|
+
if last is not None:
|
|
71
|
+
payload["last"] = {
|
|
72
|
+
"m": last.method,
|
|
73
|
+
"p": last.path,
|
|
74
|
+
"s": last.status,
|
|
75
|
+
"d": last.duration_ms,
|
|
76
|
+
}
|
|
77
|
+
return json.dumps(payload, separators=(",", ":"))
|
threecommon/_core/url.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""URL builder. Pure function — no I/O."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from urllib.parse import urlencode
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def build_url(
|
|
9
|
+
*,
|
|
10
|
+
base_url: str,
|
|
11
|
+
api_path: str,
|
|
12
|
+
path: str,
|
|
13
|
+
query: dict[str, str] | None = None,
|
|
14
|
+
) -> str:
|
|
15
|
+
"""Concatenate ``base_url + api_path + path`` and append a sorted query string.
|
|
16
|
+
|
|
17
|
+
Trailing slashes on ``base_url`` are trimmed; missing leading slashes on
|
|
18
|
+
``path`` are added. Query keys are stable-sorted for deterministic output.
|
|
19
|
+
"""
|
|
20
|
+
base = base_url.rstrip("/")
|
|
21
|
+
p = path if path.startswith("/") else "/" + path
|
|
22
|
+
out = base + api_path + p
|
|
23
|
+
|
|
24
|
+
if not query:
|
|
25
|
+
return out
|
|
26
|
+
|
|
27
|
+
pairs = [(k, v) for k, v in sorted(query.items()) if v]
|
|
28
|
+
if not pairs:
|
|
29
|
+
return out
|
|
30
|
+
|
|
31
|
+
return f"{out}?{urlencode(pairs)}"
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Generated Pydantic v2 models — DO NOT EDIT.
|
|
2
|
+
|
|
3
|
+
Regenerated from `../../openapi/spec.yaml` via `uv run datamodel-codegen ...`.
|
|
4
|
+
The auto-generated names (`V1EventsGetResponse`,`Datum`, `Status1`, ...) are intentional
|
|
5
|
+
|
|
6
|
+
Treat this package as a contract-reference artifact: nothing inside should be imported from outside
|
|
7
|
+
the SDK.
|
|
8
|
+
"""
|