@pineforge/codegen-pyodide 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -32,6 +32,29 @@ class Diagnostic:
32
32
  message: str
33
33
  hint: str | None = None
34
34
 
35
+ # The stable code (``PF-E1203`` / ``PF-W0412``) and named arguments of the
36
+ # catalog template the English ``message`` and ``hint`` render from
37
+ # (``diagnostic_codes``, ``diagnostics_catalog.json``). Both are read off
38
+ # the text when first asked for, after the transpile: the text and the
39
+ # C++ never depend on them.
40
+ @property
41
+ def code(self) -> str:
42
+ return self._coded()[0]
43
+
44
+ @property
45
+ def args(self) -> dict:
46
+ return self._coded()[1]
47
+
48
+ def _coded(self) -> tuple[str, dict]:
49
+ severity = getattr(self.level, "value", self.level)
50
+ key = (severity, self.message, self.hint)
51
+ cached = self.__dict__.get("_pf_coded")
52
+ if cached is None or cached[0] != key:
53
+ from .diagnostic_codes import classify
54
+ cached = (key, classify(severity, self.message, self.hint))
55
+ self.__dict__["_pf_coded"] = cached
56
+ return cached[1]
57
+
35
58
 
36
59
  class CompileError(Exception):
37
60
  def __init__(self, diagnostics: list[Diagnostic]):
@@ -85,6 +85,19 @@ _RECORDED_PARAMS = {
85
85
  # RequestRef}``: the run stops there only when no data is installed for the
86
86
  # request that carries the same ref (``REQUEST_REF_ANNOTATION``).
87
87
  UNPINNED_ANNOTATION = "pf_request_unpinned"
88
+ CAPABILITY_UNPINNED_ANNOTATION = "pf_capability_unpinned_requests"
89
+
90
+
91
+ def _record_unpinned_capability(program, request):
92
+ notes = program.annotations = dict(program.annotations or {})
93
+ sites = notes.setdefault(CAPABILITY_UNPINNED_ANNOTATION, [])
94
+ sites.append({
95
+ "function": f"request.{request.callee.member}",
96
+ "symbol_node": request.args[0] if request.args else request.kwargs.get("symbol"),
97
+ "tf_node": request.args[1] if len(request.args) > 1 else request.kwargs.get("timeframe"),
98
+ "lookahead_node": request.kwargs.get("lookahead"),
99
+ "gaps_node": request.kwargs.get("gaps"),
100
+ })
88
101
  REQUEST_REF_ANNOTATION = "pf_request_ref"
89
102
  # On a request whose symbol can select the chart's or another symbol's: it
90
103
  # read the chart before it read a feed, and keeps that lowering where no feed
@@ -785,10 +798,12 @@ def lower_no_data_requests(program: Program) -> Program:
785
798
  backed.append(node)
786
799
  continue
787
800
  if lowering == ABSENT_LOWERING:
801
+ _record_unpinned_capability(program, node)
788
802
  node.annotations = {**node.annotations, UNPINNED_ANNOTATION: unpinned_message(node)}
789
803
  continue
790
804
  swaps[id(node)] = _na_of(node, funcs)
791
805
  if lowering == "unpinned":
806
+ _record_unpinned_capability(program, node)
792
807
  unpinned.append(node)
793
808
  if unpinned or backed:
794
809
  index = ScriptIndex(program)
@@ -853,11 +868,15 @@ def unpin_requests(program: Program, reasons: dict[int, str]) -> None:
853
868
  "results."))
854
869
  continue
855
870
  lowered = _na_of(request, funcs)
871
+ _record_unpinned_capability(program, request)
856
872
  marker = (request.annotations or {}).get(UNPINNED_ANNOTATION)
857
873
  if isinstance(marker, dict):
858
874
  lowered.annotations = {**(lowered.annotations or {}),
859
875
  UNPINNED_ANNOTATION: marker["message"]}
860
876
  swaps[request_id] = lowered
