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.
Files changed (60) hide show
  1. ledgence/client/__init__.py +52 -0
  2. ledgence/client/_version.py +3 -0
  3. ledgence/client/client.py +84 -0
  4. ledgence/client/cloud_events.py +158 -0
  5. ledgence/client/codec.py +199 -0
  6. ledgence/client/completion_models.py +221 -0
  7. ledgence/client/completions.py +164 -0
  8. ledgence/client/discovery.py +91 -0
  9. ledgence/client/errors.py +167 -0
  10. ledgence/client/models.py +337 -0
  11. ledgence/client/otel.py +116 -0
  12. ledgence/client/py.typed +0 -0
  13. ledgence/client/tasks.py +320 -0
  14. ledgence/client/transport.py +235 -0
  15. ledgence/client/workflow_models.py +198 -0
  16. ledgence/client/workflows.py +246 -0
  17. ledgence_client-0.1.1.dist-info/METADATA +506 -0
  18. ledgence_client-0.1.1.dist-info/RECORD +60 -0
  19. ledgence_client-0.1.1.dist-info/WHEEL +4 -0
  20. ledgence_client-0.1.1.dist-info/licenses/LICENSE +21 -0
  21. ledgence_client-0.1.1.dist-info/licenses/third_party/NOTICE.md +29 -0
  22. ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/aiohappyeyeballs-2.7.1/source/LICENSE +279 -0
  23. ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/aiohappyeyeballs-2.7.1/wheel/LICENSE +279 -0
  24. ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/aiohttp-3.14.3/source/LICENSE.txt +201 -0
  25. ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/aiohttp-3.14.3/source/vendor/llhttp/LICENSE +22 -0
  26. ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/aiohttp-3.14.3/wheel/LICENSE.txt +201 -0
  27. ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/aiohttp-3.14.3/wheel/vendor/llhttp/LICENSE +22 -0
  28. ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/aiosignal-1.4.0/source/LICENSE +201 -0
  29. ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/aiosignal-1.4.0/wheel/LICENSE +201 -0
  30. ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/attrs-26.1.0/source/LICENSE +21 -0
  31. ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/attrs-26.1.0/source/docs/license.md +13 -0
  32. ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/attrs-26.1.0/wheel/LICENSE +21 -0
  33. ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/flit-core-3.12.0/source/LICENSE +29 -0
  34. 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
  35. ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/flit-core-3.12.0/source/tests_core/samples/pep517/LICENSE +1 -0
  36. ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/flit-core-3.12.0/source/tests_core/samples/pep621/LICENSE +1 -0
  37. 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
  38. 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
  39. 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
  40. ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/flit-core-3.12.0/wheel/LICENSE +29 -0
  41. 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
  42. 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
  43. ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/frozenlist-1.8.0/source/LICENSE +201 -0
  44. ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/frozenlist-1.8.0/wheel/LICENSE +201 -0
  45. ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/idna-3.19/source/LICENSE.md +31 -0
  46. ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/idna-3.19/wheel/LICENSE.md +31 -0
  47. ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/multidict-6.8.0/source/LICENSE +201 -0
  48. ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/multidict-6.8.0/wheel/LICENSE +201 -0
  49. ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/opentelemetry-api-1.44.0/source/LICENSE +201 -0
  50. ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/opentelemetry-api-1.44.0/wheel/LICENSE +201 -0
  51. ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/propcache-0.5.2/source/LICENSE +202 -0
  52. ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/propcache-0.5.2/source/NOTICE +13 -0
  53. ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/propcache-0.5.2/wheel/LICENSE +202 -0
  54. ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/propcache-0.5.2/wheel/NOTICE +13 -0
  55. ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/typing-extensions-4.16.0/source/LICENSE +279 -0
  56. ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/typing-extensions-4.16.0/wheel/LICENSE +279 -0
  57. ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/yarl-1.24.5/source/LICENSE +202 -0
  58. ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/yarl-1.24.5/source/NOTICE +13 -0
  59. ledgence_client-0.1.1.dist-info/licenses/third_party/licenses/yarl-1.24.5/wheel/LICENSE +202 -0
  60. 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,3 @@
1
+ """Distribution version shared by public diagnostics and tracing."""
2
+
3
+ __version__ = "0.1.1"
@@ -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
@@ -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