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.
@@ -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")