@passioncode-ai/fabric-agent-adapter 0.39.1 → 0.40.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 +79 -0
- package/README.md +9 -3
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +64 -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 +264 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +128 -0
- 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_service.py +10 -183
- 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/fabric-auth.mjs +286 -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-session.mjs +840 -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 +1 -16
- 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 +13 -45
- 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 +8 -67
- 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 +2 -2
- 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 +202 -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 +2 -12
- 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 +400 -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 +402 -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 +78 -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_session.py +902 -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 -38
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +289 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +15 -180
- 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 +39 -400
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +137 -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_interop.py +1 -6
- 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 +21 -43
- 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 +31 -38
- 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_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 +412 -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 +1526 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +116 -1392
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +270 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +508 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +265 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +155 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +335 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +1 -0
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)
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The service kit as a versioned set of modules: build its manifest, install it into a service, report a copy's state.
|
|
3
|
+
|
|
4
|
+
The kit is not one file. ``kit.json`` beside this script lists every module in dependency order, per language, with
|
|
5
|
+
the kit modules it imports and its sha256, and ``history`` keeps the sha256 of every earlier release, so a copy in a
|
|
6
|
+
service can be told apart: ``current``, ``older`` (with the release it came from), or ``modified``.
|
|
7
|
+
|
|
8
|
+
fabric_kit.py install <service-dir> [--lang py|node] [--module NAME ...] [--all] [--dry-run] [--force]
|
|
9
|
+
fabric_kit.py status <service-dir> [--json]
|
|
10
|
+
fabric_kit.py build [--check] # maintainers: refresh kit.json from the sources
|
|
11
|
+
|
|
12
|
+
``install`` copies the closure of the named modules (default ``fabric_service`` / ``fabric-service``) and nothing else.
|
|
13
|
+
It refuses to overwrite a file it does not recognise (a ``modified`` copy) unless ``--force``. ``status`` also names a
|
|
14
|
+
module a present module imports that is missing from the folder: the copy would fail when that code path runs.
|
|
15
|
+
Standard library only. Exit codes: 0 done or all current, 1 a problem (missing, modified, refused), 2 usage.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import ast
|
|
21
|
+
import hashlib
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import shutil
|
|
26
|
+
import sys
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Dict, List
|
|
29
|
+
|
|
30
|
+
HERE = Path(__file__).resolve().parent
|
|
31
|
+
MANIFEST = HERE / "kit.json"
|
|
32
|
+
LANGS = {"py": "python", "node": "node"}
|
|
33
|
+
DEFAULT_ROOT = {"python": "fabric_service", "node": "fabric-service"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _sha256(path: Path) -> str:
|
|
37
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def python_imports(path: Path, modules: List[str]) -> List[str]:
|
|
41
|
+
"""The kit modules a Python file imports, at any depth of its code (a lazy import is a dependency too)."""
|
|
42
|
+
found = set()
|
|
43
|
+
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))):
|
|
44
|
+
if isinstance(node, ast.Import):
|
|
45
|
+
found.update(a.name for a in node.names if a.name in modules)
|
|
46
|
+
elif isinstance(node, ast.ImportFrom) and node.module in modules:
|
|
47
|
+
found.add(node.module)
|
|
48
|
+
found.discard(path.stem)
|
|
49
|
+
return sorted(found)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def node_imports(path: Path, modules: List[str]) -> List[str]:
|
|
53
|
+
"""The kit modules an ES module imports (static `from './x.mjs'` and dynamic `import('./x.mjs')`)."""
|
|
54
|
+
text = path.read_text(encoding="utf-8")
|
|
55
|
+
found = set(re.findall(r"""(?:from|import\()\s*['"]\./([A-Za-z0-9_-]+)\.mjs['"]""", text)) & set(modules)
|
|
56
|
+
found.discard(path.stem)
|
|
57
|
+
return sorted(found)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def load_manifest() -> Dict:
|
|
61
|
+
return json.loads(MANIFEST.read_text(encoding="utf-8"))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build(check: bool = False) -> List[str]:
|
|
65
|
+
"""Refresh every module's imports and sha256 in kit.json, and record this release's digests in its history.
|
|
66
|
+
Returns what changed (with check=True nothing is written)."""
|
|
67
|
+
manifest = load_manifest()
|
|
68
|
+
changed = []
|
|
69
|
+
for lang, entries in (("python", manifest["python"]), ("node", manifest["node"])):
|
|
70
|
+
names = [e["module"] for e in entries]
|
|
71
|
+
for entry in entries:
|
|
72
|
+
path = HERE / entry["file"]
|
|
73
|
+
imports = (python_imports if lang == "python" else node_imports)(path, names)
|
|
74
|
+
digest = _sha256(path)
|
|
75
|
+
if entry.get("imports") != imports or entry.get("sha256") != digest:
|
|
76
|
+
changed.append(entry["file"])
|
|
77
|
+
entry["imports"], entry["sha256"] = imports, digest
|
|
78
|
+
data = sorted({d for e in manifest["python"] + manifest["node"] for d in e.get("data", [])})
|
|
79
|
+
digests = {d: _sha256(HERE / d) for d in data}
|
|
80
|
+
if manifest.get("data") != digests:
|
|
81
|
+
changed.append("data files")
|
|
82
|
+
manifest["data"] = digests
|
|
83
|
+
current = {e["file"]: e["sha256"] for e in manifest["python"] + manifest["node"]}
|
|
84
|
+
current.update(digests)
|
|
85
|
+
if manifest.setdefault("history", {}).get(manifest["version"]) != current:
|
|
86
|
+
manifest["history"][manifest["version"]] = current
|
|
87
|
+
changed.append("kit.json history %s" % manifest["version"])
|
|
88
|
+
if changed and not check:
|
|
89
|
+
MANIFEST.write_text(json.dumps(manifest, indent=1) + "\n", encoding="utf-8")
|
|
90
|
+
return changed
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def closure(entries: List[Dict], roots: List[str]) -> List[Dict]:
|
|
94
|
+
"""The named modules and everything they import, in the manifest's (dependency) order."""
|
|
95
|
+
by_name = {e["module"]: e for e in entries}
|
|
96
|
+
unknown = [r for r in roots if r not in by_name]
|
|
97
|
+
if unknown:
|
|
98
|
+
raise SystemExit("unknown kit module: %s (the manifest lists %s)" % (", ".join(unknown), ", ".join(by_name)))
|
|
99
|
+
wanted, stack = set(), list(roots)
|
|
100
|
+
while stack:
|
|
101
|
+
name = stack.pop()
|
|
102
|
+
if name not in wanted:
|
|
103
|
+
wanted.add(name)
|
|
104
|
+
stack.extend(by_name[name]["imports"])
|
|
105
|
+
return [e for e in entries if e["module"] in wanted]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def state_of(path: Path, manifest: Dict) -> Dict:
|
|
109
|
+
"""`current`, `older` with the release a copy came from, or `modified` (a digest no release had)."""
|
|
110
|
+
digest = _sha256(path)
|
|
111
|
+
if any(e["file"] == path.name and e["sha256"] == digest for e in manifest["python"] + manifest["node"]) or \
|
|
112
|
+
manifest.get("data", {}).get(path.name) == digest:
|
|
113
|
+
return {"state": "current", "version": manifest["version"]}
|
|
114
|
+
for version, files in sorted(manifest.get("history", {}).items(), key=lambda kv: [int(x) for x in re.findall(r"[0-9]+", kv[0])], reverse=True):
|
|
115
|
+
if files.get(path.name) == digest:
|
|
116
|
+
return {"state": "older", "version": version}
|
|
117
|
+
return {"state": "modified", "version": None}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def status(service_dir: Path) -> Dict:
|
|
121
|
+
manifest = load_manifest()
|
|
122
|
+
report = {"kit": manifest["version"], "files": [], "missing": []}
|
|
123
|
+
for lang in ("python", "node"):
|
|
124
|
+
entries = manifest[lang]
|
|
125
|
+
present = [e for e in entries if (service_dir / e["file"]).is_file()]
|
|
126
|
+
for e in present:
|
|
127
|
+
report["files"].append({"file": e["file"], **state_of(service_dir / e["file"], manifest)})
|
|
128
|
+
names = {e["module"] for e in present}
|
|
129
|
+
by_name = {e["module"]: e for e in entries}
|
|
130
|
+
def reader(path: Path, modules: List[str]) -> List[str]:
|
|
131
|
+
try:
|
|
132
|
+
return (python_imports if lang == "python" else node_imports)(path, modules)
|
|
133
|
+
except (SyntaxError, ValueError): # a copy someone broke is already `modified`; its imports are unknown
|
|
134
|
+
return []
|
|
135
|
+
# What each copy imports is read from the copy itself (an older release imports other modules than this one);
|
|
136
|
+
# a module it lacks would be installed at this release, so that module's own imports are followed too.
|
|
137
|
+
queue = [(e["file"], reader(service_dir / e["file"], list(by_name))) for e in present]
|
|
138
|
+
reported = set()
|
|
139
|
+
for e in present:
|
|
140
|
+
for d in e.get("data", []):
|
|
141
|
+
if (service_dir / d).is_file():
|
|
142
|
+
if d not in [f["file"] for f in report["files"]]:
|
|
143
|
+
report["files"].append({"file": d, **state_of(service_dir / d, manifest)})
|
|
144
|
+
elif d not in reported:
|
|
145
|
+
reported.add(d)
|
|
146
|
+
report["missing"].append({"file": e["file"], "needs": d})
|
|
147
|
+
while queue:
|
|
148
|
+
importer, deps = queue.pop(0)
|
|
149
|
+
for dep in deps:
|
|
150
|
+
if dep in names or dep in reported:
|
|
151
|
+
continue
|
|
152
|
+
reported.add(dep)
|
|
153
|
+
report["missing"].append({"file": importer, "needs": by_name[dep]["file"]})
|
|
154
|
+
for d in by_name[dep].get("data", []): # the data a missing module reads at run time
|
|
155
|
+
if not (service_dir / d).is_file() and d not in reported:
|
|
156
|
+
reported.add(d)
|
|
157
|
+
report["missing"].append({"file": by_name[dep]["file"], "needs": d})
|
|
158
|
+
queue.append((by_name[dep]["file"], by_name[dep]["imports"]))
|
|
159
|
+
return report
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def install(service_dir: Path, lang: str, modules: List[str], dry_run: bool = False, force: bool = False) -> Dict:
|
|
163
|
+
manifest = load_manifest()
|
|
164
|
+
entries = manifest[lang]
|
|
165
|
+
roots = modules or [DEFAULT_ROOT[lang]]
|
|
166
|
+
plan = closure(entries, roots)
|
|
167
|
+
result = {"kit": manifest["version"], "copied": [], "kept": [], "refused": []}
|
|
168
|
+
files = []
|
|
169
|
+
for e in plan:
|
|
170
|
+
files.append(e["file"])
|
|
171
|
+
files.extend(d for d in e.get("data", []) if d not in files)
|
|
172
|
+
for name in files:
|
|
173
|
+
target = service_dir / name
|
|
174
|
+
if target.is_file():
|
|
175
|
+
st = state_of(target, manifest)
|
|
176
|
+
if st["state"] == "current":
|
|
177
|
+
result["kept"].append(name)
|
|
178
|
+
continue
|
|
179
|
+
if st["state"] == "modified" and not force:
|
|
180
|
+
result["refused"].append(name)
|
|
181
|
+
continue
|
|
182
|
+
result["copied"].append(name)
|
|
183
|
+
if not dry_run and not result["refused"]:
|
|
184
|
+
service_dir.mkdir(parents=True, exist_ok=True)
|
|
185
|
+
for name in result["copied"]:
|
|
186
|
+
tmp = service_dir / (".%s.tmp" % name)
|
|
187
|
+
shutil.copyfile(HERE / name, tmp)
|
|
188
|
+
os.replace(tmp, service_dir / name)
|
|
189
|
+
return result
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def main(argv: List[str]) -> int:
|
|
193
|
+
parser = argparse.ArgumentParser(prog="fabric_kit.py", description=__doc__.split("\n\n")[0])
|
|
194
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
195
|
+
p_install = sub.add_parser("install", help="copy a module's closure into a service")
|
|
196
|
+
p_install.add_argument("dir", type=Path)
|
|
197
|
+
p_install.add_argument("--lang", choices=sorted(LANGS), default="py")
|
|
198
|
+
p_install.add_argument("--module", action="append", default=[])
|
|
199
|
+
p_install.add_argument("--all", action="store_true", help="every module of the language")
|
|
200
|
+
p_install.add_argument("--dry-run", action="store_true")
|
|
201
|
+
p_install.add_argument("--force", action="store_true", help="overwrite a copy the kit does not recognise")
|
|
202
|
+
p_status = sub.add_parser("status", help="the state of the kit copies in a service")
|
|
203
|
+
p_status.add_argument("dir", type=Path)
|
|
204
|
+
p_status.add_argument("--json", action="store_true")
|
|
205
|
+
p_build = sub.add_parser("build", help="refresh kit.json from the sources (maintainers)")
|
|
206
|
+
p_build.add_argument("--check", action="store_true")
|
|
207
|
+
args = parser.parse_args(argv)
|
|
208
|
+
if args.cmd == "build":
|
|
209
|
+
changed = build(args.check)
|
|
210
|
+
print(("kit.json is out of date: " if args.check else "rebuilt: ") + ", ".join(changed) if changed else "kit.json is up to date")
|
|
211
|
+
return 1 if (changed and args.check) else 0
|
|
212
|
+
if args.cmd == "status":
|
|
213
|
+
report = status(args.dir)
|
|
214
|
+
if args.json:
|
|
215
|
+
print(json.dumps(report, indent=2))
|
|
216
|
+
else:
|
|
217
|
+
for f in report["files"]:
|
|
218
|
+
print("%-9s %s%s" % (f["state"], f["file"], " (%s)" % f["version"] if f["state"] == "older" else ""))
|
|
219
|
+
for m in report["missing"]:
|
|
220
|
+
print("missing %s, which %s imports" % (m["needs"], m["file"]))
|
|
221
|
+
if not report["files"]:
|
|
222
|
+
print("no kit module in %s" % args.dir)
|
|
223
|
+
bad = not report["files"] or report["missing"] or any(f["state"] != "current" for f in report["files"])
|
|
224
|
+
return 1 if bad else 0
|
|
225
|
+
lang = LANGS[args.lang]
|
|
226
|
+
known = [e["module"] for e in load_manifest()[lang]]
|
|
227
|
+
modules = known if args.all else args.module
|
|
228
|
+
unknown = [m for m in modules if m not in known]
|
|
229
|
+
if unknown:
|
|
230
|
+
print("no such %s kit module: %s (kit.json lists %s)" % (lang, ", ".join(unknown), ", ".join(known)), file=sys.stderr)
|
|
231
|
+
return 2
|
|
232
|
+
result = install(args.dir, lang, modules, args.dry_run, args.force)
|
|
233
|
+
for name in result["copied"]:
|
|
234
|
+
print(("would copy " if args.dry_run else "copied ") + name)
|
|
235
|
+
for name in result["kept"]:
|
|
236
|
+
print("current " + name)
|
|
237
|
+
for name in result["refused"]:
|
|
238
|
+
print("refused %s: the copy there is not one the kit released; --force overwrites it" % name)
|
|
239
|
+
return 1 if result["refused"] else 0
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
if __name__ == "__main__":
|
|
243
|
+
sys.exit(main(sys.argv[1:]))
|