ledgence-client 0.1.1__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.
- ledgence/client/__init__.py +52 -0
- ledgence/client/_version.py +3 -0
- ledgence/client/client.py +84 -0
- ledgence/client/cloud_events.py +158 -0
- ledgence/client/codec.py +199 -0
- ledgence/client/completion_models.py +221 -0
- ledgence/client/completions.py +164 -0
- ledgence/client/discovery.py +91 -0
- ledgence/client/errors.py +167 -0
- ledgence/client/models.py +337 -0
- ledgence/client/otel.py +116 -0
- ledgence/client/py.typed +0 -0
- ledgence/client/tasks.py +320 -0
- ledgence/client/transport.py +235 -0
- ledgence/client/workflow_models.py +198 -0
- ledgence/client/workflows.py +246 -0
- ledgence_client-0.1.1.dist-info/METADATA +506 -0
- ledgence_client-0.1.1.dist-info/RECORD +60 -0
- ledgence_client-0.1.1.dist-info/WHEEL +4 -0
- ledgence_client-0.1.1.dist-info/licenses/LICENSE +21 -0
- ledgence_client-0.1.1.dist-info/licenses/third_party/NOTICE.md +29 -0
- ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/aiohappyeyeballs-2.7.1/source/LICENSE +279 -0
- ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/aiohappyeyeballs-2.7.1/wheel/LICENSE +279 -0
- ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/aiohttp-3.14.3/source/LICENSE.txt +201 -0
- ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/aiohttp-3.14.3/source/vendor/llhttp/LICENSE +22 -0
- ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/aiohttp-3.14.3/wheel/LICENSE.txt +201 -0
- ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/aiohttp-3.14.3/wheel/vendor/llhttp/LICENSE +22 -0
- ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/aiosignal-1.4.0/source/LICENSE +201 -0
- ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/aiosignal-1.4.0/wheel/LICENSE +201 -0
- ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/attrs-26.1.0/source/LICENSE +21 -0
- ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/attrs-26.1.0/source/docs/license.md +13 -0
- ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/attrs-26.1.0/wheel/LICENSE +21 -0
- ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/flit-core-3.12.0/source/LICENSE +29 -0
- ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/flit-core-3.12.0/source/flit_core/vendor/tomli-1.2.3.dist-info/LICENSE +21 -0
- ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/flit-core-3.12.0/source/tests_core/samples/pep517/LICENSE +1 -0
- ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/flit-core-3.12.0/source/tests_core/samples/pep621/LICENSE +1 -0
- ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/flit-core-3.12.0/source/tests_core/samples/pep621_license_files/LICENSE +1 -0
- ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/flit-core-3.12.0/source/tests_core/samples/pep621_license_files/module/vendor/LICENSE_VENDOR +1 -0
- ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/flit-core-3.12.0/source/tests_core/samples/with_data_dir/LICENSE +1 -0
- ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/flit-core-3.12.0/wheel/LICENSE +29 -0
- ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/flit-core-3.12.0/wheel/flit_core/vendor/tomli-1.2.3.dist-info/LICENSE +21 -0
- ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/flit-core-3.12.0/wheel/vendor/tomli-1.2.3.dist-info/LICENSE +21 -0
- ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/frozenlist-1.8.0/source/LICENSE +201 -0
- ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/frozenlist-1.8.0/wheel/LICENSE +201 -0
- ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/idna-3.19/source/LICENSE.md +31 -0
- ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/idna-3.19/wheel/LICENSE.md +31 -0
- ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/multidict-6.8.0/source/LICENSE +201 -0
- ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/multidict-6.8.0/wheel/LICENSE +201 -0
- ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/opentelemetry-api-1.44.0/source/LICENSE +201 -0
- ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/opentelemetry-api-1.44.0/wheel/LICENSE +201 -0
- ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/propcache-0.5.2/source/LICENSE +202 -0
- ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/propcache-0.5.2/source/NOTICE +13 -0
- ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/propcache-0.5.2/wheel/LICENSE +202 -0
- ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/propcache-0.5.2/wheel/NOTICE +13 -0
- ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/typing-extensions-4.16.0/source/LICENSE +279 -0
- ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/typing-extensions-4.16.0/wheel/LICENSE +279 -0
- ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/yarl-1.24.5/source/LICENSE +202 -0
- ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/yarl-1.24.5/source/NOTICE +13 -0
- ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/yarl-1.24.5/wheel/LICENSE +202 -0
- ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/yarl-1.24.5/wheel/NOTICE +13 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Asynchronous task orchestration client for Ledgence (MIT)."""
|
|
2
|
+
from ._version import __version__
|
|
3
|
+
from .client import AsyncClient
|
|
4
|
+
from .errors import (
|
|
5
|
+
CancellationUncertain, ClientClosed, Conflict, InputError, LedgenceError, NotFound,
|
|
6
|
+
ProtocolError, RequestTimeout, ServiceError, SubmissionUncertain, TaskCancelled,
|
|
7
|
+
TaskFailed, TransportError, Unavailable, WaitTimeout,
|
|
8
|
+
)
|
|
9
|
+
from .models import (
|
|
10
|
+
ApplicationFailure, AttemptLost, Cancelled, ErrorDetail, ExecutionFailure, Failed,
|
|
11
|
+
Quiescence, RetryPolicy, Scope, Submission, Succeeded, TaskResult, TaskState,
|
|
12
|
+
TaskPage, TaskStatus, TraceContext,
|
|
13
|
+
)
|
|
14
|
+
from .tasks import TaskHandle
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"__version__", "AsyncClient", "TaskHandle", "Submission", "Scope", "RetryPolicy", "TraceContext",
|
|
18
|
+
"TaskState", "TaskStatus", "TaskPage", "TaskResult", "Quiescence", "Succeeded", "Failed", "Cancelled",
|
|
19
|
+
"ApplicationFailure", "ExecutionFailure", "AttemptLost", "ErrorDetail", "LedgenceError",
|
|
20
|
+
"InputError", "NotFound", "Conflict", "ServiceError", "TransportError", "ProtocolError",
|
|
21
|
+
"Unavailable", "RequestTimeout", "SubmissionUncertain", "CancellationUncertain",
|
|
22
|
+
"WaitTimeout", "TaskFailed", "TaskCancelled", "ClientClosed",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
from .errors import (
|
|
26
|
+
WorkflowCancellationUncertain, WorkflowCancelled, WorkflowEventUncertain, WorkflowFailed, WorkflowWaitTimeout,
|
|
27
|
+
)
|
|
28
|
+
from .workflow_models import (
|
|
29
|
+
WorkflowCancellation, WorkflowFailure, WorkflowResult, WorkflowState, WorkflowStatus,
|
|
30
|
+
WorkflowSubmission, WorkflowSucceeded, WorkflowEventCommand, WorkflowEventReceipt,
|
|
31
|
+
)
|
|
32
|
+
from .workflows import WorkflowHandle
|
|
33
|
+
|
|
34
|
+
__all__ += [
|
|
35
|
+
"WorkflowHandle", "WorkflowSubmission", "WorkflowState", "WorkflowStatus", "WorkflowResult",
|
|
36
|
+
"WorkflowSucceeded", "WorkflowFailure", "WorkflowCancellation", "WorkflowFailed",
|
|
37
|
+
"WorkflowCancelled", "WorkflowWaitTimeout", "WorkflowCancellationUncertain",
|
|
38
|
+
"WorkflowEventCommand", "WorkflowEventReceipt", "WorkflowEventUncertain",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
from .completion_models import (
|
|
42
|
+
CompletionRetryCommand, CompletionState, CompletionSubscribeCommand,
|
|
43
|
+
CompletionSubscription, CompletionTarget,
|
|
44
|
+
)
|
|
45
|
+
from .completions import CompletionSubscriptionHandle
|
|
46
|
+
from .errors import CompletionRetryUncertain, CompletionSubscriptionUncertain
|
|
47
|
+
|
|
48
|
+
__all__ += [
|
|
49
|
+
"CompletionTarget", "CompletionState", "CompletionSubscribeCommand", "CompletionRetryCommand",
|
|
50
|
+
"CompletionSubscription", "CompletionSubscriptionHandle", "CompletionSubscriptionUncertain",
|
|
51
|
+
"CompletionRetryUncertain",
|
|
52
|
+
]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""One explicitly owned asynchronous client for a scoped Ledgence endpoint."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
5
|
+
|
|
6
|
+
from . import codec
|
|
7
|
+
from .errors import ClientClosed, InputError
|
|
8
|
+
from .models import Scope
|
|
9
|
+
from .transport import _Transport
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _endpoint(value: str) -> str:
|
|
13
|
+
if type(value) is not str or any(ord(c) <= 0x20 or ord(c) == 0x7F for c in value):
|
|
14
|
+
raise InputError("base_url must be an HTTP(S) endpoint")
|
|
15
|
+
try:
|
|
16
|
+
parsed = urlsplit(value)
|
|
17
|
+
if parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username is not None:
|
|
18
|
+
raise ValueError("invalid endpoint")
|
|
19
|
+
if parsed.query or parsed.fragment:
|
|
20
|
+
raise ValueError("endpoint cannot contain query or fragment")
|
|
21
|
+
host = parsed.hostname.encode("idna").decode("ascii").lower()
|
|
22
|
+
if ":" in host:
|
|
23
|
+
host = f"[{host}]"
|
|
24
|
+
port = parsed.port
|
|
25
|
+
if port is not None and port != (80 if parsed.scheme == "http" else 443):
|
|
26
|
+
host += f":{port}"
|
|
27
|
+
return urlunsplit((parsed.scheme, host, parsed.path.rstrip("/"), "", ""))
|
|
28
|
+
except (ValueError, UnicodeError) as exc:
|
|
29
|
+
raise InputError("invalid base_url") from exc
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AsyncClient:
|
|
33
|
+
"""Async task and workflow client. Open with async with; all use stays on one event loop."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, base_url: str, *, tenant: str, namespace: str,
|
|
36
|
+
request_timeout: float = 30.0):
|
|
37
|
+
from .tasks import Tasks
|
|
38
|
+
from .workflows import Workflows
|
|
39
|
+
from .completions import Completions
|
|
40
|
+
self._base_url = _endpoint(base_url)
|
|
41
|
+
self._scope = Scope(tenant, namespace)
|
|
42
|
+
self._request_timeout = codec.duration(request_timeout, "request_timeout", 30.0)
|
|
43
|
+
self.tasks = Tasks(self)
|
|
44
|
+
self.workflows = Workflows(self)
|
|
45
|
+
self.completions = Completions(self)
|
|
46
|
+
self._transport: _Transport | None = None
|
|
47
|
+
self._closed = False
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def base_url(self) -> str:
|
|
51
|
+
return self._base_url
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def scope(self) -> Scope:
|
|
55
|
+
return self._scope
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def request_timeout(self) -> float:
|
|
59
|
+
return self._request_timeout
|
|
60
|
+
|
|
61
|
+
async def __aenter__(self) -> AsyncClient:
|
|
62
|
+
if self._closed or self._transport is not None:
|
|
63
|
+
raise ClientClosed("client cannot be reopened or entered twice")
|
|
64
|
+
self._transport = _Transport(self.base_url)
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
async def __aexit__(self, exc_type, exc, traceback):
|
|
68
|
+
await self.close()
|
|
69
|
+
|
|
70
|
+
async def close(self) -> None:
|
|
71
|
+
"""Release owned operations; remote tasks continue independently.
|
|
72
|
+
|
|
73
|
+
Running codec jobs retain ownership until complete. Cancellation of this
|
|
74
|
+
await leaves one owned close operation; await close() again to join it.
|
|
75
|
+
"""
|
|
76
|
+
self._closed = True
|
|
77
|
+
if self._transport is not None:
|
|
78
|
+
await self._transport.close()
|
|
79
|
+
|
|
80
|
+
def _require_transport(self) -> _Transport:
|
|
81
|
+
if self._closed or self._transport is None:
|
|
82
|
+
raise ClientClosed("use the client inside an async with block")
|
|
83
|
+
self._transport.check_open()
|
|
84
|
+
return self._transport
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Portable CloudEvent validation for external workflow events (MIT)."""
|
|
2
|
+
import calendar
|
|
3
|
+
import ipaddress
|
|
4
|
+
import re
|
|
5
|
+
import unicodedata
|
|
6
|
+
|
|
7
|
+
from . import codec
|
|
8
|
+
from .errors import InputError
|
|
9
|
+
|
|
10
|
+
EVENT_LIMIT = 64 * 1024
|
|
11
|
+
EVENT_COMMAND_LIMIT = 70 * 1024
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _validate_event_uri(value, *, absolute=False):
|
|
15
|
+
"""Validate RFC 3986 URI references without rewriting their identity."""
|
|
16
|
+
atom = r"(?:[A-Za-z0-9._~!$&'()*+,;=\-]|%[0-9A-Fa-f]{2})"
|
|
17
|
+
pchar = rf"(?:{atom}|[:@])"
|
|
18
|
+
path_query, fragment_mark, fragment = value.partition("#")
|
|
19
|
+
if absolute and fragment_mark:
|
|
20
|
+
raise InputError("dataschema must be an absolute URI without a fragment")
|
|
21
|
+
path, query_mark, query = path_query.partition("?")
|
|
22
|
+
if not re.fullmatch(rf"(?:{pchar}|[/?])*", fragment) or not re.fullmatch(rf"(?:{pchar}|[/?])*", query):
|
|
23
|
+
raise InputError("invalid CloudEvent URI query or fragment")
|
|
24
|
+
scheme = re.match(r"[A-Za-z][A-Za-z0-9+.-]*:", path)
|
|
25
|
+
if absolute and scheme is None:
|
|
26
|
+
raise InputError("dataschema must be an absolute URI")
|
|
27
|
+
if scheme is not None:
|
|
28
|
+
path = path[scheme.end():]
|
|
29
|
+
authority = path.startswith("//")
|
|
30
|
+
if authority:
|
|
31
|
+
host, slash, rest = path[2:].partition("/")
|
|
32
|
+
path = slash + rest
|
|
33
|
+
if "@" in host:
|
|
34
|
+
user, _, host = host.partition("@")
|
|
35
|
+
if not re.fullmatch(rf"(?:{atom}|:)*", user):
|
|
36
|
+
raise InputError("invalid CloudEvent URI user information")
|
|
37
|
+
if host.startswith("["):
|
|
38
|
+
address, close, port = host[1:].partition("]")
|
|
39
|
+
if not close or (port and not re.fullmatch(r":[0-9]*", port)):
|
|
40
|
+
raise InputError("invalid CloudEvent URI host")
|
|
41
|
+
if re.fullmatch(r"v[0-9A-Fa-f]+\.[A-Za-z0-9._~!$&'()*+,;=:\-]+", address, re.IGNORECASE) is None:
|
|
42
|
+
try:
|
|
43
|
+
if "%" in address:
|
|
44
|
+
raise ValueError("zone identifier is not an RFC3986 address")
|
|
45
|
+
ipaddress.IPv6Address(address)
|
|
46
|
+
except ValueError as exc:
|
|
47
|
+
raise InputError("invalid CloudEvent URI address") from exc
|
|
48
|
+
else:
|
|
49
|
+
name, colon, port = host.partition(":")
|
|
50
|
+
if not re.fullmatch(rf"{atom}*", name) or (colon and not re.fullmatch(r"[0-9]*", port)):
|
|
51
|
+
raise InputError("invalid CloudEvent URI host or port")
|
|
52
|
+
if not re.fullmatch(rf"(?:{pchar}|/)*", path):
|
|
53
|
+
raise InputError("invalid CloudEvent URI path")
|
|
54
|
+
if scheme is None and not authority and ":" in path.partition("/")[0]:
|
|
55
|
+
raise InputError("relative CloudEvent URI first segment cannot contain a colon")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _validate_event_trace(event):
|
|
59
|
+
trace = event.get("traceparent")
|
|
60
|
+
if trace is not None and (type(trace) is not str
|
|
61
|
+
or not re.fullmatch(r"00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}", trace)
|
|
62
|
+
or trace[3:35] == "0" * 32 or trace[36:52] == "0" * 16):
|
|
63
|
+
raise InputError("invalid CloudEvent traceparent")
|
|
64
|
+
if "tracestate" not in event:
|
|
65
|
+
return
|
|
66
|
+
state = event["tracestate"]
|
|
67
|
+
if trace is None or type(state) is not str or not state.isascii() or len(state) > 512:
|
|
68
|
+
raise InputError("invalid CloudEvent tracestate")
|
|
69
|
+
members = state.split(",")
|
|
70
|
+
if len(members) > 32:
|
|
71
|
+
raise InputError("too many CloudEvent tracestate members")
|
|
72
|
+
seen = set()
|
|
73
|
+
for member in members:
|
|
74
|
+
member = member.strip(" ")
|
|
75
|
+
if not member:
|
|
76
|
+
continue
|
|
77
|
+
key, equal, item = member.partition("=")
|
|
78
|
+
if (not equal or not item or len(item) > 256 or key in seen
|
|
79
|
+
or not re.fullmatch(r"(?:[a-z][a-z0-9_*/-]{0,255}|[a-z0-9][a-z0-9_*/-]{0,240}@[a-z][a-z0-9_*/-]{0,13})", key)
|
|
80
|
+
or any(not 0x20 <= ord(char) <= 0x7e or char in ",=" for char in item)):
|
|
81
|
+
raise InputError("invalid CloudEvent tracestate member")
|
|
82
|
+
seen.add(key)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _valid_event_leap_second(year, month, day, hour, minute, offset_minutes):
|
|
86
|
+
# Match time's final-UTC-second-of-a-month stand-in. Integer/calendar
|
|
87
|
+
# arithmetic preserves year0000 and rollover into UTC year-1, unlike datetime.
|
|
88
|
+
day_change, utc_minute = divmod(hour * 60 + minute - offset_minutes, 24 * 60)
|
|
89
|
+
if utc_minute != 23 * 60 + 59:
|
|
90
|
+
return False
|
|
91
|
+
day += day_change
|
|
92
|
+
if day < 1:
|
|
93
|
+
month -= 1
|
|
94
|
+
if month == 0:
|
|
95
|
+
year, month = year - 1, 12
|
|
96
|
+
day += calendar.monthrange(year, month)[1]
|
|
97
|
+
elif day > calendar.monthrange(year, month)[1]:
|
|
98
|
+
day -= calendar.monthrange(year, month)[1]
|
|
99
|
+
month += 1
|
|
100
|
+
if month == 13:
|
|
101
|
+
year, month = year + 1, 1
|
|
102
|
+
return -9999 <= year <= 9999 and day == calendar.monthrange(year, month)[1]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _validate_event_context(value):
|
|
106
|
+
# Keep this portable common profile aligned with worker-api's borrowed
|
|
107
|
+
# CloudEvent validator. Execution identity requirements do not apply here.
|
|
108
|
+
if type(value) is not dict:
|
|
109
|
+
raise InputError("CloudEvent must be an object")
|
|
110
|
+
for name, item in value.items():
|
|
111
|
+
if name == "data":
|
|
112
|
+
continue
|
|
113
|
+
if re.fullmatch(r"[a-z0-9]+", name) is None:
|
|
114
|
+
raise InputError("invalid CloudEvent context name")
|
|
115
|
+
if type(item) is str:
|
|
116
|
+
if any(unicodedata.category(c) == "Cc" or 0xFDD0 <= ord(c) <= 0xFDEF
|
|
117
|
+
or ord(c) & 0xFFFE == 0xFFFE for c in item):
|
|
118
|
+
raise InputError("invalid CloudEvent context string")
|
|
119
|
+
elif type(item) is not bool and (type(item) is not int or not -(1 << 31) <= item < (1 << 31)):
|
|
120
|
+
raise InputError("invalid CloudEvent context value")
|
|
121
|
+
if value.get("specversion") != "1.0":
|
|
122
|
+
raise InputError("CloudEvent requires version1.0")
|
|
123
|
+
for name in ("id", "source", "type"):
|
|
124
|
+
if type(value.get(name)) is not str or not value[name]:
|
|
125
|
+
raise InputError(f"CloudEvent requires nonempty {name}")
|
|
126
|
+
_validate_event_uri(value["source"])
|
|
127
|
+
for name in ("subject", "dataschema", "time"):
|
|
128
|
+
if name in value and (type(value[name]) is not str or not value[name]):
|
|
129
|
+
raise InputError(f"CloudEvent {name} must be a nonempty string")
|
|
130
|
+
if "dataschema" in value:
|
|
131
|
+
_validate_event_uri(value["dataschema"], absolute=True)
|
|
132
|
+
if "time" in value:
|
|
133
|
+
match = re.fullmatch(r"([0-9]{4})-([0-9]{2})-([0-9]{2})[Tt]([0-9]{2}):([0-9]{2}):([0-9]{2})(?:\.[0-9]+)?(?:[Zz]|([+-])([0-9]{2}):([0-9]{2}))", value["time"])
|
|
134
|
+
if match is None:
|
|
135
|
+
raise InputError("CloudEvent time must be RFC3339")
|
|
136
|
+
year, month, day, hour, minute, second = map(int, match.groups()[:6])
|
|
137
|
+
if (not 1 <= month <= 12 or not 1 <= day <= calendar.monthrange(year, month)[1]
|
|
138
|
+
or hour > 23 or minute > 59 or second > 60
|
|
139
|
+
or (match[7] is not None and (int(match[8]) > 23 or int(match[9]) > 59))):
|
|
140
|
+
raise InputError("invalid CloudEvent RFC3339 timestamp")
|
|
141
|
+
offset = 0 if match[7] is None else (int(match[8]) * 60 + int(match[9]))
|
|
142
|
+
if match[7] == "-":
|
|
143
|
+
offset = -offset
|
|
144
|
+
if second == 60 and not _valid_event_leap_second(year, month, day, hour, minute, offset):
|
|
145
|
+
raise InputError("CloudEvent leap second must be the final UTC second of a month")
|
|
146
|
+
_validate_event_trace(value)
|
|
147
|
+
return value
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def validate_event(value):
|
|
151
|
+
codec.encode(value, EVENT_LIMIT, max_depth=96)
|
|
152
|
+
_validate_event_context(value)
|
|
153
|
+
if value.get("datacontenttype") != "application/json" or "data" not in value:
|
|
154
|
+
raise InputError("CloudEvent requires JSON data")
|
|
155
|
+
if len(value["id"].encode("utf-8")) > 128 or len(value["source"].encode("utf-8")) > 2048:
|
|
156
|
+
raise InputError("CloudEvent id/source exceed their UTF-8 byte limits")
|
|
157
|
+
codec.encode(value["data"], EVENT_LIMIT, max_depth=64)
|
|
158
|
+
return value
|
ledgence/client/codec.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Strict bounded JSON matching the Rust wire profile; no user coercion hooks."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
import unicodedata
|
|
7
|
+
|
|
8
|
+
from .errors import InputError, ProtocolError
|
|
9
|
+
|
|
10
|
+
DATA_LIMIT = 1024 * 1024
|
|
11
|
+
CONTROL_LIMIT = 2 * 1024 * 1024
|
|
12
|
+
RESPONSE_LIMIT = 16 * 1024 * 1024
|
|
13
|
+
STATUS_LIMIT = 16 * 1024
|
|
14
|
+
TASK_PAGE_LIMIT = 2 * 1024 * 1024
|
|
15
|
+
MAX_DEPTH = 64
|
|
16
|
+
# Registered controller results allow 96 metadata containers plus HTTP wrappers.
|
|
17
|
+
# Endpoint parsers still enforce each application value's independent depth64.
|
|
18
|
+
MAX_RESPONSE_DEPTH = 108
|
|
19
|
+
# Milliseconds through the last instant with a four-digit RFC3339 year.
|
|
20
|
+
MAX_TIMESTAMP = 253402300799999
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def text(value, name: str, maximum: int = 128, *, empty: bool = False,
|
|
24
|
+
noncharacters: bool = False) -> str:
|
|
25
|
+
if type(value) is not str:
|
|
26
|
+
raise InputError(f"{name} must be a string")
|
|
27
|
+
try:
|
|
28
|
+
raw = value.encode("utf-8")
|
|
29
|
+
except UnicodeError as exc:
|
|
30
|
+
raise InputError(f"{name} must contain Unicode scalar values") from exc
|
|
31
|
+
if (not empty and not raw) or len(raw) > maximum or any(
|
|
32
|
+
unicodedata.category(c) == "Cc" or (
|
|
33
|
+
not noncharacters and (0xFDD0 <= ord(c) <= 0xFDEF or ord(c) & 0xFFFE == 0xFFFE)
|
|
34
|
+
) for c in value
|
|
35
|
+
):
|
|
36
|
+
raise InputError(f"invalid {name}")
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def integer(value, name: str, low: int = 0, high: int = (1 << 64) - 1) -> int:
|
|
41
|
+
if type(value) is not int or not low <= value <= high:
|
|
42
|
+
raise InputError(f"{name} must be an integer from {low} through {high}")
|
|
43
|
+
return value
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def duration(value, name: str, maximum: float | None = None) -> float:
|
|
47
|
+
if type(value) not in (int, float):
|
|
48
|
+
raise InputError(f"{name} must be a positive finite number")
|
|
49
|
+
try:
|
|
50
|
+
result = float(value)
|
|
51
|
+
except OverflowError as exc:
|
|
52
|
+
raise InputError(f"{name} is too large") from exc
|
|
53
|
+
if not math.isfinite(result) or result <= 0 or (maximum is not None and result > maximum):
|
|
54
|
+
raise InputError(f"invalid {name}")
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def validate(value, limit: int, *, max_depth: int = MAX_DEPTH) -> None:
|
|
59
|
+
"""Bound traversal as well as the eventual bytes, accepting plain JSON types."""
|
|
60
|
+
_validate(value, limit, max_depth=max_depth)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _validate(value, limit: int, *, max_depth: int, authoritative: bool = False) -> int:
|
|
64
|
+
remaining = limit
|
|
65
|
+
float_slack = 0
|
|
66
|
+
ancestors: set[int] = set()
|
|
67
|
+
|
|
68
|
+
def visit(item, depth):
|
|
69
|
+
nonlocal remaining, float_slack
|
|
70
|
+
remaining -= 1
|
|
71
|
+
if remaining < 0:
|
|
72
|
+
raise InputError("JSON value exceeds its byte limit")
|
|
73
|
+
kind = type(item)
|
|
74
|
+
if item is None or kind is bool:
|
|
75
|
+
return
|
|
76
|
+
if kind is str:
|
|
77
|
+
if len(item) > remaining:
|
|
78
|
+
raise InputError("JSON string exceeds its byte limit")
|
|
79
|
+
try:
|
|
80
|
+
remaining -= len(item.encode("utf-8"))
|
|
81
|
+
except UnicodeError as exc:
|
|
82
|
+
raise InputError("JSON strings must contain Unicode scalar values") from exc
|
|
83
|
+
elif kind is int:
|
|
84
|
+
integer(item, "JSON integer", -(1 << 63), (1 << 64) - 1)
|
|
85
|
+
elif kind is float:
|
|
86
|
+
if not math.isfinite(item):
|
|
87
|
+
raise InputError("JSON floating-point numbers must be finite")
|
|
88
|
+
if authoritative:
|
|
89
|
+
# Rust's finite float token uses at least three bytes. CPython
|
|
90
|
+
# may use longer text for the same value (1e-08 versus 1e-8).
|
|
91
|
+
# Bound each accepted token's possible expansion separately.
|
|
92
|
+
width = len(repr(item))
|
|
93
|
+
if width > 32:
|
|
94
|
+
raise InputError("unsupported floating-point representation")
|
|
95
|
+
float_slack += max(0, width - 3)
|
|
96
|
+
elif kind in (dict, list, tuple):
|
|
97
|
+
if depth >= max_depth:
|
|
98
|
+
raise InputError("JSON value exceeds its container depth limit")
|
|
99
|
+
identity = id(item)
|
|
100
|
+
if identity in ancestors:
|
|
101
|
+
raise InputError("JSON value contains a cycle")
|
|
102
|
+
ancestors.add(identity)
|
|
103
|
+
try:
|
|
104
|
+
if kind is dict:
|
|
105
|
+
for key, child in item.items():
|
|
106
|
+
if type(key) is not str:
|
|
107
|
+
raise InputError("JSON object keys must be strings")
|
|
108
|
+
visit(key, depth + 1)
|
|
109
|
+
visit(child, depth + 1)
|
|
110
|
+
else:
|
|
111
|
+
for child in item:
|
|
112
|
+
visit(child, depth + 1)
|
|
113
|
+
finally:
|
|
114
|
+
ancestors.remove(identity)
|
|
115
|
+
else:
|
|
116
|
+
raise InputError("value must contain only plain JSON-compatible types")
|
|
117
|
+
if remaining < 0:
|
|
118
|
+
raise InputError("JSON value exceeds its byte limit")
|
|
119
|
+
|
|
120
|
+
visit(value, 0)
|
|
121
|
+
return float_slack
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def validate_authoritative(value, limit: int, *, max_depth: int = MAX_DEPTH) -> None:
|
|
125
|
+
"""Check accepted Rust JSON without rejecting Python float text expansion.
|
|
126
|
+
|
|
127
|
+
Transport bounds the response bytes before decoding. Original numeric tokens
|
|
128
|
+
are no longer available here, so only inbound observations receive a bounded
|
|
129
|
+
per-float allowance. Structural, string, finite-number and depth checks remain
|
|
130
|
+
unchanged. Authored commands must continue to use strict encode().
|
|
131
|
+
"""
|
|
132
|
+
slack = _validate(value, limit, max_depth=max_depth, authoritative=True)
|
|
133
|
+
for _ in _encoded_chunks(value, limit + slack):
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def encode(value, limit: int = CONTROL_LIMIT, *, max_depth: int = MAX_DEPTH + 8) -> bytes:
|
|
138
|
+
validate(value, limit, max_depth=max_depth)
|
|
139
|
+
return b"".join(_encoded_chunks(value, limit))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _encoded_chunks(value, limit: int):
|
|
143
|
+
length = 0
|
|
144
|
+
encoder = json.JSONEncoder(ensure_ascii=False, allow_nan=False, separators=(",", ":"))
|
|
145
|
+
for part in encoder.iterencode(value):
|
|
146
|
+
chunk = part.encode("utf-8")
|
|
147
|
+
length += len(chunk)
|
|
148
|
+
if length > limit:
|
|
149
|
+
raise InputError("encoded JSON exceeds its byte limit")
|
|
150
|
+
yield chunk
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _pairs(pairs):
|
|
154
|
+
result = {}
|
|
155
|
+
for key, value in pairs:
|
|
156
|
+
if key in result:
|
|
157
|
+
raise ValueError("duplicate JSON object key")
|
|
158
|
+
result[key] = value
|
|
159
|
+
return result
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _int(token):
|
|
163
|
+
if token == "-0":
|
|
164
|
+
return -0.0
|
|
165
|
+
if len(token) > 20:
|
|
166
|
+
raise ValueError("integer token exceeds i64/u64")
|
|
167
|
+
result = int(token)
|
|
168
|
+
if not -(1 << 63) <= result <= (1 << 64) - 1:
|
|
169
|
+
raise ValueError("integer token exceeds i64/u64")
|
|
170
|
+
return result
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _float(token):
|
|
174
|
+
result = float(token)
|
|
175
|
+
if not math.isfinite(result):
|
|
176
|
+
raise ValueError("nonfinite JSON number")
|
|
177
|
+
return result
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _constant(_):
|
|
181
|
+
raise ValueError("nonfinite JSON number")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def decode(raw: bytes, limit: int = RESPONSE_LIMIT):
|
|
185
|
+
if len(raw) > limit:
|
|
186
|
+
raise ProtocolError("response exceeds its byte limit")
|
|
187
|
+
try:
|
|
188
|
+
value = json.loads(raw.decode("utf-8", errors="strict"), object_pairs_hook=_pairs,
|
|
189
|
+
parse_int=_int, parse_float=_float, parse_constant=_constant)
|
|
190
|
+
validate(value, limit, max_depth=MAX_RESPONSE_DEPTH)
|
|
191
|
+
return value
|
|
192
|
+
except (ValueError, UnicodeError, RecursionError) as exc:
|
|
193
|
+
raise ProtocolError("response is not valid Ledgence JSON") from exc
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def fields(value, required: set[str], optional: set[str] = frozenset()):
|
|
197
|
+
if type(value) is not dict or set(value) - required - optional or required - set(value):
|
|
198
|
+
raise InputError("invalid object fields")
|
|
199
|
+
return value
|