workhold-client-core 1.0.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.
- _workhold_client_core/__init__.py +115 -0
- _workhold_client_core/async_transport.py +68 -0
- _workhold_client_core/capabilities.py +135 -0
- _workhold_client_core/capability_guard.py +92 -0
- _workhold_client_core/codecs.py +182 -0
- _workhold_client_core/config.py +145 -0
- _workhold_client_core/data/client-operation-ownership.json +652 -0
- _workhold_client_core/errors.py +172 -0
- _workhold_client_core/instrumentation.py +659 -0
- _workhold_client_core/models.py +451 -0
- _workhold_client_core/priority.py +30 -0
- _workhold_client_core/redaction.py +164 -0
- _workhold_client_core/requests.py +88 -0
- _workhold_client_core/retry.py +490 -0
- _workhold_client_core/transport.py +620 -0
- _workhold_client_core/transport_async.py +270 -0
- workhold_client_core-1.0.0.dist-info/METADATA +9 -0
- workhold_client_core-1.0.0.dist-info/RECORD +26 -0
- workhold_client_core-1.0.0.dist-info/WHEEL +4 -0
- workhold_client_testing/__init__.py +119 -0
- workhold_client_testing/_secrets.py +105 -0
- workhold_client_testing/clock.py +63 -0
- workhold_client_testing/fixtures.py +150 -0
- workhold_client_testing/responses.py +103 -0
- workhold_client_testing/scenarios.py +154 -0
- workhold_client_testing/transport.py +299 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Immutable client configuration without credential discovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from urllib.parse import urlparse, urlunparse
|
|
7
|
+
|
|
8
|
+
from _workhold_client_core.requests import normalize_base_url
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"ClientConfig",
|
|
12
|
+
"LONG_POLL_READ_SLACK_S",
|
|
13
|
+
"LONG_POLL_TOTAL_SLACK_S",
|
|
14
|
+
"long_poll_read_timeout_s",
|
|
15
|
+
"long_poll_total_timeout_s",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
# Per-call long-poll budgets relative to requested wait (Phase 20.1 D-04/D-06).
|
|
19
|
+
LONG_POLL_READ_SLACK_S = 5.0
|
|
20
|
+
LONG_POLL_TOTAL_SLACK_S = 10.0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def long_poll_read_timeout_s(wait_seconds: int) -> float:
|
|
24
|
+
"""Read inactivity budget: ``wait_seconds + 5``."""
|
|
25
|
+
|
|
26
|
+
return float(wait_seconds) + LONG_POLL_READ_SLACK_S
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def long_poll_total_timeout_s(wait_seconds: int) -> float:
|
|
30
|
+
"""Total deadline budget: ``wait_seconds + 10``."""
|
|
31
|
+
|
|
32
|
+
return float(wait_seconds) + LONG_POLL_TOTAL_SLACK_S
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class ClientConfig:
|
|
37
|
+
"""Shared wire configuration for sync and async transports.
|
|
38
|
+
|
|
39
|
+
Bearer tokens and claim tokens are **not** part of this object; role clients
|
|
40
|
+
pass them explicitly per request/constructor.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
public_base_url: str
|
|
44
|
+
admin_base_url: str | None = None
|
|
45
|
+
connect_timeout_s: float = 5.0
|
|
46
|
+
read_timeout_s: float = 30.0
|
|
47
|
+
total_timeout_s: float | None = None
|
|
48
|
+
verify_tls: bool = True
|
|
49
|
+
ca_cert_path: str | None = None
|
|
50
|
+
client_cert_path: str | None = None
|
|
51
|
+
client_key_path: str | None = None
|
|
52
|
+
|
|
53
|
+
def __post_init__(self) -> None:
|
|
54
|
+
if self.connect_timeout_s <= 0:
|
|
55
|
+
raise ValueError("connect_timeout_s must be positive")
|
|
56
|
+
if self.read_timeout_s <= 0:
|
|
57
|
+
raise ValueError("read_timeout_s must be positive")
|
|
58
|
+
if self.total_timeout_s is not None and self.total_timeout_s <= 0:
|
|
59
|
+
raise ValueError("total_timeout_s must be positive when set")
|
|
60
|
+
|
|
61
|
+
object.__setattr__(
|
|
62
|
+
self,
|
|
63
|
+
"public_base_url",
|
|
64
|
+
normalize_base_url(self.public_base_url),
|
|
65
|
+
)
|
|
66
|
+
if self.admin_base_url is not None:
|
|
67
|
+
object.__setattr__(
|
|
68
|
+
self,
|
|
69
|
+
"admin_base_url",
|
|
70
|
+
normalize_base_url(self.admin_base_url),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def for_public(cls, base_url: str, **kwargs: object) -> ClientConfig:
|
|
75
|
+
"""Build a config with only the public API base URL."""
|
|
76
|
+
|
|
77
|
+
return cls(public_base_url=base_url, **kwargs) # type: ignore[arg-type]
|
|
78
|
+
|
|
79
|
+
def sync_timeout_s(self) -> float:
|
|
80
|
+
"""Single timeout value for stdlib transports."""
|
|
81
|
+
|
|
82
|
+
if self.total_timeout_s is not None:
|
|
83
|
+
return self.total_timeout_s
|
|
84
|
+
return self.read_timeout_s
|
|
85
|
+
|
|
86
|
+
def ensure_long_poll_budgets(self, wait_seconds: int) -> tuple[float, float]:
|
|
87
|
+
"""Validate configured budgets for a positive wait; return (read, total).
|
|
88
|
+
|
|
89
|
+
Fails closed before HTTP when ``read_timeout_s < wait+5`` or the effective
|
|
90
|
+
total deadline is ``< wait+10``.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
if isinstance(wait_seconds, bool) or type(wait_seconds) is not int:
|
|
94
|
+
raise ValueError("wait_seconds must be an integer")
|
|
95
|
+
if wait_seconds < 0:
|
|
96
|
+
raise ValueError("wait_seconds must be >= 0")
|
|
97
|
+
if wait_seconds == 0:
|
|
98
|
+
timeout = self.sync_timeout_s()
|
|
99
|
+
return self.read_timeout_s, timeout
|
|
100
|
+
|
|
101
|
+
need_read = long_poll_read_timeout_s(wait_seconds)
|
|
102
|
+
need_total = long_poll_total_timeout_s(wait_seconds)
|
|
103
|
+
if self.read_timeout_s < need_read:
|
|
104
|
+
raise ValueError(
|
|
105
|
+
f"read_timeout_s {self.read_timeout_s} is below required "
|
|
106
|
+
f"{need_read} for wait_seconds={wait_seconds}"
|
|
107
|
+
)
|
|
108
|
+
configured_total = (
|
|
109
|
+
self.total_timeout_s
|
|
110
|
+
if self.total_timeout_s is not None
|
|
111
|
+
else self.read_timeout_s
|
|
112
|
+
)
|
|
113
|
+
if configured_total < need_total:
|
|
114
|
+
raise ValueError(
|
|
115
|
+
f"total_timeout_s {configured_total} is below required "
|
|
116
|
+
f"{need_total} for wait_seconds={wait_seconds}"
|
|
117
|
+
)
|
|
118
|
+
return need_read, need_total
|
|
119
|
+
|
|
120
|
+
def __repr__(self) -> str:
|
|
121
|
+
return (
|
|
122
|
+
f"ClientConfig("
|
|
123
|
+
f"public_base_url={_base_url_for_repr(self.public_base_url)!r}, "
|
|
124
|
+
f"admin_base_url={_base_url_for_repr(self.admin_base_url)!r}, "
|
|
125
|
+
f"connect_timeout_s={self.connect_timeout_s!r}, "
|
|
126
|
+
f"read_timeout_s={self.read_timeout_s!r}, "
|
|
127
|
+
f"total_timeout_s={self.total_timeout_s!r}, "
|
|
128
|
+
f"verify_tls={self.verify_tls!r}, "
|
|
129
|
+
f"ca_cert_path={('<set>' if self.ca_cert_path else None)!r}, "
|
|
130
|
+
f"client_cert_path={('<set>' if self.client_cert_path else None)!r}, "
|
|
131
|
+
f"client_key_path={('<set>' if self.client_key_path else None)!r})"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def __str__(self) -> str:
|
|
135
|
+
return self.__repr__()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _base_url_for_repr(url: str | None) -> str | None:
|
|
139
|
+
if url is None:
|
|
140
|
+
return None
|
|
141
|
+
parsed = urlparse(url)
|
|
142
|
+
host = parsed.hostname or ""
|
|
143
|
+
if parsed.port is not None:
|
|
144
|
+
host = f"{host}:{parsed.port}"
|
|
145
|
+
return urlunparse((parsed.scheme, host, parsed.path or "/", "", "", ""))
|