diff --git a/pineforge_codegen/__init__.py b/pineforge_codegen/__init__.py
index 6bd2489..7684e2b 100644
--- a/pineforge_codegen/__init__.py
+++ b/pineforge_codegen/__init__.py
@@ -15,6 +15,7 @@
from .limits import TimeBudget, check_ast_depth, check_source_size, ensure_recursion_headroom
from .pragmas import extract_pf_trace_pragmas
from .request_discovery import discover_requests, request_sites
+from .request_feed_inventory import build_request_feed_inventory
from .security_contexts import specialize_security_contexts
from .block_locals import rename_block_locals
from .support_checker import check_support as _support_diagnostics
@@ -201,3 +202,58 @@ def transpile_full(pine_source: str, *, check_support: bool = True,
if d.level in (Level.WARNING, Level.NOTE)],
"requests": discover_requests(gen, ctx, sites),
}
+
+
+def transpile_with_request_inventory(pine_source: str, *, check_support: bool = True,
+ filename: str = "",
+ libraries: Mapping[str, str] | None = None,
+ primary_chart_timeframe: str | None = None) -> dict:
+ """Transpile like :func:`transpile_full`, plus the request-feed inventory
+ bound to the C++ it returns (``pineforge_codegen.request_feed_inventory``).
+
+ Runs the pipeline once (``_generate``) and returns six keys, the five
+ :func:`transpile_full` returns computed from that one generation by the
+ same expressions, and the inventory:
+
+ - ``cpp``: the generated C++ source (identical to :func:`transpile`).
+ - ``inputs``: the input manifest, as :func:`transpile_full` lists it.
+ - ``strategyParams``: the literal ``strategy(...)`` kwargs, as
+ :func:`transpile_full` lists them.
+ - ``diagnostics``: the warnings and notes, as :func:`transpile_full` lists
+ them.
+ - ``requests``: the other-symbol feed projection of
+ :func:`transpile_full` (``request_discovery.discover_requests``). It
+ leaves out the same-ticker requests, so it is not the strategy's
+ complete request list.
+ - ``request_feed_inventory``: the request-feed inventory bound to ``cpp``
+ (``build_request_feed_inventory``). It lists every bar request the
+ compiled strategy can make, same ticker included, from the same
+ registration context as ``requests``. Its ``artifact_sha256`` is null
+ until the trusted compile pipeline binds it after linking with
+ :func:`pineforge_codegen.request_feed_inventory.bind_request_feed_inventory`.
+
+ :func:`transpile` and :func:`transpile_full` are unchanged.
+
+ Args mirror :func:`transpile_full`, plus:
+ primary_chart_timeframe: The chart timeframe the compiled artifact is
+ bound to, as a wire token (a request that reads the chart's own
+ timeframe is then that token; without it, ``unknown``). A value
+ that is no wire token raises ``RequestFeedInventoryError``.
+
+ Returns:
+ ``{"cpp": str, "inputs": list[dict], "strategyParams": dict,
+ "diagnostics": list[Diagnostic], "requests": list[dict],
+ "request_feed_inventory": dict}``.
+ """
+ gen, ctx, cpp, support_diagnostics, sites = _generate(
+ pine_source, check_support, filename, libraries)
+ return {
+ "cpp": cpp,
+ "inputs": gen.extract_input_manifest(),
+ "strategyParams": dict(ctx.strategy_params),
+ "diagnostics": [d for d in (*support_diagnostics, *ctx.diagnostics)
+ if d.level in (Level.WARNING, Level.NOTE)],
+ "requests": discover_requests(gen, ctx, sites),
+ "request_feed_inventory": build_request_feed_inventory(
+ gen, ctx, sites, cpp, primary_chart_timeframe=primary_chart_timeframe),
+ }
diff --git a/pineforge_codegen/request_feed_inventory.py b/pineforge_codegen/request_feed_inventory.py
new file mode 100644
index 0000000..d3e183a
--- /dev/null
+++ b/pineforge_codegen/request_feed_inventory.py
@@ -0,0 +1,311 @@
+"""The request-feed inventory a compiled strategy carries (interface v1).
+
+A selected-window run decides, before it opens a feed or loads the strategy
+library, whether every timeframe the strategy requests lies inside the build's
+policy. ``build_request_feed_inventory`` is the producer half: for the C++ the
+pinned transpiler generated it lists every bar request the compiled strategy
+can make, and ``bind_request_feed_inventory`` binds that list to the linked
+artifact. One JSON object, exactly these keys::
+
+ {"schema": "pineforge-request-feed-inventory/v1",
+ "source_sha256": "<64 lowercase hex of the generated C++'s UTF-8 bytes>",
+ "artifact_sha256": null, # filled after linking, by the bind helper
+ "primary_chart_timeframe": null, # the compile caller's chart binding
+ "entries": [{"kind": "token", "timeframe": "D"},
+ {"kind": "input", "key": "HTF", "default": "240"},
+ {"kind": "unknown"}]}
+
+``entries`` holds one member per request site, in source order (line, column,
+then registration order). An empty list is complete; an ``unknown`` entry makes
+the inventory incomplete. No expression is executed to resolve an inventory: a
+timeframe is a token only where it is written, or where the codegen already
+folds it at compile time to a string constant, and an input reference names the
+manifest key (``codegen.input._get_input_title``) and the declaration's string
+default; anything else, a computed expression and a default folded from an
+input-dependent computation included, is ``unknown``.
+
+What is listed, so that none is dropped silently:
+
+* every registered request context that is not flagged dead
+ (``CodeGen._security_eval_info``): the chart's own symbol, another symbol's
+ feed and ``request.security_lower_tf`` alike, one per context a helper's call
+ paths give it (``security_contexts``) and per call-site clone. A helper no
+ top-level statement reaches whose request registers all the same (it is not
+ flagged dead) is listed too, where ``request_discovery`` leaves it out: a
+ superset only makes the inventory refuse more;
+* every deferred refusal ``request_discovery.request_sites`` kept (another
+ symbol's request registration cannot key before the first bar), by the
+ timeframe as written;
+* every other reachable ``request.security`` / ``request.security_lower_tf``
+ call in the analyzed program that neither of those accounts for, as
+ ``unknown`` (a request the pipeline did not classify is incomplete, never
+ omitted).
+
+Not listed: a request lowered to ``na`` (its value reaches display sinks only:
+the C++ makes no request), a request of a helper nothing reaches that
+registration flags dead (it registers the chart's own timeframe and is never
+read) and the recorded fundamentals requests (``request.financial`` /
+``earnings`` / ``dividends`` / ``splits``: series recorded per chart bar, no
+timeframe feed).
+
+A request that depends on the chart's own timeframe (an empty string,
+``timeframe.period``) is a token only when the compile caller passes
+``primary_chart_timeframe``: the inventory records that binding and the
+admission requires it to equal the request's primary token. Without it the
+entry is ``unknown``.
+
+Wire tokens (:func:`canonical_wire_timeframe`): a positive whole-minute decimal
+without leading zeros; ``D`` / ``1D`` is ``D`` and ``W`` / ``1W`` is ``W``;
+bare ``M`` / ``S`` are ``1M`` / ``1S``; any other positive ``nD`` / ``nW`` /
+``nM`` / ``nS`` stays as written. Whitespace, an empty string, a leading zero,
+a decimal and every other spelling (``1H`` included: the interface gives it no
+wire spelling) are no token and the entry is ``unknown``.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import re
+
+from .external_requests import LOWERING_ANNOTATION
+from .request_discovery import (
+ _UNPINNED_LOWERING, _guarded, _registered_timeframe, _request_args, _timeframe, _within,
+)
+from .security_contexts import ScriptIndex, _reached, _request_payload
+
+SCHEMA = "pineforge-request-feed-inventory/v1"
+
+_KEYS = frozenset({"schema", "source_sha256", "artifact_sha256",
+ "primary_chart_timeframe", "entries"})
+_KEY_ORDER = ("schema", "source_sha256", "artifact_sha256",
+ "primary_chart_timeframe", "entries")
+# ``[0-9]`` and no flags: a fullwidth digit, a trailing newline or a lowercase
+# unit is no token.
+_WIRE_TOKEN = re.compile(r"(?:([1-9][0-9]*)([DWMS])?|([DWMS]))")
+_HEX64 = re.compile(r"[0-9a-f]{64}")
+_BYTES_LIKE = (bytes, bytearray, memoryview)
+
+
+class RequestFeedInventoryError(ValueError):
+ """An inventory input the producer refuses: a primary chart timeframe that
+ is no wire token, source text that is not text, or an inventory the bind
+ helper cannot validate against the bytes it is given."""
+
+
+def canonical_wire_timeframe(text) -> str | None:
+ """``text`` as the inventory's wire token, or None when it is none."""
+ if type(text) is not str:
+ return None
+ matched = _WIRE_TOKEN.fullmatch(text)
+ if matched is None:
+ return None
+ digits, unit, bare = matched.groups()
+ if bare is not None:
+ return bare if bare in ("D", "W") else "1" + bare
+ if unit is None:
+ return digits
+ if digits == "1" and unit in ("D", "W"):
+ return unit
+ return digits + unit
+
+
+def build_request_feed_inventory(gen, ctx, sites, cpp, *, primary_chart_timeframe=None) -> dict:
+ """The unbound inventory (``artifact_sha256`` null) of the strategy ``gen``
+ generated ``cpp`` for (module docstring). ``sites`` are the requests the
+ support checker kept (``_generate``'s fifth result); ``ctx`` and ``gen``
+ are the analyzer's and the codegen's, read and left as they were."""
+ if type(cpp) is not str:
+ raise RequestFeedInventoryError("cpp is not the generated C++ source text")
+ chart = None
+ if primary_chart_timeframe is not None:
+ chart = canonical_wire_timeframe(primary_chart_timeframe)
+ if chart is None:
+ raise RequestFeedInventoryError(
+ f"primary_chart_timeframe {primary_chart_timeframe!r} is not a wire timeframe token")
+ return {
+ "schema": SCHEMA,
+ "source_sha256": hashlib.sha256(cpp.encode("utf-8")).hexdigest(),
+ "artifact_sha256": None,
+ "primary_chart_timeframe": chart,
+ "entries": _inventory_entries(gen, ctx, list(sites or ()), chart),
+ }
+
+
+def bind_request_feed_inventory(inventory, cpp_bytes, artifact_bytes) -> dict:
+ """A copy of the unbound ``inventory`` with ``artifact_sha256`` filled from
+ the linked artifact's bytes, once its shape is exact and its
+ ``source_sha256`` is that of ``cpp_bytes``. Reads no file and loads no
+ library; the trusted compile pipeline calls it right after linking."""
+ _check_shape(inventory)
+ if not isinstance(cpp_bytes, _BYTES_LIKE) or not isinstance(artifact_bytes, _BYTES_LIKE):
+ raise RequestFeedInventoryError("cpp_bytes and artifact_bytes must be bytes")
+ if inventory["artifact_sha256"] is not None:
+ raise RequestFeedInventoryError(
+ "inventory artifact_sha256 is already set; only an unbound inventory can be bound")
+ if hashlib.sha256(cpp_bytes).hexdigest() != inventory["source_sha256"]:
+ raise RequestFeedInventoryError(
+ "inventory source_sha256 does not match the supplied C++ bytes")
+ return {
+ "schema": inventory["schema"],
+ "source_sha256": inventory["source_sha256"],
+ "artifact_sha256": hashlib.sha256(artifact_bytes).hexdigest(),
+ "primary_chart_timeframe": inventory["primary_chart_timeframe"],
+ "entries": [_copy_entry(entry) for entry in inventory["entries"]],
+ }
+
+
+# ---------------------------------------------------------------------------
+# Binding: exact shape
+# ---------------------------------------------------------------------------
+
+def _is_hex64(value) -> bool:
+ return type(value) is str and _HEX64.fullmatch(value) is not None
+
+
+def _is_wire_token(value) -> bool:
+ return type(value) is str and canonical_wire_timeframe(value) == value
+
+
+def _check_shape(inventory) -> None:
+ if not isinstance(inventory, dict) or set(inventory) != _KEYS:
+ raise RequestFeedInventoryError(
+ "inventory is not an object with exactly the keys " + ", ".join(_KEY_ORDER))
+ if type(inventory["schema"]) is not str or inventory["schema"] != SCHEMA:
+ raise RequestFeedInventoryError(f"inventory schema is not {SCHEMA}")
+ if not _is_hex64(inventory["source_sha256"]):
+ raise RequestFeedInventoryError("inventory source_sha256 is not 64 lowercase hex digits")
+ artifact = inventory["artifact_sha256"]
+ if artifact is not None and not _is_hex64(artifact):
+ raise RequestFeedInventoryError(
+ "inventory artifact_sha256 is neither null nor 64 lowercase hex digits")
+ primary = inventory["primary_chart_timeframe"]
+ if primary is not None and not _is_wire_token(primary):
+ raise RequestFeedInventoryError(
+ "inventory primary_chart_timeframe is neither null nor a canonical wire token")
+ entries = inventory["entries"]
+ if not isinstance(entries, list):
+ raise RequestFeedInventoryError("inventory entries is not a list")
+ for position, entry in enumerate(entries):
+ if not _entry_has_shape(entry):
+ raise RequestFeedInventoryError(
+ f"inventory entries[{position}] is not exactly one of token, input or unknown")
+
+
+def _entry_has_shape(entry) -> bool:
+ if not isinstance(entry, dict):
+ return False
+ kind = entry.get("kind")
+ if kind == "token":
+ return set(entry) == {"kind", "timeframe"} and _is_wire_token(entry["timeframe"])
+ if kind == "input":
+ return (set(entry) == {"kind", "key", "default"}
+ and type(entry["key"]) is str and type(entry["default"]) is str)
+ if kind == "unknown":
+ return set(entry) == {"kind"}
+ return False
+
+
+def _copy_entry(entry: dict) -> dict:
+ if entry["kind"] == "token":
+ return {"kind": "token", "timeframe": entry["timeframe"]}
+ if entry["kind"] == "input":
+ return {"kind": "input", "key": entry["key"], "default": entry["default"]}
+ return {"kind": "unknown"}
+
+
+# ---------------------------------------------------------------------------
+# Entries
+# ---------------------------------------------------------------------------
+
+def _located(loc):
+ """``(line, column)`` of a located node, else None."""
+ if loc is None or loc.line is None or loc.col is None:
+ return None
+ return loc.line, loc.col
+
+
+def _entry(classified, chart) -> dict:
+ """One entry from ``request_discovery``'s classification of a timeframe:
+ only a literal, a chart dependency under the caller's binding and an input
+ whose default is a string are anything but ``unknown``."""
+ kind = classified.get("kind") if isinstance(classified, dict) else None
+ if kind == "literal":
+ token = canonical_wire_timeframe(classified.get("value"))
+ if token is not None:
+ return {"kind": "token", "timeframe": token}
+ elif kind == "chart":
+ if chart is not None:
+ return {"kind": "token", "timeframe": chart}
+ elif kind == "input":
+ key, default = classified.get("title"), classified.get("default")
+ if type(key) is str and type(default) is str:
+ return {"kind": "input", "key": key, "default": default}
+ return {"kind": "unknown"}
+
+
+def _inventory_entries(gen, ctx, sites: list, chart) -> list[dict]:
+ """Every request site's entry, in source order (module docstring)."""
+ found: list[tuple[tuple, dict]] = []
+
+ def add(loc, entry: dict) -> None:
+ line, col = _located(loc) or (0, 0)
+ found.append(((line, col, len(found)), entry))
+
+ calls = {call.sec_id: call for call in ctx.security_calls}
+ dead = {item["sec_id"] for item in gen._security_calls if item.get("dead")}
+ live = [info for info in gen._security_eval_info if info["sec_id"] not in dead]
+ deferred = [site for site in sites
+ if (site.annotations or {}).get(LOWERING_ANNOTATION) == _UNPINNED_LOWERING]
+ budget = getattr(gen, "_budget", None)
+ # Expanding a name registers a reassigned global's read with the first-bar
+ # replay; generation is over, so leave its record as it was (as
+ # ``discover_requests`` does).
+ mutable_reads = set(gen._security_tf_mutable_reads)
+ try:
+ for info in live:
+ # Past the time budget a timeframe is classified as written,
+ # unexpanded: an expression stays computed, so unknown.
+ classified = _guarded(_registered_timeframe, gen, info, expand=_within(budget))
+ add(getattr(calls.get(info["sec_id"]), "loc", None), _entry(classified, chart))
+ for site in deferred:
+ # The C++ never registered it, so nothing expands its names here.
+ _symbol, timeframe = _request_args(site)
+ classified = _guarded(_timeframe, gen, timeframe, expand=False)
+ add(site.loc, _entry(classified, chart))
+ try:
+ for node in _unaccounted_requests(ctx, calls, live, deferred):
+ add(node.loc, {"kind": "unknown"})
+ except Exception: # noqa: BLE001 -- a sweep that fails is an unclassified site
+ add(None, {"kind": "unknown"})
+ finally:
+ gen._security_tf_mutable_reads = mutable_reads
+ return [entry for _, entry in sorted(found, key=lambda item: item[0])]
+
+
+def _unaccounted_requests(ctx, calls: dict, live: list, deferred: list) -> list:
+ """The reachable ``request.security`` / ``request.security_lower_tf`` calls
+ of the analyzed program that no live context and no deferred site accounts
+ for, by the payload node the analyzer recorded for them, else by their
+ source position and kind. A call in a helper nothing reaches (a method's
+ is reached) is no site the strategy can make."""
+ index = ScriptIndex(ctx.ast)
+ reached = _reached(index)
+ live_calls = [calls[info["sec_id"]] for info in live if info["sec_id"] in calls]
+ payloads = {id(call.expression) for call in live_calls}
+ places = {(_located(call.loc), bool(call.is_lower_tf_array)) for call in live_calls}
+ deferred_ids = {id(site) for site in deferred}
+ deferred_places = {_located(site.loc) for site in deferred}
+ found = []
+ for owner, requests in index.requests.items():
+ if owner in index.funcs and owner not in reached:
+ continue
+ for node in requests:
+ payload = _request_payload(node)
+ place = _located(node.loc)
+ lower = node.callee.member == "security_lower_tf"
+ if id(node) in deferred_ids or (payload is not None and id(payload) in payloads):
+ continue
+ if place is not None and ((place, lower) in places or place in deferred_places):
+ continue
+ found.append(node)
+ return found
diff --git a/pineforge_codegen/timeframe_token_cli.py b/pineforge_codegen/timeframe_token_cli.py
new file mode 100644
index 0000000..9de1d8c
--- /dev/null
+++ b/pineforge_codegen/timeframe_token_cli.py
@@ -0,0 +1,133 @@
+"""Batch timeframe-token transport for the native admission's canonicalizer.
+
+``python -m pineforge_codegen.timeframe_token_cli`` reads one request on stdin
+and writes one result on stdout::
+
+ request: {"version": 1, "values": [, ...]}
+ result: {"version": 1, "tokens": [, ...]}
+
+The result keeps the request's order and cardinality and ends with one LF.
+Each element goes once, in order, to
+``request_feed_inventory.canonical_wire_timeframe``, which decides every token:
+a value that is no string, or no wire token, gives ``null``, and an empty batch
+gives an empty list.
+
+Exit status: 0 for a result (``null`` elements included). 2 for a malformed
+request: not UTF-8, not strict JSON, a repeated key, a key other than
+``version`` and ``values``, a ``version`` that is not the integer 1, or a
+``values`` that is not a list. 1 for any other failure. Both failures write
+nothing to stdout and one generic line to stderr, which never echoes the
+request.
+
+The transport reads no source, strategy, feed or artifact, runs no code from
+its input, converts no number and caps no input size.
+"""
+
+import json
+import sys
+
+from .request_feed_inventory import canonical_wire_timeframe
+
+PROTOCOL_VERSION = 1
+EXIT_OK = 0
+EXIT_INTERNAL = 1
+EXIT_MALFORMED = 2
+_REQUEST_KEYS = frozenset({"version", "values"})
+_MALFORMED_MESSAGE = "malformed timeframe token request"
+_INTERNAL_MESSAGE = "internal failure in the timeframe token transport"
+
+
+class _Malformed(Exception):
+ """The request is malformed (exit 2)."""
+
+
+class _JsonNumber:
+ """A JSON number, kept as its literal: nothing converts it, so no digit
+ limit applies either. It is no string, hence no token; the version must be
+ the integer literal ``1``."""
+
+ __slots__ = ("literal", "is_integer")
+
+ def __init__(self, literal: str, is_integer: bool) -> None:
+ self.literal = literal
+ self.is_integer = is_integer
+
+
+def _object(pairs: list) -> dict:
+ """A JSON object's members; a repeated key is refused (strict JSON)."""
+ members = {}
+ for key, value in pairs:
+ if key in members:
+ raise ValueError("duplicate key")
+ members[key] = value
+ return members
+
+
+def _refuse_constant(name: str):
+ """NaN, Infinity and -Infinity are not JSON."""
+ raise ValueError("not strict JSON")
+
+
+def _decode(raw: bytes):
+ """The request's JSON value; bytes that are no UTF-8 or no strict JSON are
+ malformed."""
+ try:
+ return json.loads(
+ raw.decode("utf-8"),
+ object_pairs_hook=_object,
+ parse_int=lambda literal: _JsonNumber(literal, True),
+ parse_float=lambda literal: _JsonNumber(literal, False),
+ parse_constant=_refuse_constant,
+ )
+ except (ValueError, RecursionError):
+ # UnicodeDecodeError is a ValueError. A document nested past the
+ # parser's recursion limit is JSON the parser cannot read.
+ raise _Malformed from None
+
+
+def _values(request) -> list:
+ """The batch's values, once the envelope is exact."""
+ if type(request) is not dict or set(request) != _REQUEST_KEYS:
+ raise _Malformed
+ version = request["version"]
+ if not (isinstance(version, _JsonNumber) and version.is_integer
+ and version.literal == "1"):
+ raise _Malformed
+ if type(request["values"]) is not list:
+ raise _Malformed
+ return request["values"]
+
+
+def _render(raw: bytes) -> bytes:
+ """The whole result, as the bytes to write at once."""
+ values = _values(_decode(raw))
+ # One direct call per value, in order: nothing is deduplicated or reused.
+ tokens = [canonical_wire_timeframe(value) for value in values]
+ text = json.dumps({"version": PROTOCOL_VERSION, "tokens": tokens},
+ separators=(",", ":"))
+ return (text + "\n").encode("ascii")
+
+
+def _refuse(code: int, message: str) -> int:
+ sys.stderr.write(message + "\n")
+ return code
+
+
+def main() -> int:
+ """Run one request and return the exit status (module docstring)."""
+ try:
+ output = _render(sys.stdin.buffer.read())
+ except _Malformed:
+ return _refuse(EXIT_MALFORMED, _MALFORMED_MESSAGE)
+ except Exception: # noqa: BLE001 -- every other failure is internal
+ return _refuse(EXIT_INTERNAL, _INTERNAL_MESSAGE)
+ try:
+ sys.stdout.buffer.write(output)
+ sys.stdout.buffer.flush()
+ except OSError:
+ return _refuse(EXIT_INTERNAL, _INTERNAL_MESSAGE)
+ return EXIT_OK
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/test_request_feed_inventory.py b/tests/test_request_feed_inventory.py
new file mode 100644
index 0000000..d35ee83
--- /dev/null
+++ b/tests/test_request_feed_inventory.py
@@ -0,0 +1,402 @@
+"""The request-feed inventory (``pineforge_codegen.request_feed_inventory``,
+interface v1): every bar request a compiled strategy can make, as a wire token,
+an input reference or ``unknown``, bound to the C++ it was generated from.
+
+The inventory is read before a selected-window run opens a feed or loads the
+strategy library, so each test states what an admission may rely on: an empty
+list is complete, ``unknown`` is incomplete, a token is exactly a timeframe the
+script writes (or the caller's chart binding), an input reference names the
+manifest key and its string default, and binding refuses bytes the inventory
+was not made from. ``transpile`` and ``transpile_full`` stay as they were.
+"""
+
+from __future__ import annotations
+
+import hashlib
+
+import pytest
+
+import pineforge_codegen
+from pineforge_codegen import (
+ _generate, transpile, transpile_full, transpile_with_request_inventory,
+)
+from pineforge_codegen.errors import Level
+from pineforge_codegen.request_feed_inventory import (
+ SCHEMA, RequestFeedInventoryError, bind_request_feed_inventory,
+ build_request_feed_inventory, canonical_wire_timeframe,
+)
+
+HEAD = '//@version=6\nstrategy("inventory", overlay = true)\n'
+TRADE = 'if c > 0\n strategy.entry("L", strategy.long)\n'
+UNKNOWN = {"kind": "unknown"}
+
+
+def _sha(data: bytes) -> str:
+ return hashlib.sha256(data).hexdigest()
+
+
+def _inventory(body: str, **options) -> dict:
+ return transpile_with_request_inventory(HEAD + body, **options)["request_feed_inventory"]
+
+
+def _entries(body: str, **options) -> list[dict]:
+ return _inventory(body, **options)["entries"]
+
+
+def _token(timeframe: str) -> dict:
+ return {"kind": "token", "timeframe": timeframe}
+
+
+# ---------------------------------------------------------------------------
+# What the inventory holds
+# ---------------------------------------------------------------------------
+
+def test_no_requests_is_a_complete_empty_inventory():
+ result = transpile_with_request_inventory(HEAD + "c = close\n" + TRADE)
+ assert set(result) == {"cpp", "inputs", "strategyParams", "diagnostics", "requests",
+ "request_feed_inventory"}
+ assert result["request_feed_inventory"] == {
+ "schema": SCHEMA, "source_sha256": _sha(result["cpp"].encode("utf-8")),
+ "artifact_sha256": None, "primary_chart_timeframe": None, "entries": [],
+ }
+ assert SCHEMA == "pineforge-request-feed-inventory/v1"
+
+
+@pytest.mark.parametrize("tf, token", [("60", "60"), ("D", "D")])
+def test_same_ticker_literal_timeframe_is_a_token(tf, token):
+ """The chart's own symbol reads no feed of another symbol, but its request
+ registers a timeframe all the same: it is listed (``discover_requests``
+ leaves it out)."""
+ assert _entries(f'c = request.security(syminfo.tickerid, "{tf}", close)\n' + TRADE) == [
+ _token(token)]
+
+
+@pytest.mark.parametrize("tf, token", [
+ ("240", "240"), ("D", "D"), ("1D", "D"), ("W", "W"), ("1W", "W"), ("M", "1M"),
+ ("30S", "30S"),
+])
+def test_other_symbol_literal_timeframe_is_a_token(tf, token):
+ assert _entries(f'c = request.security("BINANCE:ETHUSDT", "{tf}", close)\n' + TRADE) == [
+ _token(token)]
+
+
+def test_entries_follow_the_source_order():
+ entries = _entries(
+ 'a = request.security("BINANCE:ETHUSDT", "240", close)\n'
+ 'b = request.security(syminfo.tickerid, "D", close)\n'
+ 'd = request.security("BYBIT:SOLUSDT.P", "1W", close)\n'
+ 'c = a + b + d\n' + TRADE)
+ assert entries == [_token("240"), _token("D"), _token("W")]
+
+
+@pytest.mark.parametrize("title_args, key", [
+ ('"D", "HTF"', "HTF"),
+ ('"240", title = "Higher timeframe"', "Higher timeframe"),
+ ('"D"', "htf"),
+])
+def test_input_reference_names_the_manifest_key_and_the_string_default(title_args, key):
+ """The key is the one an override is keyed by (the manifest's ``title``: the
+ title argument, else the declaration's name) and the default is the
+ declaration's own string, as written."""
+ source = HEAD + (f'htf = input.timeframe({title_args})\n'
+ 'c = request.security("BINANCE:ETHUSDT", htf, close)\n' + TRADE)
+ default = title_args.split(",")[0].strip().strip('"')
+ result = transpile_with_request_inventory(source)
+ assert result["request_feed_inventory"]["entries"] == [
+ {"kind": "input", "key": key, "default": default}]
+ manifest = {entry["title"]: entry for entry in transpile_full(source)["inputs"]}
+ assert manifest[key]["default"] == default
+
+
+def test_input_string_reference_and_its_override_key():
+ source = HEAD + ('mode = input.string("60", "Request timeframe")\n'
+ 'c = request.security(syminfo.tickerid, mode, close)\n' + TRADE)
+ inventory = transpile_with_request_inventory(source)["request_feed_inventory"]
+ assert inventory["entries"] == [
+ {"kind": "input", "key": "Request timeframe", "default": "60"}]
+ assert "Request timeframe" in {e["title"] for e in transpile_full(source)["inputs"]}
+
+
+def test_helper_contexts_are_one_entry_per_call_path():
+ """A helper's request registers once per symbol and timeframe its call
+ paths pass (``security_contexts``): each context is an entry."""
+ entries = _entries(
+ 'o = input.symbol("BINANCE:ETHUSDT", "Other")\n'
+ 'g(s, tf) => request.security(s, tf, close)\n'
+ 'h(s) => g(s, "60") + g(s, "D")\n'
+ 'c = h(o) + g("BINANCE:SOLUSDT", "240")\n' + TRADE)
+ # One line: the order within it is the order the C++ registers them in.
+ assert sorted(entry["timeframe"] for entry in entries) == ["240", "60", "D"]
+ assert all(entry["kind"] == "token" for entry in entries)
+
+
+@pytest.mark.parametrize("body", [
+ 'useD = input.bool(true, "Use D")\ntf = useD ? "D" : timeframe.period\n'
+ 'c = request.security("BINANCE:ETHUSDT", tf, close)\n',
+ 'mode = input.string("A", "Mode")\ntf = switch mode\n "A" => "60"\n "B" => "D"\n'
+ 'c = request.security("BINANCE:ETHUSDT", tf, close)\n',
+])
+def test_computed_timeframe_is_unknown_never_a_guessed_token(body):
+ """``discover_requests`` folds these at the inputs' defaults ("1D", "60");
+ an inventory states no default it computed, whoever binds the chart."""
+ assert _entries(body + TRADE) == [UNKNOWN]
+ assert _entries(body + TRADE, primary_chart_timeframe="15") == [UNKNOWN]
+
+
+def test_deferred_refusal_is_listed_by_its_timeframe_as_written():
+ """Registration cannot key the reassigned symbol, so the run stops where
+ the value is read; the site is still a request the script makes."""
+ source = ('var string s = "BINANCE:ETHUSDT"\n'
+ 's := close > open ? "BINANCE:ETHUSDT" : "BINANCE:BTCUSDT"\n'
+ 'c = request.security(s, "60", close)\n' + TRADE)
+ assert _entries(source) == [_token("60")]
+ deferred_tf = ('var string tf = "60"\nif bar_index == 0\n tf := "240"\n'
+ 'c = request.security("BINANCE:ETHUSDT", tf, close)\n' + TRADE)
+ assert _entries(deferred_tf) == [UNKNOWN]
+
+
+def test_lower_timeframe_request_is_listed():
+ entries = _entries(
+ 'c = array.size(request.security_lower_tf("BINANCE:ETHUSDT", "1", close))\n' + TRADE)
+ assert entries == [_token("1")]
+
+
+def test_a_request_lowered_to_na_is_not_a_request():
+ """Its value reaches plots only: the C++ makes no request, so a complete
+ empty list is true of it."""
+ source = ('c = request.security("BINANCE:ETHUSDT", "60", close)\nplot(c)\n'
+ 'if close > open\n strategy.entry("L", strategy.long)\n')
+ assert _entries(source) == []
+
+
+# ---------------------------------------------------------------------------
+# The chart binding
+# ---------------------------------------------------------------------------
+
+@pytest.mark.parametrize("body", [
+ 'c = request.security("BINANCE:ETHUSDT", timeframe.period, close)\n',
+ 'c = request.security("BINANCE:ETHUSDT", timeframe.main_period, close)\n',
+ 'tf = ""\nc = request.security("BINANCE:ETHUSDT", tf, close)\n',
+])
+def test_chart_dependency_is_a_token_only_under_the_callers_binding(body):
+ unbound = _inventory(body + TRADE)
+ assert (unbound["primary_chart_timeframe"], unbound["entries"]) == (None, [UNKNOWN])
+ bound = _inventory(body + TRADE, primary_chart_timeframe="15")
+ assert (bound["primary_chart_timeframe"], bound["entries"]) == ("15", [_token("15")])
+ # The binding is a wire token like any other: spelled 1D, it is D.
+ daily = _inventory(body + TRADE, primary_chart_timeframe="1D")
+ assert (daily["primary_chart_timeframe"], daily["entries"]) == ("D", [_token("D")])
+
+
+def test_a_binding_changes_no_literal_entry():
+ body = 'c = request.security("BINANCE:ETHUSDT", "D", close)\n' + TRADE
+ assert _entries(body, primary_chart_timeframe="15") == [_token("D")]
+
+
+@pytest.mark.parametrize("binding", ["", " 15", "15 ", "05", "1H", "abc", 15, b"15"])
+def test_a_binding_that_is_no_wire_token_is_refused(binding):
+ with pytest.raises(RequestFeedInventoryError, match="is not a wire timeframe token"):
+ transpile_with_request_inventory(HEAD + "c = close\n" + TRADE,
+ primary_chart_timeframe=binding)
+
+
+@pytest.mark.parametrize("text, token", [
+ ("1", "1"), ("5", "5"), ("60", "60"), ("240", "240"), ("1440", "1440"),
+ ("D", "D"), ("1D", "D"), ("2D", "2D"), ("W", "W"), ("1W", "W"), ("3W", "3W"),
+ ("M", "1M"), ("1M", "1M"), ("12M", "12M"), ("S", "1S"), ("1S", "1S"), ("30S", "30S"),
+])
+def test_wire_tokens(text, token):
+ assert canonical_wire_timeframe(text) == token
+
+
+@pytest.mark.parametrize("text", [
+ "", " 5", "5 ", "05", "0", "00", "0D", "-5", "1.5", "5.0", "1H", "4H", "H", "d", "1d",
+ "2w", "60m", "15", "5\n", "D1", "1DD", None, 15, b"5",
+])
+def test_not_wire_tokens(text):
+ assert canonical_wire_timeframe(text) is None
+
+
+# ---------------------------------------------------------------------------
+# The generated source and the generation state are left alone
+# ---------------------------------------------------------------------------
+
+def test_the_cpp_is_exactly_the_one_transpile_returns():
+ source = HEAD + ('other = input.symbol("BINANCE:ETHUSDT", "Other symbol")\n'
+ 'c = request.security(other, "D", close)\n' + TRADE)
+ plain = transpile(source)
+ result = transpile_with_request_inventory(source)
+ assert result["cpp"] == plain == transpile_full(source)["cpp"]
+ assert result["request_feed_inventory"]["source_sha256"] == _sha(plain.encode("utf-8"))
+ assert set(transpile_full(source)) == {
+ "cpp", "inputs", "strategyParams", "diagnostics", "requests"}
+ assert transpile_with_request_inventory(source) == result
+
+
+def test_building_leaves_the_generation_state_as_it_was():
+ source = HEAD + ('htf = input.timeframe("D", "HTF")\n'
+ 'c = request.security("BINANCE:ETHUSDT", htf, close)\n' + TRADE)
+ gen, ctx, cpp, _support, sites = _generate(source, True, "")
+ before = (set(gen._security_tf_mutable_reads), len(ctx.diagnostics), len(sites),
+ [info["sec_id"] for info in gen._security_eval_info])
+ build_request_feed_inventory(gen, ctx, sites, cpp)
+ assert before == (set(gen._security_tf_mutable_reads), len(ctx.diagnostics), len(sites),
+ [info["sec_id"] for info in gen._security_eval_info])
+
+
+# ---------------------------------------------------------------------------
+# Binding
+# ---------------------------------------------------------------------------
+
+CPP = b"// generated source\n"
+
+
+def _valid() -> dict:
+ return {"schema": SCHEMA, "source_sha256": _sha(CPP), "artifact_sha256": None,
+ "primary_chart_timeframe": None,
+ "entries": [_token("D"), {"kind": "input", "key": "HTF", "default": "240"},
+ dict(UNKNOWN)]}
+
+
+def test_binding_fills_the_artifact_digest_into_a_copy():
+ artifact = b"\x7fELF linked artifact"
+ original = _valid()
+ bound = bind_request_feed_inventory(original, CPP, artifact)
+ assert bound == {**_valid(), "artifact_sha256": _sha(artifact)}
+ assert list(bound) == ["schema", "source_sha256", "artifact_sha256",
+ "primary_chart_timeframe", "entries"]
+ assert original == _valid()
+ bound["entries"][0]["timeframe"] = "W"
+ assert original["entries"][0] == _token("D")
+
+
+def test_binding_a_transpiled_inventory_to_its_exact_utf8_bytes():
+ result = transpile_with_request_inventory(
+ HEAD + 'c = request.security("BINANCE:ETHUSDT", "D", close)\n' + TRADE)
+ inventory, cpp = result["request_feed_inventory"], result["cpp"]
+ artifact = b"linked"
+ bound = bind_request_feed_inventory(inventory, cpp.encode("utf-8"), artifact)
+ assert bound["artifact_sha256"] == _sha(artifact)
+ assert bound["entries"] == [_token("D")]
+ assert inventory["artifact_sha256"] is None
+
+
+def test_binding_refuses_source_bytes_the_inventory_was_not_made_from():
+ result = transpile_with_request_inventory(
+ HEAD + 'c = request.security("BINANCE:ETHUSDT", "D", close)\n' + TRADE)
+ inventory, cpp = result["request_feed_inventory"], result["cpp"]
+ other = transpile(HEAD + 'c = request.security("BINANCE:ETHUSDT", "W", close)\n' + TRADE)
+ for wrong in (cpp.encode("utf-8") + b"\n", cpp.replace("\n", "\r\n").encode("utf-8"),
+ other.encode("utf-8"), b""):
+ with pytest.raises(RequestFeedInventoryError,
+ match="source_sha256 does not match the supplied C\\+\\+ bytes"):
+ bind_request_feed_inventory(inventory, wrong, b"linked")
+
+
+def test_binding_refuses_an_inventory_that_is_already_bound():
+ inventory = _valid()
+ inventory["artifact_sha256"] = "0" * 64
+ with pytest.raises(RequestFeedInventoryError, match="artifact_sha256 is already set"):
+ bind_request_feed_inventory(inventory, CPP, b"linked")
+
+
+@pytest.mark.parametrize("cpp_bytes, artifact_bytes", [
+ (CPP.decode(), b"linked"), (CPP, "linked"), (None, b"linked"), (CPP, None),
+])
+def test_binding_takes_bytes_only(cpp_bytes, artifact_bytes):
+ with pytest.raises(RequestFeedInventoryError, match="must be bytes"):
+ bind_request_feed_inventory(_valid(), cpp_bytes, artifact_bytes)
+
+
+@pytest.mark.parametrize("mutate", [
+ lambda inv: inv.pop("entries"),
+ lambda inv: inv.update(extra=1),
+ lambda inv: inv.update(schema="pineforge-request-feed-inventory/v2"),
+ lambda inv: inv.update(source_sha256="A" * 64),
+ lambda inv: inv.update(source_sha256="abc"),
+ lambda inv: inv.update(artifact_sha256="abc"),
+ lambda inv: inv.update(primary_chart_timeframe="1D"),
+ lambda inv: inv.update(primary_chart_timeframe=15),
+ lambda inv: inv.update(entries={}),
+ lambda inv: inv["entries"].append({"kind": "token"}),
+ lambda inv: inv["entries"].append({"kind": "token", "timeframe": "05"}),
+ lambda inv: inv["entries"].append({"kind": "token", "timeframe": "1D"}),
+ lambda inv: inv["entries"].append({"kind": "token", "timeframe": ""}),
+ lambda inv: inv["entries"].append({"kind": "input", "key": "K", "default": 5}),
+ lambda inv: inv["entries"].append({"kind": "input", "key": "K"}),
+ lambda inv: inv["entries"].append({"kind": "unknown", "timeframe": "D"}),
+ lambda inv: inv["entries"].append({"kind": "other"}),
+ lambda inv: inv["entries"].append("D"),
+])
+def test_binding_refuses_an_inexact_shape(mutate):
+ inventory = _valid()
+ mutate(inventory)
+ with pytest.raises(RequestFeedInventoryError):
+ bind_request_feed_inventory(inventory, CPP, b"linked")
+
+
+@pytest.mark.parametrize("inventory", [None, [], "inventory", 7])
+def test_binding_refuses_what_is_no_object(inventory):
+ with pytest.raises(RequestFeedInventoryError, match="is not an object with exactly the keys"):
+ bind_request_feed_inventory(inventory, CPP, b"linked")
+
+
+# ---------------------------------------------------------------------------
+# transpile_with_request_inventory against transpile_full
+# ---------------------------------------------------------------------------
+
+# Input metadata of three kinds (string, int, symbol), strategy() parameters, a
+# same-ticker daily request, a feed read by a trade, and the two diagnostics
+# this script raises: ta.ema's warmup note and bar_index's window warning.
+METADATA = (
+ '//@version=6\n'
+ 'strategy("metadata", overlay = true, initial_capital = 10000, pyramiding = 2)\n'
+ 'mode = input.string("fast", "Mode", options = ["fast", "slow"])\n'
+ 'length = input.int(14, "Length", options = [14, 21])\n'
+ 'other = input.symbol("BINANCE:ETHUSDT", "Other symbol")\n'
+ 'e = ta.ema(close, length)\n'
+ 'daily = request.security(syminfo.tickerid, "D", close)\n'
+ 'feed = request.security(other, "240", close)\n'
+ 'if mode == "fast" and e > daily and feed > 0 and bar_index > 0\n'
+ ' strategy.entry("L", strategy.long)\n'
+)
+
+
+def test_new_api_returns_the_transpile_full_fields_from_one_generation():
+ legacy = transpile_full(METADATA)
+ result = transpile_with_request_inventory(METADATA)
+ assert set(result) == {"cpp", "inputs", "strategyParams", "diagnostics", "requests",
+ "request_feed_inventory"}
+ assert result["cpp"] == transpile(METADATA) == legacy["cpp"]
+ for key in ("inputs", "strategyParams", "diagnostics", "requests"):
+ assert result[key] == legacy[key], key
+ assert [entry["title"] for entry in result["inputs"]] == ["Mode", "Length", "Other symbol"]
+ assert {"initial_capital", "pyramiding"} <= set(result["strategyParams"])
+ assert {Level.WARNING, Level.NOTE} <= {d.level for d in result["diagnostics"]}
+ assert result["requests"] == [{
+ "line": 8, "fn": "request.security",
+ "symbol": {"kind": "input", "title": "Other symbol", "default": "BINANCE:ETHUSDT"},
+ "timeframe": {"kind": "literal", "value": "240"},
+ "lookahead": False, "gaps": False, "ignore_invalid_symbol": False,
+ }]
+ assert result["request_feed_inventory"]["entries"] == [_token("D"), _token("240")]
+
+
+def test_new_api_runs_generation_exactly_once(monkeypatch):
+ generate = pineforge_codegen._generate
+ calls = []
+
+ def counted(*args, **kwargs):
+ calls.append(args)
+ return generate(*args, **kwargs)
+
+ monkeypatch.setattr(pineforge_codegen, "_generate", counted)
+ transpile_with_request_inventory(METADATA)
+ assert len(calls) == 1
+
+
+def test_same_ticker_daily_request_is_inventoried_though_requests_leaves_it_out():
+ source = HEAD + 'c = request.security(syminfo.tickerid, "D", close)\n' + TRADE
+ result = transpile_with_request_inventory(source)
+ assert transpile_full(source)["requests"] == result["requests"] == []
+ assert result["request_feed_inventory"]["entries"] == [_token("D")]
diff --git a/tests/test_timeframe_token_cli.py b/tests/test_timeframe_token_cli.py
new file mode 100644
index 0000000..5485a77
--- /dev/null
+++ b/tests/test_timeframe_token_cli.py
@@ -0,0 +1,205 @@
+"""The batch transport ``python -m pineforge_codegen.timeframe_token_cli``.
+
+The in-process cases call ``main()`` with its streams replaced and count the calls
+to the shared ``canonical_wire_timeframe``; the subprocess cases check the exit
+status and the bytes on stdout. No case reads a source, a strategy or a feed.
+"""
+
+import io
+import json
+import subprocess
+import sys
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+
+from pineforge_codegen import request_feed_inventory, timeframe_token_cli
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+MALFORMED_TEXT = "malformed timeframe token request\n"
+INTERNAL_TEXT = "internal failure in the timeframe token transport\n"
+
+
+def _result(tokens: list) -> bytes:
+ text = json.dumps({"version": 1, "tokens": tokens}, separators=(",", ":"))
+ return (text + "\n").encode("ascii")
+
+
+def _request(values: list) -> bytes:
+ return json.dumps({"version": 1, "values": values}).encode("utf-8")
+
+
+def _main(monkeypatch, raw: bytes):
+ """``main()`` on ``raw`` with stdin, stdout and stderr replaced: returns
+ (exit status, stdout bytes, stderr text)."""
+ stdout = SimpleNamespace(buffer=io.BytesIO())
+ stderr = io.StringIO()
+ monkeypatch.setattr(sys, "stdin", SimpleNamespace(buffer=io.BytesIO(raw)))
+ monkeypatch.setattr(sys, "stdout", stdout)
+ monkeypatch.setattr(sys, "stderr", stderr)
+ code = timeframe_token_cli.main()
+ return code, stdout.buffer.getvalue(), stderr.getvalue()
+
+
+def _spy(monkeypatch) -> list:
+ """Route the module's ``canonical_wire_timeframe`` through a recorder of
+ its arguments that calls the shared function; returns the record."""
+ calls: list = []
+ shared = timeframe_token_cli.canonical_wire_timeframe
+
+ def recorder(text):
+ calls.append(text)
+ return shared(text)
+
+ monkeypatch.setattr(timeframe_token_cli, "canonical_wire_timeframe", recorder)
+ return calls
+
+
+def _subprocess(raw: bytes):
+ return subprocess.run(
+ [sys.executable, "-m", "pineforge_codegen.timeframe_token_cli"],
+ input=raw, capture_output=True, cwd=REPO_ROOT, check=False)
+
+
+def test_the_batch_uses_the_shared_function_itself():
+ assert (timeframe_token_cli.canonical_wire_timeframe
+ is request_feed_inventory.canonical_wire_timeframe)
+
+
+def test_a_result_is_one_compact_line(monkeypatch):
+ code, out, err = _main(monkeypatch, b'{"version":1,"values":["240","1D",null]}')
+ assert (code, err) == (0, "")
+ assert out == b'{"version":1,"tokens":["240","D",null]}\n'
+
+
+@pytest.mark.parametrize("value, token", [
+ ("1", "1"), ("240", "240"), ("D", "D"), ("1D", "D"), ("2D", "2D"),
+ ("W", "W"), ("1W", "W"), ("2W", "2W"), ("M", "1M"), ("1M", "1M"),
+ ("12M", "12M"), ("S", "1S"), ("1S", "1S"), ("30S", "30S"),
+])
+def test_aliases_and_spellings_give_the_canonical_token(monkeypatch, value, token):
+ code, out, err = _main(monkeypatch, _request([value]))
+ assert (code, err) == (0, "")
+ assert out == _result([token])
+
+
+@pytest.mark.parametrize("value", [
+ "", " 5", "5 ", "05", "0", "00", "0D", "-5", "1.5", "5.0", "1H", "4H", "H",
+ "d", "2w", "60m", "D1", "1DD", "15", "5\n",
+ 15, 1.5, True, False, None, [], {}, ["D"], {"timeframe": "D"},
+])
+def test_a_string_that_is_no_token_and_any_nonstring_give_null(monkeypatch, value):
+ code, out, err = _main(monkeypatch, _request([value]))
+ assert (code, err) == (0, "")
+ assert out == _result([None])
+
+
+def test_a_long_known_token_comes_back_unchanged(monkeypatch):
+ token = "1" + "0" * 5000 + "D"
+ code, out, err = _main(monkeypatch, _request([token]))
+ assert (code, err) == (0, "")
+ assert out == _result([token])
+
+
+def test_an_empty_batch_gives_an_empty_list(monkeypatch):
+ code, out, err = _main(monkeypatch, b'{"version":1,"values":[]}')
+ assert (code, err) == (0, "")
+ assert out == b'{"version":1,"tokens":[]}\n'
+
+
+def test_order_and_cardinality_are_kept_with_no_deduplication(monkeypatch):
+ values = ["240", "D", "bogus", "240", None, 5, "W", "D"]
+ code, out, _ = _main(monkeypatch, _request(values))
+ assert code == 0
+ assert out == _result(["240", "D", None, "240", None, None, "W", "D"])
+
+
+def test_each_value_gets_one_direct_call_in_order(monkeypatch):
+ calls = _spy(monkeypatch)
+ values = ["D", "240", "bogus", "D", None, ""]
+ code, out, _ = _main(monkeypatch, _request(values))
+ assert code == 0
+ assert calls == values
+ assert out == _result(["D", "240", None, "D", None, None])
+
+
+def test_a_number_and_a_bool_are_one_call_each(monkeypatch):
+ calls = _spy(monkeypatch)
+ code, out, _ = _main(monkeypatch, b'{"version":1,"values":[5,true,"D"]}')
+ assert code == 0
+ assert len(calls) == 3
+ assert out == _result([None, None, "D"])
+
+
+@pytest.mark.parametrize("raw", [
+ b'{"version":1,"version":1,"values":[]}',
+ b'{"version":1,"values":[],"values":[]}',
+ b'{"version":1,"values":[{"a":1,"a":2}]}',
+ b'{"version":1,"values":[],"extra":0}',
+ b'{"version":1}',
+ b'{"values":[]}',
+ b'{"version":1,"values":"D"}',
+ b'{"version":1,"values":{}}',
+ b'{"version":1,"values":null}',
+ b"[]",
+ b'"text"',
+ b"7",
+ b"null",
+])
+def test_a_repeated_extra_or_missing_key_or_a_non_object_exits_2(monkeypatch, raw):
+ assert _main(monkeypatch, raw) == (2, b"", MALFORMED_TEXT)
+
+
+@pytest.mark.parametrize("version", [
+ b"true", b"false", b"null", b"1.0", b"1e0", b"1E0", b'"1"', b"[1]", b"{}",
+ b"0", b"2", b"-1",
+])
+def test_a_version_that_is_not_the_integer_one_exits_2(monkeypatch, version):
+ raw = b'{"version":' + version + b',"values":[]}'
+ assert _main(monkeypatch, raw) == (2, b"", MALFORMED_TEXT)
+
+
+@pytest.mark.parametrize("raw", [
+ b'{"version":1,"values":["\xff"]}',
+ b'{"version":1,"values":["\xed\xa0\x80"]}',
+ b"\xef\xbb\xbf" + b'{"version":1,"values":[]}',
+ b'{"version":1,"values":[',
+ b"",
+ b'{"version":1,"values":[]} x',
+ b'{"version":1,"values":[NaN]}',
+ b'{"version":1,"values":[-Infinity]}',
+ b'{"version":1,"values":[1,]}',
+ b'{"version":1,"values":["a\x01b"]}',
+])
+def test_bytes_that_are_no_utf8_or_no_strict_json_exit_2(monkeypatch, raw):
+ assert _main(monkeypatch, raw) == (2, b"", MALFORMED_TEXT)
+
+
+def test_a_document_nested_past_the_parser_limit_exits_2(monkeypatch):
+ assert _main(monkeypatch, b"[" * 100000) == (2, b"", MALFORMED_TEXT)
+
+
+def test_an_unexpected_failure_exits_1_with_no_stdout(monkeypatch):
+ def broken(text):
+ raise RuntimeError("broken on purpose")
+
+ monkeypatch.setattr(timeframe_token_cli, "canonical_wire_timeframe", broken)
+ assert _main(monkeypatch, _request(["D"])) == (1, b"", INTERNAL_TEXT)
+
+
+def test_subprocess_exits_0_with_the_result_line():
+ proc = _subprocess(b'{"version":1,"values":["240","1D",null]}')
+ assert (proc.returncode, proc.stdout, proc.stderr) == (
+ 0, b'{"version":1,"tokens":["240","D",null]}\n', b"")
+
+
+@pytest.mark.parametrize("raw", [
+ b'{"version":1,"version":1,"values":[]}',
+ b"\xff",
+ b'{"version":true,"values":[]}',
+])
+def test_subprocess_refuses_a_malformed_request_with_exit_2_and_no_stdout(raw):
+ proc = _subprocess(raw)
+ assert (proc.returncode, proc.stdout, proc.stderr) == (
+ 2, b"", MALFORMED_TEXT.encode("ascii"))