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