instruction.md 0.2.0__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.
- instruction_md/__init__.py +56 -0
- instruction_md/__main__.py +195 -0
- instruction_md/_js.py +170 -0
- instruction_md/_micromark.py +3234 -0
- instruction_md/_yaml.py +432 -0
- instruction_md/attributes.py +193 -0
- instruction_md/check.py +145 -0
- instruction_md/consumer.py +494 -0
- instruction_md/digest.py +34 -0
- instruction_md/format.py +51 -0
- instruction_md/inline.py +486 -0
- instruction_md/jcs.py +28 -0
- instruction_md/mcp_auth.py +55 -0
- instruction_md/py.typed +0 -0
- instruction_md/read.py +942 -0
- instruction_md/registry.json +1603 -0
- instruction_md/registry.py +116 -0
- instruction_md/resolve.py +864 -0
- instruction_md/text.py +268 -0
- instruction_md/types.py +219 -0
- instruction_md/verify.py +195 -0
- instruction_md/workflow-registry.json +1028 -0
- instruction_md/workflow.py +325 -0
- instruction_md-0.2.0.dist-info/METADATA +200 -0
- instruction_md-0.2.0.dist-info/RECORD +30 -0
- instruction_md-0.2.0.dist-info/WHEEL +5 -0
- instruction_md-0.2.0.dist-info/licenses/LICENSE-APACHE +202 -0
- instruction_md-0.2.0.dist-info/licenses/LICENSE-MIT +21 -0
- instruction_md-0.2.0.dist-info/licenses/NOTICE +28 -0
- instruction_md-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""instruction-md — the instruction.md specification's first edition (``instruction/1``) in Python: the reader and
|
|
2
|
+
its checks, the inline syntax, resolution and the agent's text profile, the registry, the formatter, digests and
|
|
3
|
+
signature verification, and a follower for instructions served over MCP.
|
|
4
|
+
|
|
5
|
+
from instruction_md import read, check, resolve, to_text
|
|
6
|
+
r = read(text) # the authored tree (r.document) and the reader's diagnostics
|
|
7
|
+
diagnostics = check(r) # those, with the inline checks, in source order
|
|
8
|
+
res = resolve(text, uri="agent.md", params={"timezone": "UTC"}, load=sources)
|
|
9
|
+
res.text # what the agent receives (instruction-text/1)
|
|
10
|
+
res.resolved.resolved_digest # the resolved artifact's RFC 8785 digest (instruction-resolved/1)
|
|
11
|
+
|
|
12
|
+
The reference is the TypeScript package ``@instruction-md/spec`` (``packages/spec``); this port is byte-exact with
|
|
13
|
+
its vectors and with the specification's conformance data, both vendored under ``conformance/``."""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from ._yaml import load_csv, load_json, load_yaml
|
|
17
|
+
from .attributes import attributes_end, parse_attributes
|
|
18
|
+
from .check import Target, check, inline_diagnostics, resolve_target
|
|
19
|
+
from .consumer import META, AppliedInstruction, FollowerClient, FollowPolicy, InstructionFollower, McpSessionClient, Report, parse_meta
|
|
20
|
+
from .digest import digest, digest_text, sha256_hex
|
|
21
|
+
from .format import format
|
|
22
|
+
from .inline import InlineToken, inline_tokens, split_wiki
|
|
23
|
+
from .jcs import canonical_json, json_digest
|
|
24
|
+
from .read import ReadResult, read, strip_comments, value_fits, value_from
|
|
25
|
+
from .registry import (
|
|
26
|
+
BACKMATTER_KEYS, COMMON, FRONT_MATTER_KEYS, INLINE, INLINE_REQUIRED, KEYWORD_FLAGS, KEYWORDS, KINDS, PATTERNS, PRESENTATION_ATTRS, REGISTRY,
|
|
27
|
+
REGISTRY_VERSION, RULES, TYPED_REFERENCES, AttrSpec, KindSpec, attr_spec, canonical_kind, inline_attr_spec,
|
|
28
|
+
)
|
|
29
|
+
from .resolve import Resolution, Resolved, Source, references_of, resolve, resolve_tree, resolve_uri, resolve_with, slug
|
|
30
|
+
from .text import TextOptions, TextSource, fence, normalize, to_text, value_text
|
|
31
|
+
from .types import (
|
|
32
|
+
ABSENT, AuthoredDocument, Diagnostic, DirectiveNode, Identity, MarkdownNode, Metadata, Node, Parameter, Range, StructuredValue, document_from_json,
|
|
33
|
+
node_from_json,
|
|
34
|
+
)
|
|
35
|
+
from .verify import JWK, VerifyResult, public_jwk, sign_jws, verify_author, verify_delivery
|
|
36
|
+
from .workflow import WORKFLOW_COMMON_FIELDS, WORKFLOW_DIALECT, WORKFLOW_KINDS, WORKFLOW_LIMITS, WORKFLOW_REGISTRY, WORKFLOW_SCOPES, WorkflowKind, workflow_diagnostics
|
|
37
|
+
|
|
38
|
+
__version__ = "0.2.0"
|
|
39
|
+
#: The edition this package reads: front matter's ``spec: "1"``, the tree's ``format: "instruction/1"``.
|
|
40
|
+
SPEC_VERSION = "1"
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"__version__", "SPEC_VERSION",
|
|
44
|
+
"read", "ReadResult", "strip_comments", "value_fits", "value_from", "check", "inline_diagnostics", "resolve_target", "Target",
|
|
45
|
+
"parse_attributes", "attributes_end", "inline_tokens", "InlineToken", "split_wiki", "load_yaml", "load_json", "load_csv",
|
|
46
|
+
"to_text", "TextOptions", "TextSource", "normalize", "fence", "value_text",
|
|
47
|
+
"resolve", "resolve_with", "resolve_tree", "references_of", "resolve_uri", "slug", "Source", "Resolution", "Resolved",
|
|
48
|
+
"format", "digest", "digest_text", "sha256_hex", "canonical_json", "json_digest",
|
|
49
|
+
"verify_author", "verify_delivery", "VerifyResult", "sign_jws", "public_jwk", "JWK",
|
|
50
|
+
"InstructionFollower", "FollowerClient", "FollowPolicy", "Report", "AppliedInstruction", "McpSessionClient", "parse_meta", "META",
|
|
51
|
+
"AuthoredDocument", "Metadata", "MarkdownNode", "DirectiveNode", "StructuredValue", "Node", "Diagnostic", "Parameter", "Identity", "Range", "ABSENT",
|
|
52
|
+
"node_from_json", "document_from_json",
|
|
53
|
+
"REGISTRY", "REGISTRY_VERSION", "KINDS", "COMMON", "INLINE", "KEYWORDS", "KEYWORD_FLAGS", "RULES", "TYPED_REFERENCES", "INLINE_REQUIRED",
|
|
54
|
+
"PRESENTATION_ATTRS", "PATTERNS", "FRONT_MATTER_KEYS", "BACKMATTER_KEYS", "KindSpec", "AttrSpec", "attr_spec", "inline_attr_spec", "canonical_kind",
|
|
55
|
+
"workflow_diagnostics", "WORKFLOW_DIALECT", "WORKFLOW_REGISTRY", "WORKFLOW_KINDS", "WORKFLOW_COMMON_FIELDS", "WORKFLOW_LIMITS", "WORKFLOW_SCOPES", "WorkflowKind",
|
|
56
|
+
]
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""``python -m instruction_md check|tree|deliver|format FILE`` — the library's document operations, for a shell or a
|
|
2
|
+
CI step — and ``watch URI``: follow an instruction on an instruction.md MCP server (the follower, with the ``mcp``
|
|
3
|
+
extra). ``deliver`` resolves a document's bases and includes from the files beside it, under its directory, never
|
|
4
|
+
above it and never from a URL."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
import urllib.parse
|
|
15
|
+
import urllib.request
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Callable, Optional
|
|
18
|
+
|
|
19
|
+
from . import __version__, check, format as format_text, read, resolve
|
|
20
|
+
from .read import value_from
|
|
21
|
+
from .resolve import Source
|
|
22
|
+
from .types import Diagnostic
|
|
23
|
+
|
|
24
|
+
_PARAM = re.compile(r"([A-Za-z_][A-Za-z0-9_.-]*)=(.*)\Z", re.S)
|
|
25
|
+
_SCHEME = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*:")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _line(d: Diagnostic) -> str:
|
|
29
|
+
"""A diagnostic as one line: ``line 3: … [code]``, with the file it came from when that is another one."""
|
|
30
|
+
return (f"{d.uri}: " if d.uri else "") + (f"line {d.range.start}: " if d.range else "") + f"{d.message} [{d.code}]"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _params(items: list[str], text: str) -> dict[str, Any]:
|
|
34
|
+
"""``name=value`` arguments, each read as the document declares the parameter (a number, a list, a boolean)."""
|
|
35
|
+
declared = read(text).parameters
|
|
36
|
+
out: dict[str, Any] = {}
|
|
37
|
+
for item in items:
|
|
38
|
+
m = _PARAM.match(item)
|
|
39
|
+
if not m:
|
|
40
|
+
print(f"not a name=value: {item}", file=sys.stderr)
|
|
41
|
+
raise SystemExit(2)
|
|
42
|
+
p = declared.get(m.group(1))
|
|
43
|
+
out[m.group(1)] = value_from(p, m.group(2)) if p else m.group(2)
|
|
44
|
+
return out
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def files_under(root: Path) -> Callable[[str], Optional[Source]]:
|
|
48
|
+
"""Sources beside a file: what its ``extends`` and its includes name, read from the file system under the directory
|
|
49
|
+
it is in (never above it, through a symbolic link either; never a URL)."""
|
|
50
|
+
top = os.path.realpath(root)
|
|
51
|
+
|
|
52
|
+
def load(uri: str) -> Optional[Source]:
|
|
53
|
+
if _SCHEME.match(uri) and not uri.startswith("file:"):
|
|
54
|
+
return None
|
|
55
|
+
path = urllib.request.url2pathname(urllib.parse.urlsplit(uri).path) if uri.startswith("file:") else uri
|
|
56
|
+
path = os.path.realpath(path)
|
|
57
|
+
if path != top and not path.startswith(top + os.sep):
|
|
58
|
+
return None
|
|
59
|
+
try:
|
|
60
|
+
return Source(uri, Path(path).read_bytes().decode("utf-8"))
|
|
61
|
+
except (OSError, UnicodeDecodeError):
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
return load
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def main(argv: list[str] | None = None) -> int:
|
|
68
|
+
ap = argparse.ArgumentParser(prog="instruction_md", description="read, check, resolve and format instruction.md files (instruction/1)")
|
|
69
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
70
|
+
for name, help_ in (("check", "the reader's diagnostics and the inline checks; exit 1 on errors"), ("tree", "print the authored tree (instruction/1) as JSON"),
|
|
71
|
+
("deliver", "print the text a model receives (instruction-text/1), the bases and includes read from files beside the document"),
|
|
72
|
+
("format", "print the formatted text")):
|
|
73
|
+
p = sub.add_parser(name, help=help_)
|
|
74
|
+
p.add_argument("file", type=Path)
|
|
75
|
+
if name == "deliver":
|
|
76
|
+
p.add_argument("--param", action="append", default=[], metavar="NAME=VALUE", help="a parameter's value, read as the document declares it")
|
|
77
|
+
p.add_argument("--resolved", action="store_true", help="print the resolved artifact (instruction-resolved/1) instead")
|
|
78
|
+
w = sub.add_parser("watch", help="follow an instruction on an MCP server: print each version applied, or why one is held")
|
|
79
|
+
w.add_argument("uri", help="instruction://{id}[@ref|@vN|@ver_…]")
|
|
80
|
+
w.add_argument("--url", default=os.environ.get("INSTRUCTION_MD_URL", "https://instruction.md/mcp"))
|
|
81
|
+
w.add_argument("--token", default=os.environ.get("INSTRUCTION_MD_TOKEN"), help="a bearer token (default INSTRUCTION_MD_TOKEN); it isn't renewed")
|
|
82
|
+
w.add_argument("--client-id", default=os.environ.get("INSTRUCTION_MD_CLIENT_ID"),
|
|
83
|
+
help="sign in as this agent (default INSTRUCTION_MD_CLIENT_ID), with its secret from INSTRUCTION_MD_CLIENT_SECRET, renewed as it expires")
|
|
84
|
+
w.add_argument("--token-url", default=os.environ.get("INSTRUCTION_MD_TOKEN_URL"), help="the agent's token endpoint (default INSTRUCTION_MD_TOKEN_URL)")
|
|
85
|
+
w.add_argument("--once", action="store_true", help="read once, apply or hold, report, and exit (6 when held)")
|
|
86
|
+
w.add_argument("--require-signature", action="store_true")
|
|
87
|
+
w.add_argument("--require-delivery-signature", action="store_true")
|
|
88
|
+
w.add_argument("--out", type=Path, help="write each applied version's text here")
|
|
89
|
+
w.add_argument("--label", default="instruction-md-python", help="the binding's consumer name")
|
|
90
|
+
w.add_argument("--no-report", action="store_true", help="declare no binding, report nothing")
|
|
91
|
+
w.add_argument("--freshness", type=float, default=60.0, help="seconds between re-reads (0: notifications only)")
|
|
92
|
+
a = ap.parse_args(argv)
|
|
93
|
+
if a.cmd == "watch":
|
|
94
|
+
return asyncio.run(_watch(a))
|
|
95
|
+
text = a.file.read_bytes().decode("utf-8") # (the bytes as stored: a digest covers them, line endings and all)
|
|
96
|
+
if a.cmd == "check":
|
|
97
|
+
diagnostics = check(text)
|
|
98
|
+
for d in diagnostics:
|
|
99
|
+
print(f"{d.severity}: {_line(d)}", file=sys.stderr)
|
|
100
|
+
return 1 if any(d.severity == "error" for d in diagnostics) else 0
|
|
101
|
+
if a.cmd == "tree":
|
|
102
|
+
r = read(text)
|
|
103
|
+
print(json.dumps(r.document.to_json(), indent=2, ensure_ascii=False))
|
|
104
|
+
return 1 if any(d.severity == "error" for d in r.diagnostics) else 0
|
|
105
|
+
if a.cmd == "deliver":
|
|
106
|
+
path = a.file.resolve()
|
|
107
|
+
res = resolve(text, uri=path.as_uri(), params=_params(a.param, text), load=files_under(path.parent))
|
|
108
|
+
errors = [d for d in res.diagnostics if d.severity == "error"]
|
|
109
|
+
if errors:
|
|
110
|
+
for d in errors:
|
|
111
|
+
print(_line(d), file=sys.stderr)
|
|
112
|
+
return 1
|
|
113
|
+
for d in res.diagnostics:
|
|
114
|
+
print(f"{d.severity}: {_line(d)}", file=sys.stderr)
|
|
115
|
+
if a.resolved:
|
|
116
|
+
print(json.dumps(res.resolved.to_json(), indent=2, ensure_ascii=False))
|
|
117
|
+
else:
|
|
118
|
+
sys.stdout.write(res.text)
|
|
119
|
+
return 0
|
|
120
|
+
sys.stdout.write(format_text(text))
|
|
121
|
+
return 0
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
PROTOCOL_VERSION = "2026-07-28"
|
|
125
|
+
"""The MCP wire `watch` speaks: stateless, every request whole on its own (no initialize, no Mcp-Session-Id)."""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
async def _no_subscription(_uri: str) -> None:
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def _watch(a: argparse.Namespace) -> int:
|
|
133
|
+
"""The follower over the official MCP SDK's streamable HTTP client, as the person or agent the token names."""
|
|
134
|
+
try:
|
|
135
|
+
from mcp import Client
|
|
136
|
+
from mcp.client.streamable_http import create_mcp_http_client, streamable_http_client
|
|
137
|
+
from mcp_types import Implementation
|
|
138
|
+
except ImportError:
|
|
139
|
+
print("watch needs the MCP SDK: pip install 'instruction.md[mcp]'", file=sys.stderr)
|
|
140
|
+
return 2
|
|
141
|
+
from .consumer import FollowPolicy, InstructionFollower, McpSessionClient, Report
|
|
142
|
+
|
|
143
|
+
auth = None
|
|
144
|
+
if a.client_id:
|
|
145
|
+
secret = os.environ.get("INSTRUCTION_MD_CLIENT_SECRET")
|
|
146
|
+
if not secret or not a.token_url:
|
|
147
|
+
print("--client-id needs INSTRUCTION_MD_CLIENT_SECRET and --token-url (or INSTRUCTION_MD_TOKEN_URL)", file=sys.stderr)
|
|
148
|
+
return 2
|
|
149
|
+
from .mcp_auth import ClientCredentials
|
|
150
|
+
|
|
151
|
+
auth = ClientCredentials(a.token_url, a.client_id, secret)
|
|
152
|
+
code = {"held": False}
|
|
153
|
+
|
|
154
|
+
def applied(x, _prev) -> None: # type: ignore[no-untyped-def]
|
|
155
|
+
print(json.dumps({"event": "applied", "uri": x.uri, "versionId": x.version_id, "number": x.revision, "verified": x.verified, "digest": x.digest}), flush=True)
|
|
156
|
+
if a.out:
|
|
157
|
+
a.out.write_text(x.text, encoding="utf-8")
|
|
158
|
+
|
|
159
|
+
def held(reason: str, _c) -> None: # type: ignore[no-untyped-def]
|
|
160
|
+
code["held"] = True
|
|
161
|
+
print(json.dumps({"event": "held", "reason": reason}), file=sys.stderr, flush=True)
|
|
162
|
+
|
|
163
|
+
headers = {"Authorization": f"Bearer {a.token}"} if a.token and not auth else {}
|
|
164
|
+
# (stderr carries the JSON lines: the SDK's own notices stay off it)
|
|
165
|
+
for name in ("mcp", "client"): # (the SDK's session logs as "client")
|
|
166
|
+
logging.getLogger(name).setLevel(logging.ERROR)
|
|
167
|
+
async with create_mcp_http_client(headers=headers, auth=auth) as http:
|
|
168
|
+
# The 2026-07-28 wire, pinned: no initialize and no session, so any replica of a clustered server answers each
|
|
169
|
+
# request (the owner, 2026-10-24: "the latest protocol version that does not require session id"); a
|
|
170
|
+
# subscription is a listen stream. "auto" probes server/discover and picks it too against instruction.md, but
|
|
171
|
+
# a probe answered with an error (one replica's hiccup) falls back to the session handshake.
|
|
172
|
+
transport = streamable_http_client(a.url, http_client=http)
|
|
173
|
+
async with Client(transport, mode=PROTOCOL_VERSION, client_info=Implementation(name="instruction-md-python", version=__version__)) as mcp:
|
|
174
|
+
client = McpSessionClient(mcp.session)
|
|
175
|
+
f = InstructionFollower(
|
|
176
|
+
client=client, uri=a.uri, on_apply=applied, on_hold=held,
|
|
177
|
+
policy=FollowPolicy(require_signature=a.require_signature, require_delivery_signature=a.require_delivery_signature),
|
|
178
|
+
freshness_seconds=0 if a.once else a.freshness,
|
|
179
|
+
report=None if a.no_report else Report(consumer={"label": a.label, "kind": "agent"}, client={"name": "instruction-md-python", "version": __version__}),
|
|
180
|
+
)
|
|
181
|
+
client.on_updated = f.notify
|
|
182
|
+
if a.once:
|
|
183
|
+
# (read once: nothing to listen for)
|
|
184
|
+
f.freshness_seconds = 0
|
|
185
|
+
client.subscribe_resource = _no_subscription # type: ignore[method-assign]
|
|
186
|
+
await f.start()
|
|
187
|
+
if a.once:
|
|
188
|
+
f.stop()
|
|
189
|
+
return 0 if f.current and not code["held"] else 6
|
|
190
|
+
await asyncio.Event().wait()
|
|
191
|
+
return 0
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
if __name__ == "__main__":
|
|
195
|
+
sys.exit(main())
|
instruction_md/_js.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""What the TypeScript reference gets from JavaScript and a port must spell out: its whitespace (``\\s``, ``trim``), a
|
|
2
|
+
regex ``$`` that is the end of the string, its one number type and how it prints, ``String(value)`` and
|
|
3
|
+
``JSON.stringify``. The text profile and the resolved artifact's digest are byte-exact with the reference, so these
|
|
4
|
+
are too."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import math
|
|
8
|
+
import re
|
|
9
|
+
from functools import lru_cache
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
#: JavaScript's whitespace and line terminators: what ``\\s`` matches and ``trim()`` removes.
|
|
13
|
+
WHITESPACE = "\t\n\v\f\r \u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff"
|
|
14
|
+
S = "[" + WHITESPACE + "]"
|
|
15
|
+
NOT_S = "[^" + WHITESPACE + "]"
|
|
16
|
+
#: ``.``: anything but a line terminator.
|
|
17
|
+
DOT = "[^\n\r\u2028\u2029]"
|
|
18
|
+
|
|
19
|
+
_SAFE = 2 ** 53
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def trim(s: str) -> str:
|
|
23
|
+
return s.strip(WHITESPACE)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@lru_cache(maxsize=None)
|
|
27
|
+
def regex(pattern: str) -> re.Pattern[str]:
|
|
28
|
+
"""A registry pattern, whose ``$`` ends the string (Python's would also match before a final newline)."""
|
|
29
|
+
return re.compile(pattern[:-1] + r"\Z" if pattern.endswith("$") and not pattern.endswith("\\$") else pattern)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def truthy(value: Any) -> bool:
|
|
33
|
+
"""JavaScript's truthiness: an empty list or object is true; 0, NaN, "", null and false are not."""
|
|
34
|
+
if value is None or value is False or value == "":
|
|
35
|
+
return False
|
|
36
|
+
if is_number(value):
|
|
37
|
+
return value == value and value != 0
|
|
38
|
+
return True
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def is_number(value: Any) -> bool:
|
|
42
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def number(value: Any) -> Any:
|
|
46
|
+
"""A number as JavaScript holds it: an integer stays one while it is exact (to 2**53), anything else is a double."""
|
|
47
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
48
|
+
if -_SAFE <= value <= _SAFE:
|
|
49
|
+
return value
|
|
50
|
+
# (past a double's range JavaScript reads Infinity; Python's float() would raise)
|
|
51
|
+
try:
|
|
52
|
+
return float(value)
|
|
53
|
+
except OverflowError:
|
|
54
|
+
return math.inf if value > 0 else -math.inf
|
|
55
|
+
if isinstance(value, float) and math.isfinite(value) and value.is_integer() and -_SAFE <= value <= _SAFE:
|
|
56
|
+
return int(value)
|
|
57
|
+
return value
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_NUMERIC = re.compile(r"[+-]?(?:Infinity|(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:[eE][+-]?[0-9]+)?)\Z")
|
|
61
|
+
_RADIX = re.compile(r"0([xXoObB])([0-9A-Za-z]+)\Z")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def to_number(text: str) -> float:
|
|
65
|
+
"""``Number(text)``: decimal (or a 0x, 0o, 0b literal), surrounding whitespace ignored, "" is 0, else NaN."""
|
|
66
|
+
s = trim(text)
|
|
67
|
+
if s == "":
|
|
68
|
+
return 0
|
|
69
|
+
radix = _RADIX.match(s)
|
|
70
|
+
if radix:
|
|
71
|
+
try:
|
|
72
|
+
return number(int(radix.group(2), {"x": 16, "o": 8, "b": 2}[radix.group(1).lower()]))
|
|
73
|
+
except ValueError:
|
|
74
|
+
return math.nan
|
|
75
|
+
if not _NUMERIC.match(s):
|
|
76
|
+
return math.nan
|
|
77
|
+
return number(float(s.replace("Infinity", "inf")))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def number_text(value: Any) -> str:
|
|
81
|
+
"""ECMAScript's Number::toString: the shortest digits that round-trip, plainly up to 1e21, else exponential."""
|
|
82
|
+
if isinstance(value, int):
|
|
83
|
+
if -_SAFE <= value <= _SAFE:
|
|
84
|
+
return str(value)
|
|
85
|
+
value = float(value)
|
|
86
|
+
if math.isnan(value):
|
|
87
|
+
return "NaN"
|
|
88
|
+
if math.isinf(value):
|
|
89
|
+
return "Infinity" if value > 0 else "-Infinity"
|
|
90
|
+
if value == 0:
|
|
91
|
+
return "0"
|
|
92
|
+
sign = "-" if value < 0 else ""
|
|
93
|
+
mantissa, _, exp = repr(abs(value)).partition("e")
|
|
94
|
+
whole, _, fraction = mantissa.partition(".")
|
|
95
|
+
if fraction == "0":
|
|
96
|
+
fraction = ""
|
|
97
|
+
digits = (whole + fraction).lstrip("0")
|
|
98
|
+
# the value is int(digits) * 10**(exp - len(fraction)); ECMAScript's n puts the decimal point n digits in
|
|
99
|
+
n = len(digits) - len(fraction) + (int(exp) if exp else 0)
|
|
100
|
+
digits = digits.rstrip("0")
|
|
101
|
+
k = len(digits)
|
|
102
|
+
if k <= n <= 21:
|
|
103
|
+
return sign + digits + "0" * (n - k)
|
|
104
|
+
if 0 < n <= 21:
|
|
105
|
+
return sign + digits[:n] + "." + digits[n:]
|
|
106
|
+
if -6 < n <= 0:
|
|
107
|
+
return sign + "0." + "0" * -n + digits
|
|
108
|
+
e = n - 1
|
|
109
|
+
return sign + digits[0] + ("." + digits[1:] if k > 1 else "") + "e" + ("+" if e >= 0 else "-") + str(abs(e))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def string(value: Any) -> str:
|
|
113
|
+
"""``String(value)``."""
|
|
114
|
+
if value is None:
|
|
115
|
+
return "null"
|
|
116
|
+
if isinstance(value, bool):
|
|
117
|
+
return "true" if value else "false"
|
|
118
|
+
if is_number(value):
|
|
119
|
+
return number_text(value)
|
|
120
|
+
if isinstance(value, str):
|
|
121
|
+
return value
|
|
122
|
+
if isinstance(value, (list, tuple)):
|
|
123
|
+
return ",".join("" if v is None else string(v) for v in value)
|
|
124
|
+
return "[object Object]"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
_ESCAPES = {'"': '\\"', "\\": "\\\\", "\b": "\\b", "\f": "\\f", "\n": "\\n", "\r": "\\r", "\t": "\\t"}
|
|
128
|
+
_NEEDS_ESCAPE = re.compile('["\\\\\x00-\x1f\ud800-\udfff]')
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def quote(s: str) -> str:
|
|
132
|
+
"""A string as ``JSON.stringify`` writes it (a lone surrogate escaped, every other character as it is)."""
|
|
133
|
+
return '"' + _NEEDS_ESCAPE.sub(lambda m: _ESCAPES.get(m.group(), "\\u%04x" % ord(m.group())), s) + '"'
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def stringify(value: Any, sort_keys: Any = None, strict: bool = False) -> str:
|
|
137
|
+
"""``JSON.stringify(value)`` without spaces (a non-finite number as null, or with ``strict`` an error); with
|
|
138
|
+
``sort_keys``, an object's keys in that order (a key function)."""
|
|
139
|
+
if value is None:
|
|
140
|
+
return "null"
|
|
141
|
+
if value is True:
|
|
142
|
+
return "true"
|
|
143
|
+
if value is False:
|
|
144
|
+
return "false"
|
|
145
|
+
if isinstance(value, str):
|
|
146
|
+
return quote(value)
|
|
147
|
+
if is_number(value):
|
|
148
|
+
if isinstance(value, float) and not math.isfinite(value):
|
|
149
|
+
if strict:
|
|
150
|
+
raise ValueError("JSON has no non-finite numbers")
|
|
151
|
+
return "null"
|
|
152
|
+
return number_text(value)
|
|
153
|
+
if isinstance(value, (list, tuple)):
|
|
154
|
+
return "[" + ",".join(stringify(v, sort_keys, strict) for v in value) + "]"
|
|
155
|
+
if isinstance(value, dict):
|
|
156
|
+
keys = sorted(value, key=sort_keys) if sort_keys else list(value)
|
|
157
|
+
return "{" + ",".join(quote(k) + ":" + stringify(value[k], sort_keys, strict) for k in keys) + "}"
|
|
158
|
+
raise TypeError(f"JSON cannot carry a {type(value).__name__}")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def object_order(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
162
|
+
"""An object's keys in JavaScript's order: the array indices first, ascending, then the rest as they came."""
|
|
163
|
+
index = [p for p in pairs if _ARRAY_INDEX.match(p[0]) and int(p[0]) < 4294967295]
|
|
164
|
+
if not index:
|
|
165
|
+
return dict(pairs)
|
|
166
|
+
rest = [p for p in pairs if not (_ARRAY_INDEX.match(p[0]) and int(p[0]) < 4294967295)]
|
|
167
|
+
return dict(sorted(index, key=lambda p: int(p[0])) + rest)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
_ARRAY_INDEX = re.compile(r"(?:0|[1-9][0-9]*)\Z")
|