workhold-client-core 1.0.0__tar.gz
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-1.0.0/PKG-INFO +9 -0
- workhold_client_core-1.0.0/pyproject.toml +23 -0
- workhold_client_core-1.0.0/pyproject.toml.orig +21 -0
- workhold_client_core-1.0.0/src/_workhold_client_core/__init__.py +115 -0
- workhold_client_core-1.0.0/src/_workhold_client_core/async_transport.py +68 -0
- workhold_client_core-1.0.0/src/_workhold_client_core/capabilities.py +135 -0
- workhold_client_core-1.0.0/src/_workhold_client_core/capability_guard.py +92 -0
- workhold_client_core-1.0.0/src/_workhold_client_core/codecs.py +182 -0
- workhold_client_core-1.0.0/src/_workhold_client_core/config.py +145 -0
- workhold_client_core-1.0.0/src/_workhold_client_core/data/client-operation-ownership.json +652 -0
- workhold_client_core-1.0.0/src/_workhold_client_core/errors.py +172 -0
- workhold_client_core-1.0.0/src/_workhold_client_core/instrumentation.py +659 -0
- workhold_client_core-1.0.0/src/_workhold_client_core/models.py +451 -0
- workhold_client_core-1.0.0/src/_workhold_client_core/priority.py +30 -0
- workhold_client_core-1.0.0/src/_workhold_client_core/redaction.py +164 -0
- workhold_client_core-1.0.0/src/_workhold_client_core/requests.py +88 -0
- workhold_client_core-1.0.0/src/_workhold_client_core/retry.py +490 -0
- workhold_client_core-1.0.0/src/_workhold_client_core/transport.py +620 -0
- workhold_client_core-1.0.0/src/_workhold_client_core/transport_async.py +270 -0
- workhold_client_core-1.0.0/src/workhold_client_testing/__init__.py +119 -0
- workhold_client_core-1.0.0/src/workhold_client_testing/_secrets.py +105 -0
- workhold_client_core-1.0.0/src/workhold_client_testing/clock.py +63 -0
- workhold_client_core-1.0.0/src/workhold_client_testing/fixtures.py +150 -0
- workhold_client_core-1.0.0/src/workhold_client_testing/responses.py +103 -0
- workhold_client_core-1.0.0/src/workhold_client_testing/scenarios.py +154 -0
- workhold_client_core-1.0.0/src/workhold_client_testing/transport.py +299 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: workhold-client-core
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Private shared transport, errors, and wire models for Workhold role clients
|
|
5
|
+
Author: Mikhail Konin
|
|
6
|
+
Author-email: Mikhail Konin <dev.konin@gmail.com>
|
|
7
|
+
Requires-Dist: httpx>=0.28 ; extra == 'async'
|
|
8
|
+
Requires-Python: >=3.13
|
|
9
|
+
Provides-Extra: async
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "workhold-client-core"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Private shared transport, errors, and wire models for Workhold role clients"
|
|
5
|
+
requires-python = ">=3.13"
|
|
6
|
+
dependencies = []
|
|
7
|
+
|
|
8
|
+
[[project.authors]]
|
|
9
|
+
name = "Mikhail Konin"
|
|
10
|
+
email = "dev.konin@gmail.com"
|
|
11
|
+
|
|
12
|
+
[project.optional-dependencies]
|
|
13
|
+
async = ["httpx>=0.28"]
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["uv_build>=0.11.30,<0.12.0"]
|
|
17
|
+
build-backend = "uv_build"
|
|
18
|
+
|
|
19
|
+
[tool.uv.build-backend]
|
|
20
|
+
module-name = [
|
|
21
|
+
"_workhold_client_core",
|
|
22
|
+
"workhold_client_testing",
|
|
23
|
+
]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "workhold-client-core"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Private shared transport, errors, and wire models for Workhold role clients"
|
|
5
|
+
authors = [
|
|
6
|
+
{ name = "Mikhail Konin", email = "dev.konin@gmail.com" }
|
|
7
|
+
]
|
|
8
|
+
requires-python = ">=3.13"
|
|
9
|
+
dependencies = []
|
|
10
|
+
|
|
11
|
+
[project.optional-dependencies]
|
|
12
|
+
async = [
|
|
13
|
+
"httpx>=0.28",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["uv_build>=0.11.30,<0.12.0"]
|
|
18
|
+
build-backend = "uv_build"
|
|
19
|
+
|
|
20
|
+
[tool.uv.build-backend]
|
|
21
|
+
module-name = ["_workhold_client_core", "workhold_client_testing"]
|
|
@@ -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
|
+
)
|