877
+ # The request leaves the program; its lowering is the deferred
878
+ # refusal's now (``request_discovery`` lists it so).
879
+ request.annotations = {**(request.annotations or {}), LOWERING_ANNOTATION: "unpinned"}
861
880
  warnings.append(pass_warning(
862
881
  request,
863
882
  f"{spell_call(request)}: no data is pinned for this request; the run stops with "
@@ -0,0 +1,506 @@
1
+ """The other symbols' data a script requests, listed before it runs.
2
+
3
+ ``transpile_full(...)["requests"]`` names every request site that reads
4
+ another symbol's feed, so a host can fetch the bars a run needs before it
5
+ starts it. A run supplies another symbol's bars as one feed per (symbol
6
+ string, timeframe) and the engine looks a site's feed up byte for byte
7
+ (``codegen/emit_top.py``, ``_emit_foreign_security_registration``), so each
8
+ entry states both the way registration computes them:
9
+
10
+ {"line": 7, "fn": "request.security",
11
+ "symbol": {"kind": "input", "title": "Other symbol", "default": "BINANCE:ETHUSDT"},
12
+ "timeframe": {"kind": "chart"},
13
+ "lookahead": false, "gaps": false, "ignore_invalid_symbol": false}
14
+
15
+ The sites are the registrations the C++ makes: ``request.security`` of
16
+ another symbol that the support checker lowered onto its feed
17
+ (``external_requests.FEED_LOWERING``), one entry per context a helper's call
18
+ paths give it (``security_contexts``), with the feed column a footprint reads
19
+ (``column``). A site whose value reaches display and alert sinks only is
20
+ lowered to ``na`` and reads nothing, and a helper nothing reaches never runs:
21
+ neither is listed. A deferred refusal -- another symbol's request whose value
22
+ can reach a trade but which registration cannot key before the first bar --
23
+ is listed with an ``unresolvable`` symbol: its first read stops the run.
24
+ Every other request reads no feed: the chart's symbol, ``request.*`` of
25
+ fundamentals (recorded series, not bars) and ``request.security_lower_tf`` of
26
+ another symbol (it stops the run where it is evaluated).
27
+
28
+ Names are expanded as registration expands them
29
+ (``_substitute_tf_input_reads``: the expression the C++ renders and the run
30
+ evaluates before the first bar), and a ``value`` is computed only over
31
+ ``ScriptIndex.registration_value``'s grammar -- literals, inputs at their
32
+ defaults, ``+ == != and or not``, ternaries and ``ticker.inherit`` /
33
+ ``standard`` -- so discovery states a value where the run computes the same
34
+ one from the same expression.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ from .ast_nodes import (
40
+ BinOp, BoolLiteral, ExprStmt, FuncCall, Identifier, MemberAccess, NaLiteral,
41
+ NumberLiteral, StringLiteral, Subscript, SwitchStmt, Ternary, UnaryOp,
42
+ )
43
+ from .errors import CompileError, Phase
44
+ from .external_requests import FEED_LOWERING, LOWERING_ANNOTATION, _nodes
45
+ from .pine_spelling import is_input_call, pine_string_literal
46
+ from .security_contexts import _REGISTRATION_INPUTS, ScriptIndex, _reached, ticker_symbol_arg
47
+
48
+ # A deferred refusal's lowering (``support_checker._lower_no_data_request``,
49
+ # ``external_requests.unpin_requests``).
50
+ _UNPINNED_LOWERING = "unpinned"
51
+ # The chart's own timeframe where a fold meets ``timeframe.period``.
52
+ _CHART = object()
53
+ _UNKNOWN = object()
54
+ # A folded value longer than any symbol or timeframe is no key.
55
+ _MAX_VALUE_CHARS = 1024
56
+ # Names a fold follows, one through another, and nodes it visits, before it
57
+ # gives up (the value is then unknown).
58
+ _MAX_FOLD_DEPTH = 256
59
+ _MAX_FOLD_STEPS = 100_000
60
+
61
+
62
+ class _Fold:
63
+ """One value's fold: each name expanded and folded once."""
64
+
65
+ def __init__(self, gen) -> None:
66
+ self.gen = gen
67
+ self.names: dict[str, object] = {}
68
+ self.steps = 0
69
+ _PRECEDENCE = {"or": 1, "and": 2, "==": 3, "!=": 3, "<": 4, "<=": 4, ">": 4, ">=": 4,
70
+ "+": 5, "-": 5, "*": 6, "/": 6, "%": 6}
71
+
72
+
73
+ def request_sites(program) -> list[FuncCall]:
74
+ """The ``request.security`` calls the support checker lowered onto
75
+ another symbol's feed or to a deferred refusal, outside the helpers
76
+ nothing reaches: taken before the passes that replace a refusal by its
77
+ ``na`` (``lower_no_data_requests``) or turn a feed back into one
78
+ (``unpin_requests``), which mark it so."""
79
+ sites = [node for node in _nodes(program)
80
+ if isinstance(node, FuncCall) and _call_name(node) == ("request", "security")
81
+ and (node.annotations or {}).get(LOWERING_ANNOTATION)
82
+ in (FEED_LOWERING, _UNPINNED_LOWERING)]
83
+ if not sites:
84
+ return sites
85
+ index = ScriptIndex(program)
86
+ reached = _reached(index)
87
+ return [site for site in sites
88
+ if (owner := index.owner.get(id(site))) not in index.funcs or owner in reached]
89
+
90
+
91
+ def discover_requests(gen, ctx, sites: list[FuncCall]) -> list[dict]:
92
+ """The requests of another symbol's data (module docstring), by line,
93
+ each distinct entry once."""
94
+ entries: list[tuple[tuple, dict]] = []
95
+ calls = {call.sec_id: call for call in ctx.security_calls}
96
+ mutable_reads = set(gen._security_tf_mutable_reads)
97
+
98
+ def add(loc, entry: dict) -> None:
99
+ entry = {"line": loc.line if loc is not None else None, "fn": "request.security",
100
+ **entry}
101
+ where = (loc.line or 0, loc.col or 0) if loc is not None else (0, 0)
102
+ entries.append(((*where, len(entries)), entry))
103
+
104
+ budget = getattr(gen, "_budget", None)
105
+ foreign = [info for info in gen._security_eval_info if info.get("foreign")]
106
+ helpers: set[str] = set()
107
+ unreached: set[str] = set()
108
+ if any(getattr(calls.get(info["sec_id"]), "containing_func", "") for info in foreign):
109
+ # A helper nothing reaches never runs: its registration is never read.
110
+ index = ScriptIndex(ctx.ast)
111
+ helpers = set(index.funcs)
112
+ unreached = helpers - _reached(index)
113
+ try:
114
+ for info in foreign:
115
+ helper = getattr(calls.get(info["sec_id"]), "containing_func", "")
116
+ if helper in helpers and helper in unreached:
117
+ continue
118
+ # Past the time budget the rest are listed as written, unexpanded:
119
+ # discovery never fails a script that transpiled.
120
+ expand = _within(budget)
121
+ entry = {
122
+ "symbol": _guarded(_symbol, gen, info["symbol_node"], expand=expand),
123
+ "timeframe": _guarded(_registered_timeframe, gen, info, expand=expand),
124
+ "lookahead": bool(info.get("lookahead_on")),
125
+ "gaps": bool(info.get("gaps_on")),
126
+ "ignore_invalid_symbol": _guarded(
127
+ _flag, gen, info.get("ignore_invalid_node"), expand=expand),
128
+ }
129
+ column = gen._security_footprint_column(info["sec_id"])
130
+ if column:
131
+ entry["column"] = column
132
+ add(getattr(calls.get(info["sec_id"]), "loc", None), entry)
133
+ for site in sites:
134
+ if (site.annotations or {}).get(LOWERING_ANNOTATION) != _UNPINNED_LOWERING:
135
+ continue
136
+ # The C++ never registered it, so nothing expands its names here:
137
+ # its arguments are listed as written.
138
+ symbol, timeframe = _request_args(site)
139
+ gaps, lookahead = _gaps_lookahead(site)
140
+ ignore = site.kwargs.get("ignore_invalid_symbol")
141
+ add(site.loc, {
142
+ "symbol": {"kind": "unresolvable", "expr": _spell(symbol)},
143
+ "timeframe": _guarded(_timeframe, gen, timeframe, expand=False),
144
+ "lookahead": _barmerge(lookahead, "lookahead_on"),
145
+ "gaps": _barmerge(gaps, "gaps_on"),
146
+ "ignore_invalid_symbol": (
147
+ False if ignore is None else
148
+ ignore.value if isinstance(ignore, BoolLiteral) else None),
149
+ })
150
+ finally:
151
+ # Expanding a name registers a reassigned global's read with the
152
+ # first-bar replay; generation is over, so leave its record as it was.
153
+ gen._security_tf_mutable_reads = mutable_reads
154
+ out: list[dict] = []
155
+ for _, entry in sorted(entries, key=lambda item: item[0]):
156
+ if entry not in out:
157
+ out.append(entry)
158
+ return out
159
+
160
+
161
+ # ---------------------------------------------------------------------------
162
+ # Symbol and timeframe
163
+ # ---------------------------------------------------------------------------
164
+
165
+ def _within(budget) -> bool:
166
+ """Whether the transpile's time budget still holds."""
167
+ if budget is None:
168
+ return True
169
+ try:
170
+ budget.check(phase=Phase.CODEGEN)
171
+ except CompileError:
172
+ return False
173
+ return True
174
+
175
+
176
+ def _guarded(classify, gen, node, **options):
177
+ """``classify(gen, node)``; a shape it does not know is listed as
178
+ computed, with no value: discovery never fails a script that
179
+ transpiled."""
180
+ try:
181
+ return classify(gen, node, **options)
182
+ except Exception: # noqa: BLE001
183
+ if isinstance(node, dict):
184
+ node = node.get("tf_node")
185
+ return None if classify is _flag else {"kind": "computed", "expr": _spell(node)}
186
+
187
+
188
+ def _symbol(gen, node, expand: bool = True) -> dict:
189
+ """``literal`` / ``input`` / ``computed`` as registration computes the
190
+ symbol string the run keys its feed on."""
191
+ resolved = _passthrough(_expand(gen, node) if expand else node)
192
+ if isinstance(resolved, StringLiteral):
193
+ return {"kind": "literal", "value": resolved.value}
194
+ if _is_registered_input(resolved):
195
+ return _input(gen, resolved)
196
+ if not expand:
197
+ return {"kind": "computed", "expr": _spell(node)}
198
+ return _computed(gen, node, resolved, timeframe=False)
199
+
200
+
201
+ def _registered_timeframe(gen, info: dict, expand: bool = True) -> dict:
202
+ """The timeframe a feed site registers with (``_resolve_security_tf``'s
203
+ ``tf`` / ``tf_expr``, ``configure_security_evaluators``). ``input_tf_``
204
+ is the chart's: a run reading another symbol's feed takes the chart's
205
+ bars unaggregated (the engine refuses it otherwise)."""
206
+ tf, tf_expr = info.get("tf"), info.get("tf_expr")
207
+ if tf:
208
+ return {"kind": "literal", "value": canonical_timeframe(tf)}
209
+ if tf == "" or tf_expr in ("input_tf_", "script_tf_"):
210
+ return {"kind": "chart"}
211
+ return _timeframe(gen, info.get("tf_node"), expand=expand)
212
+
213
+
214
+ def _timeframe(gen, node, expand: bool = True) -> dict:
215
+ """A timeframe registration expands (``expand``), or as written."""
216
+ if node is None:
217
+ return {"kind": "chart"}
218
+ resolved = _expand(gen, node) if expand else node
219
+ if isinstance(resolved, StringLiteral):
220
+ if resolved.value == "":
221
+ return {"kind": "chart"}
222
+ return {"kind": "literal", "value": canonical_timeframe(resolved.value)}
223
+ if _is_timeframe_period(resolved):
224
+ return {"kind": "chart"}
225
+ if _is_registered_input(resolved, timeframe=True):
226
+ return _input(gen, resolved)
227
+ if not expand:
228
+ return {"kind": "computed", "expr": _spell(node)}
229
+ return _computed(gen, node, resolved, timeframe=True)
230
+
231
+
232
+ def canonical_timeframe(tf: str) -> str:
233
+ """The engine's spelling of a feed's timeframe
234
+ (``canonical_symbol_timeframe``): Pine's bare ``D`` / ``W`` / ``M`` /
235
+ ``S`` folded to ``1D`` / ``1W`` / ``1M`` / ``1S``, any other text kept
236
+ as it is (whole minutes ``"240"``, ``<n>D|W|M|S``; a feed matches no
237
+ other spelling)."""
238
+ return "1" + tf if tf in ("D", "W", "M", "S") else tf
239
+
240
+
241
+ def _input(gen, call: FuncCall) -> dict:
242
+ default = _input_default(gen, call)
243
+ return {"kind": "input", "title": gen._get_input_title(call),
244
+ "default": None if default is _UNKNOWN else default}
245
+
246
+
247
+ def _computed(gen, node, resolved, *, timeframe: bool) -> dict:
248
+ out = {"kind": "computed", "expr": _spell(node)}
249
+ value = _fold(_Fold(gen), resolved)
250
+ if value is _CHART and timeframe:
251
+ out["value"] = ""
252
+ elif isinstance(value, str):
253
+ out["value"] = canonical_timeframe(value) if timeframe and value else value
254
+ titles: list[str] = []
255
+ for call in _input_calls(gen, resolved):
256
+ title = gen._get_input_title(call)
257
+ if title not in titles:
258
+ titles.append(title)
259
+ if titles:
260
+ out["inputs"] = titles
261
+ return out
262
+
263
+
264
+ def _flag(gen, node, expand: bool = True) -> bool | None:
265
+ """``ignore_invalid_symbol`` as registration reads it: false when left
266
+ out, else its value at the inputs' defaults (None when unknown)."""
267
+ if node is None:
268
+ return False
269
+ if not expand:
270
+ return node.value if isinstance(node, BoolLiteral) else None
271
+ value = _fold(_Fold(gen), _expand(gen, node))
272
+ return value if isinstance(value, bool) else None
273
+
274
+
275
+ def _expand(gen, node):
276
+ """``node`` with its names expanded as registration expands them."""
277
+ return gen._substitute_tf_input_reads(node, set())
278
+
279
+
280
+ def _passthrough(node):
281
+ """``ticker.inherit`` / ``ticker.standard`` render their symbol argument
282
+ unchanged (``visit_call``)."""
283
+ while (isinstance(node, FuncCall) and _call_name(node) in (
284
+ ("ticker", "inherit"), ("ticker", "standard"))):
285
+ symbol = ticker_symbol_arg(node)
286
+ if symbol is None:
287
+ break
288
+ node = symbol
289
+ return node
290
+
291
+
292
+ def _is_registered_input(node, timeframe: bool = False) -> bool:
293
+ """An input whose value registration reads: ``input.symbol`` /
294
+ ``string`` (``timeframe`` for a timeframe), or ``input()`` of a string."""
295
+ if not is_input_call(node):
296
+ return False
297
+ ns, name = _call_name(node)
298
+ if ns is None:
299
+ default = node.args[0] if node.args else node.kwargs.get("defval")
300
+ return isinstance(default, StringLiteral)
301
+ return name in (_REGISTRATION_INPUTS - {"bool"}) or (timeframe and name == "timeframe")
302
+
303
+
304
+ def _input_default(gen, call: FuncCall):
305
+ default = gen._get_input_default(call)
306
+ if isinstance(default, (StringLiteral, BoolLiteral, NumberLiteral)):
307
+ return default.value
308
+ return _UNKNOWN
309
+
310
+
311
+ def _input_calls(gen, node) -> list[FuncCall]:
312
+ """The input calls ``node`` reads, through the names registration
313
+ expands (a ``switch``'s too), in source order."""
314
+ found: dict[int, FuncCall] = {}
315
+ seen: set[str] = set()
316
+ pending = [node]
317
+ while pending:
318
+ for item in _nodes(pending.pop()):
319
+ if is_input_call(item):
320
+ found.setdefault(id(item), item)
321
+ elif isinstance(item, Identifier) and item.name not in seen:
322
+ seen.add(item.name)
323
+ expanded = _expand(gen, item)
324
+ if expanded is not item:
325
+ pending.append(expanded)
326
+ return sorted(found.values(), key=lambda call: (call.loc.line, call.loc.col)
327
+ if call.loc is not None else (0, 0))
328
+
329
+
330
+ def _fold(state: _Fold, node, depth: int = 0):
331
+ """The value at the inputs' defaults of an expression in
332
+ ``ScriptIndex.registration_value``'s grammar (and, for a timeframe, a
333
+ ``switch`` of single-expression arms, as registration renders it), else
334
+ ``_UNKNOWN``. The chart's own symbol strings are the chart's (unknown
335
+ here); ``timeframe.period`` is ``_CHART``."""
336
+ state.steps += 1
337
+ if depth > _MAX_FOLD_DEPTH or state.steps > _MAX_FOLD_STEPS:
338
+ return _UNKNOWN
339
+ depth += 1
340
+ if isinstance(node, (StringLiteral, BoolLiteral)):
341
+ return node.value
342
+ if isinstance(node, NumberLiteral):
343
+ return node.value
344
+ if _is_timeframe_period(node):
345
+ return _CHART
346
+ if isinstance(node, Identifier):
347
+ # A name registration expands (a switch's subject: the expansion
348
+ # leaves a switch as written); one it keeps has no value here.
349
+ if node.name not in state.names:
350
+ state.names[node.name] = _UNKNOWN # a name read inside its own value
351
+ expanded = _expand(state.gen, node)
352
+ state.names[node.name] = (_UNKNOWN if expanded is node
353
+ else _fold(state, expanded, depth))
354
+ return state.names[node.name]
355
+ if isinstance(node, SwitchStmt):
356
+ return _fold_switch(state, node, depth)
357
+ if isinstance(node, FuncCall):
358
+ if is_input_call(node):
359
+ ns, name = _call_name(node)
360
+ if ns is None or name in _REGISTRATION_INPUTS or name == "timeframe":
361
+ return _input_default(state.gen, node)
362
+ return _UNKNOWN
363
+ passed = _passthrough(node)
364
+ return _UNKNOWN if passed is node else _fold(state, passed, depth)
365
+ if isinstance(node, Ternary):
366
+ condition = _fold(state, node.condition, depth)
367
+ if not isinstance(condition, bool):
368
+ return _UNKNOWN
369
+ return _fold(state, node.true_val if condition else node.false_val, depth)
370
+ if isinstance(node, UnaryOp):
371
+ operand = _fold(state, node.operand, depth)
372
+ return (not operand) if node.op == "not" and isinstance(operand, bool) else _UNKNOWN
373
+ if isinstance(node, BinOp) and node.op in ("+", "==", "!=", "and", "or"):
374
+ left = _fold(state, node.left, depth)
375
+ if left is _UNKNOWN or left is _CHART:
376
+ return _UNKNOWN
377
+ right = _fold(state, node.right, depth)
378
+ if right is _UNKNOWN or right is _CHART:
379
+ return _UNKNOWN
380
+ if node.op == "+":
381
+ if not (isinstance(left, str) and isinstance(right, str)
382
+ and len(left) + len(right) <= _MAX_VALUE_CHARS):
383
+ return _UNKNOWN
384
+ return left + right
385
+ if node.op in ("and", "or"):
386
+ if not (isinstance(left, bool) and isinstance(right, bool)):
387
+ return _UNKNOWN
388
+ return (left and right) if node.op == "and" else (left or right)
389
+ if type(left) is not type(right):
390
+ return _UNKNOWN
391
+ return (left == right) if node.op == "==" else (left != right)
392
+ return _UNKNOWN
393
+
394
+
395
+ def _fold_switch(state: _Fold, node: SwitchStmt, depth: int):
396
+ """A timeframe ``switch`` as registration renders it
397
+ (``_security_tf_switch_runtime_expr``): the first arm whose value equals
398
+ the subject (or whose condition holds), else the default arm, else the
399
+ chart's timeframe; every arm one expression."""
400
+ subject = None
401
+ if node.expr is not None:
402
+ subject = _fold(state, node.expr, depth)
403
+ if subject is _UNKNOWN or subject is _CHART:
404
+ return _UNKNOWN
405
+
406
+ def arm(body):
407
+ if len(body) != 1 or not isinstance(body[0], ExprStmt) or isinstance(
408
+ body[0].expr, NaLiteral):
409
+ return _UNKNOWN
410
+ return _fold(state, body[0].expr, depth)
411
+
412
+ default = node.default_body or None
413
+ for condition, body in node.cases:
414
+ if condition is None:
415
+ default = default or body
416
+ continue
417
+ value = _fold(state, condition, depth)
418
+ if value is _UNKNOWN or value is _CHART:
419
+ return _UNKNOWN
420
+ if (value is True if node.expr is None
421
+ else type(value) is type(subject) and value == subject):
422
+ return arm(body)
423
+ return arm(default) if default else _CHART
424
+
425
+
426
+ # ---------------------------------------------------------------------------
427
+ # Call shapes and spelling
428
+ # ---------------------------------------------------------------------------
429
+
430
+ def _call_name(node: FuncCall) -> tuple[str | None, str | None]:
431
+ callee = node.callee
432
+ if isinstance(callee, Identifier):
433
+ return None, callee.name
434
+ if isinstance(callee, MemberAccess) and isinstance(callee.object, Identifier):
435
+ return callee.object.name, callee.member
436
+ return None, None
437
+
438
+
439
+ def _is_timeframe_period(node) -> bool:
440
+ """``timeframe.period`` or ``timeframe.main_period``: the chart's."""
441
+ return (isinstance(node, MemberAccess) and node.member in ("period", "main_period")
442
+ and isinstance(node.object, Identifier) and node.object.name == "timeframe")
443
+
444
+
445
+ def _request_args(node: FuncCall):
446
+ symbol = node.args[0] if node.args else node.kwargs.get("symbol")
447
+ timeframe = node.args[1] if len(node.args) > 1 else node.kwargs.get("timeframe")
448
+ return symbol, timeframe
449
+
450
+
451
+ def _gaps_lookahead(node: FuncCall):
452
+ gaps = node.kwargs.get("gaps")
453
+ if gaps is None and len(node.args) > 3:
454
+ gaps = node.args[3]
455
+ lookahead = node.kwargs.get("lookahead")
456
+ if lookahead is None and len(node.args) > 4:
457
+ lookahead = node.args[4]
458
+ return gaps, lookahead
459
+
460
+
461
+ def _barmerge(node, on: str) -> bool | None:
462
+ """A ``barmerge.*`` constant's flag; left out is off; None when computed."""
463
+ if node is None:
464
+ return False
465
+ if (isinstance(node, MemberAccess) and isinstance(node.object, Identifier)
466
+ and node.object.name == "barmerge"):
467
+ return node.member == on
468
+ return None
469
+
470
+
471
+ def _spell(node, parent: int = 0, right: bool = False) -> str:
472
+ """``node`` spelled as Pine source (display only)."""
473
+ if node is None:
474
+ return ""
475
+ if isinstance(node, StringLiteral):
476
+ return pine_string_literal(node.value)
477
+ if isinstance(node, BoolLiteral):
478
+ return "true" if node.value else "false"
479
+ if isinstance(node, NumberLiteral):
480
+ return str(node.value)
481
+ if isinstance(node, NaLiteral):
482
+ return "na"
483
+ if isinstance(node, Identifier):
484
+ return node.name
485
+ if isinstance(node, MemberAccess):
486
+ return f"{_spell(node.object, 9)}.{node.member}"
487
+ if isinstance(node, FuncCall):
488
+ args = [_spell(a) for a in node.args]
489
+ args += [f"{key} = {_spell(value)}" for key, value in node.kwargs.items()]
490
+ return f"{_spell(node.callee, 9)}({', '.join(args)})"
491
+ if isinstance(node, Subscript):
492
+ return f"{_spell(node.object, 9)}[{_spell(node.index)}]"
493
+ if isinstance(node, UnaryOp):
494
+ text = f"not {_spell(node.operand, 7)}" if node.op == "not" else (
495
+ f"{node.op}{_spell(node.operand, 7)}")
496
+ return f"({text})" if parent > 7 else text
497
+ if isinstance(node, BinOp):
498
+ level = _PRECEDENCE.get(node.op, 5)
499
+ text = (f"{_spell(node.left, level)} {node.op} "
500
+ f"{_spell(node.right, level, right=True)}")
501
+ return f"({text})" if level < parent or (right and level == parent) else text
502
+ if isinstance(node, Ternary):
503
+ text = (f"{_spell(node.condition, 1)} ? {_spell(node.true_val)} : "
504
+ f"{_spell(node.false_val)}")
505
+ return f"({text})" if parent else text
506
+ return "..."
Binary file
package/release.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
- "codegen": "1.0.1",
2
+ "codegen": "1.2.0",
3
3
  "pyodide": "314.0.0",
4
4
  "python": "3.14.0",
5
5
  "emscripten": "emscripten_5_0_3",
6
- "sha256": "2ad9a8595850ce2c485dcddb049fd738097d9149d2136ce83a951a506541a113"
6
+ "sha256": "7c9654f79d188babd5034d3ebedd2cdd60a8ec2866b70d8ad863a9f06341420e"
7
7
  }
package/tables.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "CODEGEN_VERSION": "1.0.1",
2
+ "CODEGEN_VERSION": "1.2.0",
3
3
  "TA_CLASS_MAP_KEYS": [
4
4
  "sma",
5
5
  "ema",
@@ -32,6 +32,10 @@ def _diagnostic_entries(diagnostics) -> list:
32
32
  "col": loc.col if loc else 1,
33
33
  "message": message,
34
34
  "severity": getattr(d.level, "value", "error"),
35
+ # The stable code and named arguments of the catalog templates the
36
+ # message (and the hint after " — ") render from.
37
+ "code": d.code,
38
+ "args": d.args,
35
39
  }
36
40
  end_col = getattr(loc, "end_col", None) if loc else None
37
41
  if end_col is not None:
@@ -49,7 +53,8 @@ def transpile_json(source: str) -> str:
49
53
  # A script that transpiled carries its warnings in the same entry format.
50
54
  return json.dumps({"ok": True, "cpp": full["cpp"], "inputs": full["inputs"],
51
55
  "strategyParams": full["strategyParams"],
52
- "diagnostics": _diagnostic_entries(full["diagnostics"])})
56
+ "diagnostics": _diagnostic_entries(full["diagnostics"]),
57
+ "requests": full["requests"]})
53
58
  `;
54
59
 
55
60
  const post = (m) => self.postMessage(m);
Binary file