@passioncode-ai/fabric-agent-adapter 0.39.1 → 0.41.0
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.
- package/CHANGELOG.md +203 -0
- package/README.md +18 -6
- package/SECURITY.md +27 -6
- package/bin/fabric-agent-adapter.js +71 -6
- package/package.json +2 -2
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +42 -66
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +55 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +9 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +15 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +181 -26
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +81 -23
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +42 -33
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +21 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +104 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +465 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +5 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +129 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +48 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +11 -184
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +288 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +848 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +7 -18
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +15 -46
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +11 -68
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +50 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +235 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +146 -32
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +404 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +408 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +91 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +882 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -39
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +336 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +113 -222
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +44 -405
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +140 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +119 -77
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +285 -109
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +32 -39
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +437 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1621 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +26 -13
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +122 -1392
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +278 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +539 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +324 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +173 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +491 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +11 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +2 -1
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py
CHANGED
|
@@ -18,6 +18,7 @@ Normative source: fabric-agent-contract docs/specification/interop.md (DEC-0016,
|
|
|
18
18
|
from __future__ import annotations
|
|
19
19
|
|
|
20
20
|
import base64
|
|
21
|
+
import hashlib
|
|
21
22
|
import json
|
|
22
23
|
from pathlib import Path
|
|
23
24
|
import re
|
|
@@ -27,6 +28,7 @@ from typing import Any, Callable, Dict, List, Optional
|
|
|
27
28
|
|
|
28
29
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
29
30
|
import fabric_service as _fs # noqa: E402
|
|
31
|
+
from fabric_base import store_lock # noqa: E402 - one lock per store path (R3)
|
|
30
32
|
|
|
31
33
|
PROTOCOL = "fabric-interop/0.1"
|
|
32
34
|
EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/interop/0.1"
|
|
@@ -49,11 +51,11 @@ JOB_HANDLE_SCHEMA: Dict[str, Any] = {
|
|
|
49
51
|
}
|
|
50
52
|
TERMINAL = ("completed", "failed", "cancelled")
|
|
51
53
|
|
|
52
|
-
_TRACEPARENT = re.compile(r"^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})
|
|
53
|
-
_JOB_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}
|
|
54
|
-
_KEY = re.compile(r"^[A-Za-z0-9_.-]{1,64}
|
|
54
|
+
_TRACEPARENT = re.compile(r"^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})\Z")
|
|
55
|
+
_JOB_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}\Z")
|
|
56
|
+
_KEY = re.compile(r"^[A-Za-z0-9_.-]{1,64}\Z")
|
|
55
57
|
_SECRET_WORDS = re.compile(r"pass(word|phrase)|secret|api[\s_-]?key|access[\s_-]?key|private[\s_-]?key|"
|
|
56
|
-
r"(access|auth|bearer|refresh|session)[\s_-]?token|^token
|
|
58
|
+
r"(access|auth|bearer|refresh|session)[\s_-]?token|^token\Z|credential", re.IGNORECASE)
|
|
57
59
|
|
|
58
60
|
|
|
59
61
|
class InteropError(_fs.ServiceError):
|
|
@@ -255,12 +257,7 @@ def _own(value: Any, key: str) -> Any:
|
|
|
255
257
|
return value.get(key, _ABSENT) if isinstance(value, dict) else _ABSENT
|
|
256
258
|
|
|
257
259
|
|
|
258
|
-
|
|
259
|
-
year -= month <= 2
|
|
260
|
-
era = (year if year >= 0 else year - 399) // 400
|
|
261
|
-
yoe = year - era * 400
|
|
262
|
-
doy = (153 * (month + (-3 if month > 2 else 9)) + 2) // 5 + day - 1
|
|
263
|
-
return era * 146097 + yoe * 365 + yoe // 4 - yoe // 100 + doy - 719468
|
|
260
|
+
from fabric_time import days_from_civil as _days_from_civil # noqa: E402 - the core's (FAA-65)
|
|
264
261
|
|
|
265
262
|
|
|
266
263
|
def utc_time(value: Any) -> Optional[int]:
|
|
@@ -370,7 +367,6 @@ def intent_idempotency_key(intent: Any) -> Optional[str]:
|
|
|
370
367
|
"""The idempotency key of an intent {agentId, capability, input, request?}: a UUID of version 8 from the first 16
|
|
371
368
|
bytes of the SHA-256 of its RFC 8785 form. The same intent gives the same key on every resend and machine; None
|
|
372
369
|
for another shape or an input with no RFC 8785 form."""
|
|
373
|
-
import hashlib
|
|
374
370
|
import fabric_delegation as _fd # noqa: E402 - beside this file: RFC 8785
|
|
375
371
|
names = ("agentId", "capability", "input", "request")
|
|
376
372
|
if not isinstance(intent, dict) or any(name not in names for name in intent):
|
|
@@ -474,6 +470,16 @@ def pause_message(resets_at: Optional[str] = None, text: Optional[str] = None) -
|
|
|
474
470
|
return message
|
|
475
471
|
|
|
476
472
|
|
|
473
|
+
def intent_digest(capability: str, arguments: Any) -> str:
|
|
474
|
+
"""What a key is kept beside (FAA-77): the SHA-256 of the RFC 8785 form of [capability, input], so a key admits
|
|
475
|
+
only the intent it was first sent with. An input with no RFC 8785 form is refused."""
|
|
476
|
+
import fabric_delegation as _fd # noqa: E402 - beside this file: RFC 8785
|
|
477
|
+
try:
|
|
478
|
+
return "sha256:" + hashlib.sha256(_fd.jcs([capability, arguments]).encode("utf-8")).hexdigest()
|
|
479
|
+
except (TypeError, ValueError, RecursionError):
|
|
480
|
+
raise InteropError("A job's input with an idempotency key needs a JSON form (RFC 8785).") from None
|
|
481
|
+
|
|
482
|
+
|
|
477
483
|
def repeated_key_decision(job: Any, now: Any, limit_holds: Optional[bool] = None, approval_granted: bool = False) -> str:
|
|
478
484
|
"""The provider's answer to a repeated key: "run" (the same intent runs again, once) or "same" (answer with that job).
|
|
479
485
|
A quota-exhausted job runs again once the limit no longer holds: `limit_holds` False when the provider knows it, else
|
|
@@ -530,17 +536,17 @@ def url_request(message: str, url: str) -> Dict[str, Any]:
|
|
|
530
536
|
|
|
531
537
|
# --- C3.2 jobs ---------------------------------------------------------------------
|
|
532
538
|
|
|
533
|
-
|
|
534
|
-
"""Durable jobs, one private JSON file each under `directory`, written atomically.
|
|
539
|
+
_SURROGATE = re.compile("[\ud800-\udfff]")
|
|
535
540
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
"""
|
|
541
|
+
|
|
542
|
+
class JobStore:
|
|
543
|
+
"""Durable jobs, one private JSON file each under `directory`, written atomically and under the directory's lock.
|
|
544
|
+
A job id survives a restart. The file holds the public `job` object exactly as fabric.job.get returns it, and a
|
|
545
|
+
`private` part (the capability, its input, the trace) that is never returned; the Node kit uses the same format."""
|
|
541
546
|
|
|
542
547
|
def __init__(self, directory: Path):
|
|
543
548
|
self.dir = Path(directory)
|
|
549
|
+
self._lock = store_lock(self.dir)
|
|
544
550
|
|
|
545
551
|
def _path(self, job_id: str) -> Path:
|
|
546
552
|
if not _JOB_ID.match(job_id or ""):
|
|
@@ -567,48 +573,76 @@ class JobStore:
|
|
|
567
573
|
limit_holds: Optional[bool] = None, approval_granted: bool = False) -> Dict[str, Any]:
|
|
568
574
|
"""Start a job; returns the handle a job-capable tool puts in structuredContent.
|
|
569
575
|
|
|
570
|
-
With `idempotency_key` the key is kept per `caller` (the authenticated caller, DEC-0087)
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
if
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
576
|
+
With `idempotency_key` the key is kept per `caller` (the authenticated caller, DEC-0087) beside the digest of the
|
|
577
|
+
intent (the capability and its input): a key that names a job of another intent raises `idempotency-conflict`;
|
|
578
|
+
one that names a job of this intent starts nothing and raises InteropError, unless `repeat` would answer "run"
|
|
579
|
+
(a limit no longer holds, an approval granted); then the key names the new job. A 0.40.0 key file: see `repeat`."""
|
|
580
|
+
with self._lock:
|
|
581
|
+
intent = None
|
|
582
|
+
if idempotency_key is not None:
|
|
583
|
+
intent = intent_digest(capability, arguments)
|
|
584
|
+
decision = self.repeat(caller, idempotency_key, now if now is not None else _fs.now_iso(), limit_holds=limit_holds,
|
|
585
|
+
approval_granted=approval_granted, capability=capability, arguments=arguments)
|
|
586
|
+
if decision["decision"] == "conflict":
|
|
587
|
+
raise InteropError("idempotency-conflict: key %s names a job of another intent; send the same capability and input, or a new key." % idempotency_key)
|
|
588
|
+
if decision["decision"] == "same":
|
|
589
|
+
raise InteropError("Key %s already names job %s; answer with that job (JobStore.repeat)." % (idempotency_key, decision["job"]["id"]))
|
|
590
|
+
job_id = "job_" + secrets.token_hex(12)
|
|
591
|
+
job: Dict[str, Any] = {"id": job_id, "status": "working"}
|
|
592
|
+
if poll_interval_ms:
|
|
593
|
+
job["pollIntervalMs"] = int(poll_interval_ms)
|
|
594
|
+
private: Dict[str, Any] = {"capability": capability, "input": arguments,
|
|
595
|
+
"traceparent": traceparent if parse_traceparent(traceparent) else None}
|
|
596
|
+
if delegation: # DEC-0039: who started it; only they read, cancel or answer it
|
|
597
|
+
private["delegation"] = {"principal": delegation.get("principal"), "caller": delegation.get("caller")}
|
|
598
|
+
self._save({"job": job, "private": private})
|
|
599
|
+
if idempotency_key is not None:
|
|
600
|
+
_fs.atomic_write(self._key_path(caller, idempotency_key), (json.dumps({"job": job_id, "intent": intent}) + "\n").encode(), 0o600)
|
|
601
|
+
return {"job": {"id": job_id, "status": "working"}}
|
|
590
602
|
|
|
591
603
|
def _key_path(self, caller: Optional[str], key: str) -> Path:
|
|
592
|
-
import hashlib
|
|
593
604
|
if not isinstance(key, str) or not key:
|
|
594
605
|
raise InteropError("An idempotency key is a non-empty string.")
|
|
606
|
+
if _SURROGATE.search(key) or (isinstance(caller, str) and _SURROGATE.search(caller)):
|
|
607
|
+
raise InteropError("An idempotency key and its caller are Unicode text; a lone surrogate is refused.")
|
|
595
608
|
name = hashlib.sha256(json.dumps([caller, key], separators=(",", ":"), ensure_ascii=False).encode("utf-8")).hexdigest() # as JSON.stringify writes it
|
|
596
609
|
directory = self.dir / "keys"
|
|
597
610
|
_fs.ensure_private_dir(directory)
|
|
598
611
|
return directory / ("%s.json" % name)
|
|
599
612
|
|
|
613
|
+
def _key_record(self, caller: Optional[str], key: str) -> Optional[Dict[str, Any]]:
|
|
614
|
+
try:
|
|
615
|
+
record = json.loads(self._key_path(caller, key).read_text(encoding="utf-8"))
|
|
616
|
+
except FileNotFoundError:
|
|
617
|
+
return None
|
|
618
|
+
except ValueError:
|
|
619
|
+
record = None
|
|
620
|
+
if not isinstance(record, dict):
|
|
621
|
+
raise InteropError("Key %s is unreadable on disk." % key)
|
|
622
|
+
return record
|
|
623
|
+
|
|
600
624
|
def repeat(self, caller: Optional[str], key: str, now: Any, limit_holds: Optional[bool] = None,
|
|
601
|
-
approval_granted: bool = False) -> Dict[str, Any]:
|
|
602
|
-
"""The answer to a key this caller sent before (DEC-0087): {"decision": "new"|"same"|"run", "job":
|
|
603
|
-
|
|
625
|
+
approval_granted: bool = False, *, capability: Optional[str] = None, arguments: Any = None) -> Dict[str, Any]:
|
|
626
|
+
"""The answer to a key this caller sent before (DEC-0087): {"decision": "new"|"same"|"run"|"conflict", "job"}. "same": answer with
|
|
627
|
+
that job, never run it again (outcome-unknown above all); "run": the intent runs again, once; "conflict": given `capability` and
|
|
628
|
+
its input `arguments`, the key was kept beside another intent (FAA-77). A 0.40.0 key file ({"job": id}) never answers "conflict"."""
|
|
629
|
+
record = self._key_record(caller, key)
|
|
630
|
+
if record is None:
|
|
631
|
+
return {"decision": "new", "job": None}
|
|
604
632
|
try:
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
except (FileNotFoundError, UnknownJob):
|
|
633
|
+
job = self.get(record["job"])
|
|
634
|
+
except UnknownJob:
|
|
608
635
|
return {"decision": "new", "job": None}
|
|
609
|
-
except (
|
|
636
|
+
except (KeyError, TypeError):
|
|
610
637
|
raise InteropError("Key %s is unreadable on disk." % key) from None
|
|
611
|
-
|
|
638
|
+
decision = repeated_key_decision(job, now, limit_holds, approval_granted)
|
|
639
|
+
if capability is not None:
|
|
640
|
+
intent = intent_digest(capability, arguments)
|
|
641
|
+
if "intent" in record:
|
|
642
|
+
decision = "conflict" if record["intent"] != intent else decision
|
|
643
|
+
elif decision == "run": # a 0.40.0 key runs only the intent its job stored (an input with no JSON form is refused)
|
|
644
|
+
decision = "run" if intent_digest(*map(self.private(record["job"]).get, ("capability", "input"))) == intent else "same"
|
|
645
|
+
return {"decision": decision, "job": job}
|
|
612
646
|
|
|
613
647
|
def get(self, job_id: str) -> Dict[str, Any]:
|
|
614
648
|
return dict(self._load(job_id)["job"])
|
|
@@ -620,15 +654,16 @@ class JobStore:
|
|
|
620
654
|
return self._load(job_id)["private"].get("traceparent")
|
|
621
655
|
|
|
622
656
|
def _transition(self, job_id: str, status: str, **fields: Any) -> Dict[str, Any]:
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
657
|
+
with self._lock:
|
|
658
|
+
record = self._load(job_id)
|
|
659
|
+
job = record["job"]
|
|
660
|
+
if job["status"] in TERMINAL:
|
|
661
|
+
raise InteropError("Job %s is already %s; a terminal job does not change." % (job_id, job["status"]))
|
|
662
|
+
for key in ("inputRequests", "result", "error", "statusMessage"):
|
|
663
|
+
job.pop(key, None)
|
|
664
|
+
job["status"] = status
|
|
665
|
+
job.update({key: value for key, value in fields.items() if value is not None})
|
|
666
|
+
return self._save(record)
|
|
632
667
|
|
|
633
668
|
def working(self, job_id: str, message: Optional[str] = None) -> Dict[str, Any]:
|
|
634
669
|
return self._transition(job_id, "working", statusMessage=message)
|
|
@@ -646,15 +681,16 @@ class JobStore:
|
|
|
646
681
|
"""Apply inputResponses to an input_required job; unknown keys are ignored (MCP Tasks rule).
|
|
647
682
|
|
|
648
683
|
Returns the responses that matched; when any did, the job is working again."""
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
684
|
+
with self._lock:
|
|
685
|
+
job = self.get(job_id)
|
|
686
|
+
if job["status"] != "input_required":
|
|
687
|
+
return {}
|
|
688
|
+
matched = {key: value for key, value in (input_responses or {}).items()
|
|
689
|
+
if key in job.get("inputRequests", {}) and isinstance(value, dict)
|
|
690
|
+
and value.get("action") in ("accept", "decline", "cancel")}
|
|
691
|
+
if matched:
|
|
692
|
+
self.working(job_id)
|
|
693
|
+
return matched
|
|
658
694
|
|
|
659
695
|
def complete(self, job_id: str, envelope: Dict[str, Any]) -> Dict[str, Any]:
|
|
660
696
|
"""The envelope carries the job's trace, authoritative for the stored result (DEC-0017); one that names
|
|
@@ -680,13 +716,10 @@ class JobStore:
|
|
|
680
716
|
"""A job a limit paused and its provider will resume (DEC-0087): working, its statusMessage in the pause grammar,
|
|
681
717
|
and pollIntervalMs reaching the reset within its cap of one hour."""
|
|
682
718
|
message = pause_message(resets_at, text)
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
record["job"]["pollIntervalMs"] = int(min(wait, 3600000))
|
|
688
|
-
self._save(record)
|
|
689
|
-
return self._transition(job_id, "working", statusMessage=message)
|
|
719
|
+
wait = utc_time(resets_at) - utc_time(_fs.now_iso()) if resets_at is not None else 0
|
|
720
|
+
# One write, after the terminal check: a terminal job does not change, pollIntervalMs included (finding 11).
|
|
721
|
+
return self._transition(job_id, "working", statusMessage=message,
|
|
722
|
+
pollIntervalMs=int(min(wait, 3600000)) if wait > 0 else None)
|
|
690
723
|
|
|
691
724
|
def quota_exhausted(self, job_id: str, message: str, retryable: bool, resets_at: Optional[str] = None) -> Dict[str, Any]:
|
|
692
725
|
"""A job a limit stopped and its provider will not resume: failed, quota-exhausted, data {retryable, resetsAt?}."""
|
|
@@ -791,7 +824,8 @@ def mcp_header_problem(message: Any, headers: Any) -> Optional[str]:
|
|
|
791
824
|
return "Mcp-Method %s disagrees with the body's method %s" % (sent, method)
|
|
792
825
|
field = NAMED_METHODS.get(str(method))
|
|
793
826
|
if field:
|
|
794
|
-
|
|
827
|
+
params = message.get("params")
|
|
828
|
+
value = params.get(field) if isinstance(params, dict) else None
|
|
795
829
|
named = header("Mcp-Name")
|
|
796
830
|
if named is None and declared:
|
|
797
831
|
return "the body carries params.%s but the required Mcp-Name header is absent" % field
|
|
@@ -878,8 +912,11 @@ class McpToolServer:
|
|
|
878
912
|
if "id" not in message:
|
|
879
913
|
return None # a notification gets no response
|
|
880
914
|
rid = message["id"]
|
|
881
|
-
params = message.get("params")
|
|
882
|
-
meta = params.get("_meta")
|
|
915
|
+
params = {} if message.get("params") is None else message["params"]
|
|
916
|
+
meta = params.get("_meta") if isinstance(params, dict) else None
|
|
917
|
+
meta = {} if meta is None else meta
|
|
918
|
+
if not isinstance(params, dict) or not isinstance(meta, dict): # finding 17: answered, never a crash
|
|
919
|
+
return self._error(rid, -32602, "params and params._meta are JSON objects.")
|
|
883
920
|
span = child_traceparent(meta.get("traceparent"))
|
|
884
921
|
method = message["method"]
|
|
885
922
|
if method == "initialize":
|
|
@@ -898,7 +935,9 @@ class McpToolServer:
|
|
|
898
935
|
if method != "tools/call":
|
|
899
936
|
return self._error(rid, -32601, "Method %s is not served here." % method)
|
|
900
937
|
name = params.get("name")
|
|
901
|
-
arguments = params.get("arguments")
|
|
938
|
+
arguments = {} if params.get("arguments") is None else params["arguments"]
|
|
939
|
+
if not isinstance(name, str) or not isinstance(arguments, dict):
|
|
940
|
+
return self._error(rid, -32602, "A tool call names its tool as a string and passes its arguments as a JSON object.")
|
|
902
941
|
if name in ("fabric.job.get", "fabric.job.cancel") and self.jobs is not None:
|
|
903
942
|
verdict = None
|
|
904
943
|
if self._authorize is not None:
|
|
@@ -941,7 +980,10 @@ class McpToolServer:
|
|
|
941
980
|
if name == "fabric.job.cancel":
|
|
942
981
|
state = self.jobs.get(job_id)
|
|
943
982
|
if state["status"] not in TERMINAL:
|
|
944
|
-
|
|
983
|
+
try:
|
|
984
|
+
state = self.jobs.cancel(job_id)
|
|
985
|
+
except InteropError: # it ended meanwhile: answer as it ended
|
|
986
|
+
state = self.jobs.get(job_id)
|
|
945
987
|
return tool_result({"job": state}, traceparent=job_span)
|
|
946
988
|
responses = arguments.get("inputResponses")
|
|
947
989
|
if responses:
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""JavaScript's view of JSON values, once for the whole kit (FAA-65, layer L0).
|
|
2
|
+
|
|
3
|
+
The contract's answers are computed by TypeScript, so a kit that reads JSON the way Python does disagrees with them on
|
|
4
|
+
the edges: `5.0` is a count, an integer beyond a double is Infinity, `String({"toString": 1})` throws, object keys
|
|
5
|
+
that look like array indexes come first. Every reader in the kit takes those semantics from here:
|
|
6
|
+
|
|
7
|
+
- ``js_string`` (``String()``), ``show`` (the contract's ``show``: ``String()``, or JSON where it would throw),
|
|
8
|
+
``json_stringify`` (``JSON.stringify``), ``js_to_number`` (``Number()``), ``double``, ``safe_integer``;
|
|
9
|
+
- ``same`` (``===``) and ``contains``, ``get`` with the ``MISSING`` sentinel for an absent member;
|
|
10
|
+
- ``jcs`` (RFC 8785 canonical JSON) and the number formatting it shares with ``js_string``.
|
|
11
|
+
|
|
12
|
+
Standard library only; nothing here imports another kit module.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import math
|
|
17
|
+
import re
|
|
18
|
+
from typing import Any, Dict, List, Tuple
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def js_number_string(value: float) -> str:
|
|
22
|
+
"""ECMAScript Number::toString, which RFC 8785 §3.2.2.3 prescribes."""
|
|
23
|
+
if value == 0:
|
|
24
|
+
return "0"
|
|
25
|
+
if value < 0:
|
|
26
|
+
return "-" + js_number_string(-value)
|
|
27
|
+
digits, exponent = _shortest(value)
|
|
28
|
+
k, n = len(digits), len(digits) + exponent
|
|
29
|
+
if k <= n <= 21:
|
|
30
|
+
return digits + "0" * (n - k)
|
|
31
|
+
if 0 < n <= 21:
|
|
32
|
+
return digits[:n] + "." + digits[n:]
|
|
33
|
+
if -6 < n <= 0:
|
|
34
|
+
return "0." + "0" * (-n) + digits
|
|
35
|
+
e = n - 1
|
|
36
|
+
mantissa = digits if k == 1 else digits[0] + "." + digits[1:]
|
|
37
|
+
return "%se%s%d" % (mantissa, "+" if e >= 0 else "-", abs(e))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _shortest(value: float) -> Tuple[str, int]:
|
|
41
|
+
"""The shortest round-trip digits of a positive finite double (Python's repr is shortest round-trip)."""
|
|
42
|
+
text = repr(value)
|
|
43
|
+
mantissa, _, exp = text.partition("e")
|
|
44
|
+
whole, _, frac = mantissa.partition(".")
|
|
45
|
+
digits = (whole + frac).lstrip("0")
|
|
46
|
+
exponent = (int(exp) if exp else 0) - len(frac)
|
|
47
|
+
if not digits:
|
|
48
|
+
return "0", 0
|
|
49
|
+
stripped = digits.rstrip("0")
|
|
50
|
+
exponent += len(digits) - len(stripped)
|
|
51
|
+
return stripped, exponent
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
JSON_ESCAPES = {'"': '\\"', "\\": "\\\\", "\b": "\\b", "\f": "\\f", "\n": "\\n", "\r": "\\r", "\t": "\\t"}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def json_string_literal(value: str) -> str:
|
|
58
|
+
out = ['"']
|
|
59
|
+
for ch in value:
|
|
60
|
+
code = ord(ch)
|
|
61
|
+
if 0xD800 <= code <= 0xDFFF:
|
|
62
|
+
raise TypeError("a lone surrogate has no canonical form")
|
|
63
|
+
if ch in JSON_ESCAPES:
|
|
64
|
+
out.append(JSON_ESCAPES[ch])
|
|
65
|
+
elif code < 0x20:
|
|
66
|
+
out.append("\\u%04x" % code)
|
|
67
|
+
else:
|
|
68
|
+
out.append(ch)
|
|
69
|
+
out.append('"')
|
|
70
|
+
return "".join(out)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def jcs(value: Any) -> str:
|
|
74
|
+
"""RFC 8785: members sorted by UTF-16 code units, numbers in ECMAScript form, strings as JSON.stringify
|
|
75
|
+
escapes them. Refuses what has no canonical form: a non-finite number, a lone surrogate, a non-JSON value.
|
|
76
|
+
A JSON number is a double: an integer beyond 2**53 is written as JavaScript would write it."""
|
|
77
|
+
if value is None:
|
|
78
|
+
return "null"
|
|
79
|
+
if value is True:
|
|
80
|
+
return "true"
|
|
81
|
+
if value is False:
|
|
82
|
+
return "false"
|
|
83
|
+
if isinstance(value, int):
|
|
84
|
+
if abs(value) < 2 ** 53:
|
|
85
|
+
return str(value)
|
|
86
|
+
try:
|
|
87
|
+
return js_number_string(float(value))
|
|
88
|
+
except OverflowError:
|
|
89
|
+
raise TypeError("a number beyond a double has no canonical form") from None
|
|
90
|
+
if isinstance(value, float):
|
|
91
|
+
if not math.isfinite(value):
|
|
92
|
+
raise TypeError("a non-finite number has no canonical form")
|
|
93
|
+
return js_number_string(value)
|
|
94
|
+
if isinstance(value, str):
|
|
95
|
+
return json_string_literal(value)
|
|
96
|
+
if isinstance(value, list):
|
|
97
|
+
return "[" + ",".join(jcs(item) for item in value) + "]"
|
|
98
|
+
if isinstance(value, dict):
|
|
99
|
+
if not all(isinstance(key, str) for key in value):
|
|
100
|
+
raise TypeError("only string member names have a canonical form")
|
|
101
|
+
keys = sorted(value, key=lambda key: key.encode("utf-16-be", "surrogatepass"))
|
|
102
|
+
return "{" + ",".join(json_string_literal(key) + ":" + jcs(value[key]) for key in keys) + "}"
|
|
103
|
+
raise TypeError("%s has no canonical form" % type(value).__name__)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
MISSING = object()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def js_string(value: Any) -> str:
|
|
110
|
+
"""String(value) for a JSON value."""
|
|
111
|
+
if value is MISSING:
|
|
112
|
+
return "undefined"
|
|
113
|
+
if value is None:
|
|
114
|
+
return "null"
|
|
115
|
+
if value is True:
|
|
116
|
+
return "true"
|
|
117
|
+
if value is False:
|
|
118
|
+
return "false"
|
|
119
|
+
if isinstance(value, float):
|
|
120
|
+
if math.isnan(value):
|
|
121
|
+
return "NaN"
|
|
122
|
+
if math.isinf(value):
|
|
123
|
+
return "Infinity" if value > 0 else "-Infinity"
|
|
124
|
+
return js_number_string(value)
|
|
125
|
+
if isinstance(value, int):
|
|
126
|
+
if abs(value) < 2 ** 53:
|
|
127
|
+
return str(value)
|
|
128
|
+
try:
|
|
129
|
+
return js_number_string(float(value))
|
|
130
|
+
except OverflowError: # a JSON number beyond a double is Infinity in JavaScript
|
|
131
|
+
return "Infinity" if value > 0 else "-Infinity"
|
|
132
|
+
if isinstance(value, dict):
|
|
133
|
+
return "[object Object]"
|
|
134
|
+
if isinstance(value, list):
|
|
135
|
+
return ",".join("" if item is None else js_string(item) for item in value)
|
|
136
|
+
return str(value)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def get(value: Any, key: str) -> Any:
|
|
140
|
+
return value.get(key, MISSING) if isinstance(value, dict) else MISSING
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def get_string(value: Any, key: str) -> str:
|
|
144
|
+
return js_string(get(value, key))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def is_number(value: Any) -> bool:
|
|
148
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def double(value: Any) -> float:
|
|
152
|
+
"""A JSON number as JavaScript holds it: the nearest double, and an integer beyond the double range ±Infinity."""
|
|
153
|
+
try:
|
|
154
|
+
return float(value)
|
|
155
|
+
except OverflowError:
|
|
156
|
+
return math.inf if value > 0 else -math.inf
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def same(a: Any, b: Any) -> bool:
|
|
160
|
+
"""JavaScript's === (and a Set's SameValueZero) on parsed JSON: an object equals only itself."""
|
|
161
|
+
if a is MISSING or b is MISSING:
|
|
162
|
+
return a is b
|
|
163
|
+
if isinstance(a, (dict, list)) or isinstance(b, (dict, list)):
|
|
164
|
+
return a is b
|
|
165
|
+
if isinstance(a, bool) or isinstance(b, bool):
|
|
166
|
+
return isinstance(a, bool) and isinstance(b, bool) and a == b
|
|
167
|
+
if is_number(a) and is_number(b):
|
|
168
|
+
return double(a) == double(b) # 2**53 + 1 is 2**53 in JavaScript
|
|
169
|
+
return type(a) is type(b) and a == b
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def contains(items: List[Any], value: Any) -> bool:
|
|
173
|
+
return any(same(item, value) for item in items)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
#: The characters JavaScript's StringToNumber trims: WhiteSpace and LineTerminator (ECMA-262 §12.2, §12.3).
|
|
177
|
+
_JS_SPACE = "\t\n\v\f\r
"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
_JS_DECIMAL = re.compile(r"[+-]?(?:(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:[eE][+-]?[0-9]+)?|Infinity)", re.ASCII)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
_JS_RADIX = re.compile(r"0(?:[xX](?P<x>[0-9a-fA-F]+)|[oO](?P<o>[0-7]+)|[bB](?P<b>[01]+))", re.ASCII)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def js_to_number(value: Any) -> float:
|
|
187
|
+
"""Number(value) for a JSON value, as JavaScript reads it; NaN for what it cannot read as a number."""
|
|
188
|
+
if value is MISSING:
|
|
189
|
+
return math.nan
|
|
190
|
+
if value is None:
|
|
191
|
+
return 0.0
|
|
192
|
+
if isinstance(value, bool):
|
|
193
|
+
return 1.0 if value else 0.0
|
|
194
|
+
if is_number(value):
|
|
195
|
+
try:
|
|
196
|
+
return float(value)
|
|
197
|
+
except OverflowError:
|
|
198
|
+
return math.inf if value > 0 else -math.inf
|
|
199
|
+
if isinstance(value, list):
|
|
200
|
+
return js_to_number(js_string(value)) # Number([2]) is Number("2"); Number([]) is 0
|
|
201
|
+
if isinstance(value, str):
|
|
202
|
+
text = value.strip(_JS_SPACE)
|
|
203
|
+
if text == "":
|
|
204
|
+
return 0.0
|
|
205
|
+
if _JS_DECIMAL.fullmatch(text):
|
|
206
|
+
return float(text.replace("Infinity", "inf"))
|
|
207
|
+
radix = _JS_RADIX.fullmatch(text)
|
|
208
|
+
if radix:
|
|
209
|
+
digits, base = next((radix.group(k), b) for k, b in (("x", 16), ("o", 8), ("b", 2)) if radix.group(k))
|
|
210
|
+
return double(int(digits, base))
|
|
211
|
+
return math.nan
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def safe_integer(value: Any) -> bool:
|
|
215
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
216
|
+
return abs(value) <= 2 ** 53 - 1 # checked before any float: a 400-digit integer would overflow
|
|
217
|
+
return isinstance(value, float) and math.isfinite(value) and value.is_integer() and abs(value) <= 2 ** 53 - 1
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def objects(value: Any) -> List[Dict[str, Any]]:
|
|
221
|
+
return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def indexed_objects(value: Any) -> List[Tuple[Dict[str, Any], int]]:
|
|
225
|
+
"""The object items of an array with their index in the array as written, so an instancePath names the item it
|
|
226
|
+
flags even when a non-object precedes it (the contract's ``indexed``)."""
|
|
227
|
+
return [(item, i) for i, item in enumerate(value) if isinstance(item, dict)] if isinstance(value, list) else []
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class Unshowable(Exception):
|
|
231
|
+
pass
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def strict_string(value: Any) -> str:
|
|
235
|
+
"""String(value), raising where JavaScript's String() throws: an object whose own ``toString`` is not a function
|
|
236
|
+
(every own member of parsed JSON is data), directly or inside an array it joins."""
|
|
237
|
+
if isinstance(value, dict):
|
|
238
|
+
if "toString" in value:
|
|
239
|
+
raise Unshowable()
|
|
240
|
+
return "[object Object]"
|
|
241
|
+
if isinstance(value, list):
|
|
242
|
+
return ",".join("" if item is None else strict_string(item) for item in value)
|
|
243
|
+
return js_string(value)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
_INDEX_KEY = re.compile(r"0|[1-9][0-9]*", re.ASCII)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def js_keys(value: Dict[str, Any]) -> List[str]:
|
|
250
|
+
"""An object's own keys in the order JavaScript lists them: array-index keys ("0" … "4294967294", no leading zero)
|
|
251
|
+
ascending, then the others in the order written."""
|
|
252
|
+
index = sorted((k for k in value if _INDEX_KEY.fullmatch(k) and int(k) <= 2 ** 32 - 2), key=int)
|
|
253
|
+
taken = set(index)
|
|
254
|
+
return index + [k for k in value if k not in taken]
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def json_string(text: str) -> str:
|
|
258
|
+
"""JSON.stringify of a string (well-formed, ES2019): a lone surrogate is escaped as \\uXXXX, never refused."""
|
|
259
|
+
out = ['"']
|
|
260
|
+
for ch in text:
|
|
261
|
+
code = ord(ch)
|
|
262
|
+
if ch in JSON_ESCAPES:
|
|
263
|
+
out.append(JSON_ESCAPES[ch])
|
|
264
|
+
elif code < 0x20 or 0xD800 <= code <= 0xDFFF:
|
|
265
|
+
out.append("\\u%04x" % code)
|
|
266
|
+
else:
|
|
267
|
+
out.append(ch)
|
|
268
|
+
out.append('"')
|
|
269
|
+
return "".join(out)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def json_stringify(value: Any) -> str:
|
|
273
|
+
"""JSON.stringify for a parsed JSON value: members in JavaScript's key order, numbers as JavaScript writes them, a
|
|
274
|
+
lone surrogate escaped."""
|
|
275
|
+
if isinstance(value, dict):
|
|
276
|
+
return "{" + ",".join(json_string(k) + ":" + json_stringify(value[k]) for k in js_keys(value)) + "}"
|
|
277
|
+
if isinstance(value, list):
|
|
278
|
+
return "[" + ",".join(json_stringify(v) for v in value) + "]"
|
|
279
|
+
if value is None:
|
|
280
|
+
return "null"
|
|
281
|
+
if isinstance(value, bool):
|
|
282
|
+
return "true" if value else "false"
|
|
283
|
+
if is_number(value):
|
|
284
|
+
text = js_string(value)
|
|
285
|
+
return "null" if text in ("NaN", "Infinity", "-Infinity") else text
|
|
286
|
+
return json_string(value)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def show(value: Any) -> str:
|
|
290
|
+
"""The contract's ``show``: String(value), or the value's JSON where String() would throw."""
|
|
291
|
+
try:
|
|
292
|
+
return strict_string(value)
|
|
293
|
+
except Unshowable:
|
|
294
|
+
return json_stringify(value)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def key_of(*parts: Any) -> str:
|
|
298
|
+
"""The contract's ``keyOf``: a collision-free key over JSON values, JSON.stringify of the parts (an absent value
|
|
299
|
+
is null inside an array, as JavaScript writes it)."""
|
|
300
|
+
return json_stringify([None if part is MISSING else part for part in parts])
|
|
301
|
+
from typing import Any
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def json_equal(a: Any, b: Any) -> bool:
|
|
305
|
+
"""The contract's jsonEqual: arrays in order, objects by their members, primitives as JavaScript's ===."""
|
|
306
|
+
if isinstance(a, list) or isinstance(b, list):
|
|
307
|
+
return isinstance(a, list) and isinstance(b, list) and len(a) == len(b) and all(json_equal(x, y) for x, y in zip(a, b))
|
|
308
|
+
if isinstance(a, dict) or isinstance(b, dict):
|
|
309
|
+
return isinstance(a, dict) and isinstance(b, dict) and len(a) == len(b) and all(k in b and json_equal(a[k], b[k]) for k in a)
|
|
310
|
+
return same(a, b)
|