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,115 @@
1
+ """Private shared Queue client primitives (not a public role API surface)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from _workhold_client_core.capabilities import Capabilities
6
+ from _workhold_client_core.codecs import (
7
+ PayloadDecodeError,
8
+ PayloadDecoder,
9
+ PayloadEncoder,
10
+ TypedClaimView,
11
+ TypedTaskView,
12
+ decode_payload,
13
+ encode_payload,
14
+ measure_json_bytes,
15
+ )
16
+ from _workhold_client_core.errors import (
17
+ AuthenticationError,
18
+ ErrorCode,
19
+ LeaseLostError,
20
+ MalformedResponseError,
21
+ ProtocolError,
22
+ QueueClientError,
23
+ RequestCancelledError,
24
+ TerminalConflictError,
25
+ TimeoutError,
26
+ TransportError,
27
+ raise_for_protocol_status,
28
+ )
29
+ from _workhold_client_core.models import (
30
+ AckCancelResult,
31
+ CancelResponse,
32
+ ClaimSummary,
33
+ CompleteResult,
34
+ EnqueueResponse,
35
+ FailResult,
36
+ HeartbeatResult,
37
+ ProtocolErrorBody,
38
+ ResolveSubmissionResponse,
39
+ Task,
40
+ TaskState,
41
+ )
42
+ from _workhold_client_core.priority import (
43
+ PRIORITY_DEFAULT,
44
+ PRIORITY_MAX,
45
+ PRIORITY_MIN,
46
+ validate_priority,
47
+ )
48
+ from _workhold_client_core.redaction import (
49
+ REDACTED,
50
+ redact_headers,
51
+ redact_text,
52
+ sanitize_for_diagnostics,
53
+ )
54
+ from _workhold_client_core.config import ClientConfig
55
+ from _workhold_client_core.requests import PreparedRequest, prepare_json_request
56
+ from _workhold_client_core.transport import (
57
+ AsyncTransport,
58
+ HttpJsonTransport,
59
+ SyncTransport,
60
+ TransportResponse,
61
+ encode_path_segment,
62
+ )
63
+
64
+ __version__ = "1.0.0"
65
+
66
+ __all__ = [
67
+ "AckCancelResult",
68
+ "AsyncTransport",
69
+ "AuthenticationError",
70
+ "CancelResponse",
71
+ "Capabilities",
72
+ "ClientConfig",
73
+ "ClaimSummary",
74
+ "CompleteResult",
75
+ "EnqueueResponse",
76
+ "ErrorCode",
77
+ "FailResult",
78
+ "HeartbeatResult",
79
+ "HttpJsonTransport",
80
+ "LeaseLostError",
81
+ "PayloadDecodeError",
82
+ "PayloadDecoder",
83
+ "PayloadEncoder",
84
+ "PreparedRequest",
85
+ "MalformedResponseError",
86
+ "PRIORITY_DEFAULT",
87
+ "PRIORITY_MAX",
88
+ "PRIORITY_MIN",
89
+ "ProtocolError",
90
+ "ProtocolErrorBody",
91
+ "QueueClientError",
92
+ "RequestCancelledError",
93
+ "SyncTransport",
94
+ "REDACTED",
95
+ "ResolveSubmissionResponse",
96
+ "Task",
97
+ "TaskState",
98
+ "TerminalConflictError",
99
+ "TimeoutError",
100
+ "TransportError",
101
+ "TransportResponse",
102
+ "TypedClaimView",
103
+ "TypedTaskView",
104
+ "decode_payload",
105
+ "encode_path_segment",
106
+ "encode_payload",
107
+ "measure_json_bytes",
108
+ "prepare_json_request",
109
+ "raise_for_protocol_status",
110
+ "redact_headers",
111
+ "redact_text",
112
+ "sanitize_for_diagnostics",
113
+ "validate_priority",
114
+ "__version__",
115
+ ]
@@ -0,0 +1,68 @@
1
+ """Compatibility shim: URL-based async transport for 20-02 role clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+
7
+ from _workhold_client_core.config import ClientConfig
8
+ from _workhold_client_core.transport import AsyncTransport, TransportResponse
9
+ from _workhold_client_core.transport_async import HttpxAsyncTransport as _CoreHttpx
10
+
11
+ __all__ = ["AsyncTransport", "HttpxAsyncTransport"]
12
+
13
+
14
+ class HttpxAsyncTransport:
15
+ """Adapter over core ``HttpxAsyncTransport`` using a public base URL."""
16
+
17
+ def __init__(
18
+ self,
19
+ base_url: str,
20
+ *,
21
+ timeout_s: float = 30.0,
22
+ client: object | None = None,
23
+ config: ClientConfig | None = None,
24
+ ) -> None:
25
+ if config is None:
26
+ config = ClientConfig(
27
+ public_base_url=base_url,
28
+ read_timeout_s=timeout_s,
29
+ total_timeout_s=timeout_s,
30
+ )
31
+ self._inner = _CoreHttpx(config, client=client)
32
+ self._timeout_s = timeout_s
33
+
34
+ @property
35
+ def timeout_s(self) -> float:
36
+ return self._timeout_s
37
+
38
+ @property
39
+ def config(self) -> ClientConfig:
40
+ return self._inner.config
41
+
42
+ async def aclose(self) -> None:
43
+ await self._inner.aclose()
44
+
45
+ async def request(
46
+ self,
47
+ method: str,
48
+ path: str,
49
+ *,
50
+ headers: Mapping[str, str] | None = None,
51
+ json_body: object | None = None,
52
+ query: Mapping[str, str] | None = None,
53
+ expect_body: bool = True,
54
+ read_timeout_s: float | None = None,
55
+ total_timeout_s: float | None = None,
56
+ cancellation: object | None = None,
57
+ ) -> TransportResponse:
58
+ return await self._inner.request(
59
+ method,
60
+ path,
61
+ headers=headers,
62
+ json_body=json_body,
63
+ query=query,
64
+ expect_body=expect_body,
65
+ read_timeout_s=read_timeout_s,
66
+ total_timeout_s=total_timeout_s,
67
+ cancellation=cancellation,
68
+ )
@@ -0,0 +1,135 @@
1
+ """Tolerant OpenAPI ``Capabilities`` wire parsing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Final
8
+
9
+ _REQUIRED: Final[frozenset[str]] = frozenset(
10
+ {
11
+ "protocol_major",
12
+ "protocol_version",
13
+ "schema_revision",
14
+ "scheduling",
15
+ "priority",
16
+ "delivery_events",
17
+ "batch_claim",
18
+ "long_polling",
19
+ "max_claim_tasks",
20
+ "max_wait_seconds",
21
+ "payload_runtime_max_bytes",
22
+ "payload_hard_max_bytes",
23
+ "enqueue_dedup_ttl_seconds",
24
+ "enqueue_dedup_ttl_min_seconds",
25
+ "enqueue_dedup_ttl_max_seconds",
26
+ "terminal_replay_ttl_seconds",
27
+ "terminal_replay_ttl_min_seconds",
28
+ "terminal_replay_ttl_max_seconds",
29
+ "admin_replay_ttl_seconds",
30
+ "admin_replay_ttl_min_seconds",
31
+ "admin_replay_ttl_max_seconds",
32
+ }
33
+ )
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class Capabilities:
38
+ """Capabilities resource; additive fields land in ``extra``."""
39
+
40
+ protocol_major: int
41
+ protocol_version: str
42
+ schema_revision: str
43
+ scheduling: bool
44
+ priority: bool
45
+ delivery_events: bool
46
+ batch_claim: bool
47
+ long_polling: bool
48
+ max_claim_tasks: int
49
+ max_wait_seconds: int
50
+ payload_runtime_max_bytes: int
51
+ payload_hard_max_bytes: int
52
+ enqueue_dedup_ttl_seconds: int
53
+ enqueue_dedup_ttl_min_seconds: int
54
+ enqueue_dedup_ttl_max_seconds: int
55
+ terminal_replay_ttl_seconds: int
56
+ terminal_replay_ttl_min_seconds: int
57
+ terminal_replay_ttl_max_seconds: int
58
+ admin_replay_ttl_seconds: int
59
+ admin_replay_ttl_min_seconds: int
60
+ admin_replay_ttl_max_seconds: int
61
+ extra: Mapping[str, Any] = field(default_factory=dict)
62
+
63
+ @classmethod
64
+ def parse(cls, raw: object) -> Capabilities:
65
+ if not isinstance(raw, Mapping):
66
+ raise ValueError("capabilities must be an object")
67
+ missing = sorted(_REQUIRED - set(raw))
68
+ if missing:
69
+ raise ValueError(f"capabilities missing required fields: {missing}")
70
+
71
+ extra = {k: v for k, v in raw.items() if k not in _REQUIRED}
72
+ return cls(
73
+ protocol_major=_require_int(raw["protocol_major"], "protocol_major"),
74
+ protocol_version=_require_str(raw["protocol_version"], "protocol_version"),
75
+ schema_revision=_require_str(raw["schema_revision"], "schema_revision"),
76
+ scheduling=_require_bool(raw["scheduling"], "scheduling"),
77
+ priority=_require_bool(raw["priority"], "priority"),
78
+ delivery_events=_require_bool(raw["delivery_events"], "delivery_events"),
79
+ batch_claim=_require_bool(raw["batch_claim"], "batch_claim"),
80
+ long_polling=_require_bool(raw["long_polling"], "long_polling"),
81
+ max_claim_tasks=_require_int(raw["max_claim_tasks"], "max_claim_tasks"),
82
+ max_wait_seconds=_require_int(raw["max_wait_seconds"], "max_wait_seconds"),
83
+ payload_runtime_max_bytes=_require_int(
84
+ raw["payload_runtime_max_bytes"], "payload_runtime_max_bytes"
85
+ ),
86
+ payload_hard_max_bytes=_require_int(
87
+ raw["payload_hard_max_bytes"], "payload_hard_max_bytes"
88
+ ),
89
+ enqueue_dedup_ttl_seconds=_require_int(
90
+ raw["enqueue_dedup_ttl_seconds"], "enqueue_dedup_ttl_seconds"
91
+ ),
92
+ enqueue_dedup_ttl_min_seconds=_require_int(
93
+ raw["enqueue_dedup_ttl_min_seconds"], "enqueue_dedup_ttl_min_seconds"
94
+ ),
95
+ enqueue_dedup_ttl_max_seconds=_require_int(
96
+ raw["enqueue_dedup_ttl_max_seconds"], "enqueue_dedup_ttl_max_seconds"
97
+ ),
98
+ terminal_replay_ttl_seconds=_require_int(
99
+ raw["terminal_replay_ttl_seconds"], "terminal_replay_ttl_seconds"
100
+ ),
101
+ terminal_replay_ttl_min_seconds=_require_int(
102
+ raw["terminal_replay_ttl_min_seconds"], "terminal_replay_ttl_min_seconds"
103
+ ),
104
+ terminal_replay_ttl_max_seconds=_require_int(
105
+ raw["terminal_replay_ttl_max_seconds"], "terminal_replay_ttl_max_seconds"
106
+ ),
107
+ admin_replay_ttl_seconds=_require_int(
108
+ raw["admin_replay_ttl_seconds"], "admin_replay_ttl_seconds"
109
+ ),
110
+ admin_replay_ttl_min_seconds=_require_int(
111
+ raw["admin_replay_ttl_min_seconds"], "admin_replay_ttl_min_seconds"
112
+ ),
113
+ admin_replay_ttl_max_seconds=_require_int(
114
+ raw["admin_replay_ttl_max_seconds"], "admin_replay_ttl_max_seconds"
115
+ ),
116
+ extra=dict(extra),
117
+ )
118
+
119
+
120
+ def _require_str(value: object, name: str) -> str:
121
+ if not isinstance(value, str):
122
+ raise ValueError(f"{name} must be a string")
123
+ return value
124
+
125
+
126
+ def _require_int(value: object, name: str) -> int:
127
+ if isinstance(value, bool) or not isinstance(value, int):
128
+ raise ValueError(f"{name} must be an integer")
129
+ return value
130
+
131
+
132
+ def _require_bool(value: object, name: str) -> bool:
133
+ if not isinstance(value, bool):
134
+ raise ValueError(f"{name} must be a boolean")
135
+ return value
@@ -0,0 +1,92 @@
1
+ """Fail-closed capability gates for reserved protocol features."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from typing import Any
7
+
8
+ from _workhold_client_core.capabilities import Capabilities
9
+
10
+ __all__ = [
11
+ "require_batch_claim",
12
+ "require_delivery_events",
13
+ "require_long_polling",
14
+ ]
15
+
16
+
17
+ def require_long_polling(capabilities: Capabilities, wait_seconds: int) -> None:
18
+ """Require authenticated live long-polling for positive waits.
19
+
20
+ ``wait_seconds == 0`` bypasses the feature guard (immediate claim).
21
+ Malformed waits and incomplete advertisements fail closed before HTTP.
22
+ """
23
+
24
+ if isinstance(wait_seconds, bool) or type(wait_seconds) is not int:
25
+ raise ValueError("wait_seconds must be an integer")
26
+ if wait_seconds < 0:
27
+ raise ValueError("wait_seconds must be >= 0")
28
+ if wait_seconds == 0:
29
+ return
30
+ if not capabilities.long_polling:
31
+ raise ValueError("long_polling capability is not enabled")
32
+ if capabilities.max_wait_seconds <= 0:
33
+ raise ValueError(
34
+ "max_wait_seconds must be positive when long_polling is enabled"
35
+ )
36
+ if wait_seconds > capabilities.max_wait_seconds:
37
+ raise ValueError(
38
+ f"wait_seconds {wait_seconds} exceeds max_wait_seconds "
39
+ f"{capabilities.max_wait_seconds}"
40
+ )
41
+
42
+
43
+ def require_batch_claim(capabilities: Capabilities, max_tasks: int) -> None:
44
+ """Fail closed for ``max_tasks>1`` unless live ``batch_claim`` advertises it.
45
+
46
+ ``max_tasks == 1`` is always the MVP path and does not require batch_claim.
47
+ """
48
+
49
+ if isinstance(max_tasks, bool) or type(max_tasks) is not int:
50
+ raise ValueError("max_tasks must be an integer")
51
+ if max_tasks < 1:
52
+ raise ValueError("max_tasks must be >= 1")
53
+ if max_tasks == 1:
54
+ if not capabilities.batch_claim and capabilities.max_claim_tasks != 1:
55
+ raise ValueError("max_claim_tasks must be 1 while batch_claim is disabled")
56
+ if capabilities.batch_claim and capabilities.max_claim_tasks < 1:
57
+ raise ValueError(
58
+ "max_claim_tasks must be positive when batch_claim is enabled"
59
+ )
60
+ return
61
+ if not capabilities.batch_claim:
62
+ raise ValueError("batch_claim capability is not enabled")
63
+ if capabilities.max_claim_tasks < 1:
64
+ raise ValueError("max_claim_tasks must be positive when batch_claim is enabled")
65
+ if max_tasks > capabilities.max_claim_tasks:
66
+ raise ValueError(
67
+ f"max_tasks {max_tasks} exceeds max_claim_tasks "
68
+ f"{capabilities.max_claim_tasks}"
69
+ )
70
+
71
+
72
+ def require_delivery_events(
73
+ capabilities: Capabilities,
74
+ events: Sequence[Mapping[str, Any]] | None,
75
+ ) -> None:
76
+ """Fail closed for non-empty ``complete.events[]`` unless advertised.
77
+
78
+ Empty/omitted ``events`` bypasses the guard (MVP complete without events).
79
+ When enabled, each event must be a JSON object (reserved OpenAPI shape).
80
+ """
81
+
82
+ if events is None:
83
+ return
84
+ if not isinstance(events, Sequence) or isinstance(events, (str, bytes, bytearray)):
85
+ raise ValueError("events must be a sequence of objects")
86
+ if len(events) == 0:
87
+ return
88
+ if not capabilities.delivery_events:
89
+ raise ValueError("delivery_events capability is not enabled")
90
+ for index, event in enumerate(events):
91
+ if not isinstance(event, Mapping):
92
+ raise ValueError(f"events[{index}] must be an object")
@@ -0,0 +1,182 @@
1
+ """Opt-in typed payload codecs that preserve opaque JSON wire values."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from typing import Any, Generic, Protocol, TypeVar
8
+
9
+ from _workhold_client_core.errors import QueueClientError
10
+ from _workhold_client_core.models import Task
11
+
12
+ __all__ = [
13
+ "PayloadDecodeError",
14
+ "PayloadDecoder",
15
+ "PayloadEncoder",
16
+ "TypedClaimView",
17
+ "TypedTaskView",
18
+ "decode_payload",
19
+ "encode_payload",
20
+ "measure_json_bytes",
21
+ ]
22
+
23
+ T = TypeVar("T")
24
+ T_contra = TypeVar("T_contra", contravariant=True)
25
+ T_co = TypeVar("T_co", covariant=True)
26
+
27
+
28
+ class PayloadEncoder(Protocol[T_contra]):
29
+ """Encode an application value into a JSON-serializable wire payload."""
30
+
31
+ @property
32
+ def codec_name(self) -> str: ...
33
+
34
+ def encode(self, value: T_contra) -> Any: ...
35
+
36
+
37
+ class PayloadDecoder(Protocol[T_co]):
38
+ """Decode an opaque JSON wire payload into an application value."""
39
+
40
+ @property
41
+ def codec_name(self) -> str: ...
42
+
43
+ @property
44
+ def value_type(self) -> str: ...
45
+
46
+ def decode(self, raw: Any) -> T_co: ...
47
+
48
+
49
+ class PayloadDecodeError(QueueClientError):
50
+ """Bounded decode failure: codec/type metadata only; raw kept for recovery."""
51
+
52
+ def __init__(
53
+ self,
54
+ *,
55
+ codec_name: str,
56
+ value_type: str,
57
+ raw: Any,
58
+ reason: str = "decode_failed",
59
+ ) -> None:
60
+ self.codec_name = codec_name
61
+ self.value_type = value_type
62
+ self._raw = raw
63
+ self.reason = reason
64
+ super().__init__(reason)
65
+
66
+ @property
67
+ def raw(self) -> Any:
68
+ """Explicit recovery access to the opaque wire value (never logged here)."""
69
+
70
+ return self._raw
71
+
72
+ def __str__(self) -> str:
73
+ return (
74
+ f"PayloadDecodeError(codec_name={self.codec_name!r}, "
75
+ f"value_type={self.value_type!r}, reason={self.reason!r})"
76
+ )
77
+
78
+ def __repr__(self) -> str:
79
+ return self.__str__()
80
+
81
+
82
+ def measure_json_bytes(value: Any) -> int:
83
+ """Byte size of a JSON value using the same compact encoding as admission."""
84
+
85
+ return len(
86
+ json.dumps(
87
+ value,
88
+ ensure_ascii=False,
89
+ separators=(",", ":"),
90
+ allow_nan=False,
91
+ sort_keys=True,
92
+ ).encode("utf-8")
93
+ )
94
+
95
+
96
+ def encode_payload(
97
+ value: Any,
98
+ encoder: PayloadEncoder[Any] | None = None,
99
+ *,
100
+ max_bytes: int | None = None,
101
+ ) -> Any:
102
+ """Encode (optional) then enforce payload size before the HTTP request body.
103
+
104
+ Default ``encoder is None`` keeps the application value as the opaque wire
105
+ JSON payload unchanged.
106
+ """
107
+
108
+ wire = value if encoder is None else encoder.encode(value)
109
+ if max_bytes is not None:
110
+ if isinstance(max_bytes, bool) or type(max_bytes) is not int or max_bytes < 1:
111
+ raise ValueError("max_bytes must be a positive integer")
112
+ try:
113
+ size = measure_json_bytes(wire)
114
+ except (TypeError, ValueError) as exc:
115
+ raise ValueError("payload must be a JSON value") from exc
116
+ if size > max_bytes:
117
+ raise ValueError(
118
+ f"payload exceeds max_bytes ({size} > {max_bytes})"
119
+ )
120
+ return wire
121
+
122
+
123
+ def decode_payload(
124
+ raw: Any,
125
+ decoder: PayloadDecoder[T] | None = None,
126
+ ) -> Any:
127
+ """Decode an opaque wire payload; default returns the raw JSON value."""
128
+
129
+ if decoder is None:
130
+ return raw
131
+ try:
132
+ return decoder.decode(raw)
133
+ except PayloadDecodeError:
134
+ raise
135
+ except Exception as exc:
136
+ raise PayloadDecodeError(
137
+ codec_name=decoder.codec_name,
138
+ value_type=decoder.value_type,
139
+ raw=raw,
140
+ reason=type(exc).__name__,
141
+ ) from exc
142
+
143
+
144
+ @dataclass(frozen=True, slots=True)
145
+ class TypedTaskView(Generic[T]):
146
+ """Task plus decoded application payload; ``raw_payload`` is the wire value."""
147
+
148
+ task: Task
149
+ payload: T
150
+ raw_payload: Any
151
+
152
+ @classmethod
153
+ def from_task(cls, task: Task, decoder: PayloadDecoder[T]) -> TypedTaskView[T]:
154
+ raw = task.payload
155
+ return cls(task=task, payload=decode_payload(raw, decoder), raw_payload=raw)
156
+
157
+
158
+ @dataclass(frozen=True, slots=True)
159
+ class TypedClaimView(Generic[T]):
160
+ """Claim handle plus decoded application payload; wire value preserved."""
161
+
162
+ claim: Any
163
+ payload: T
164
+ raw_payload: Any
165
+
166
+ @classmethod
167
+ def from_claim(
168
+ cls,
169
+ claim: Any,
170
+ decoder: PayloadDecoder[T] | None = None,
171
+ ) -> TypedClaimView[T]:
172
+ raw = claim.task.payload
173
+ active = decoder
174
+ if active is None:
175
+ active = getattr(claim, "payload_decoder", None)
176
+ if active is None:
177
+ raise ValueError("payload_decoder is required when claim has none")
178
+ return cls(
179
+ claim=claim,
180
+ payload=decode_payload(raw, active),
181
+ raw_payload=raw,
182
+ )