@pineforge/codegen-pyodide 0.7.3 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/glue.py CHANGED
@@ -9,13 +9,13 @@ import sys
9
9
  if "/codegen" not in sys.path:
10
10
  sys.path.insert(0, "/codegen")
11
11
 
12
- from pineforge_codegen import transpile
12
+ from pineforge_codegen import transpile_full
13
13
  from pineforge_codegen.errors import CompileError
14
14
 
15
15
 
16
16
  def transpile_json(source: str) -> str:
17
17
  try:
18
- cpp = transpile(source)
18
+ full = transpile_full(source)
19
19
  except CompileError as e:
20
20
  diags = []
21
21
  for d in e.diagnostics:
@@ -32,4 +32,4 @@ def transpile_json(source: str) -> str:
32
32
  entry["endCol"] = end_col
33
33
  diags.append(entry)
34
34
  return json.dumps({"ok": False, "error": str(e), "diagnostics": diags})
35
- return json.dumps({"ok": True, "cpp": cpp})
35
+ return json.dumps({"ok": True, "cpp": full["cpp"], "inputs": full["inputs"], "strategyParams": full["strategyParams"]})
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pineforge/codegen-pyodide",
3
- "version": "0.7.3",
3
+ "version": "0.7.4",
4
4
  "description": "Gate-validated Pyodide payload for the PineScript v6 -> C++ transpiler: archive (run in Pyodide), unpacked source, introspected tables, and release metadata.",
5
5
  "type": "module",
6
6
  "main": "index.mjs",
@@ -51,3 +51,42 @@ def transpile(pine_source: str, *, check_support: bool = True, filename: str = "
51
51
  # ``if (trace_enabled_) { trace(...); ... }`` block.
52
52
  ctx.pf_trace_pragmas = pragmas
53
53
  return CodeGen(ctx).generate()
54
+
55
+
56
+ def transpile_full(pine_source: str, *, check_support: bool = True,
57
+ filename: str = "<input>") -> dict:
58
+ """Transpile like :func:`transpile`, plus the host-UI input manifest.
59
+
60
+ Runs ONE pipeline pass (Lexer -> Parser -> support check -> Analyzer ->
61
+ CodeGen.generate) and returns the generated C++ alongside the data the
62
+ cloud Studio needs to auto-build a backtest "override params" form:
63
+
64
+ - ``cpp``: the generated C++ source (identical to :func:`transpile`).
65
+ - ``inputs``: a list of ``InputDef`` dicts (one per top-level
66
+ ``var = input.*(...)`` declaration). Each has ``title`` / ``type`` /
67
+ ``default`` and optionally ``min`` / ``max`` / ``step`` / ``options``
68
+ (omitted when the corresponding signature argument is absent or
69
+ references a non-const value). See
70
+ :meth:`CodeGen.extract_input_manifest`.
71
+ - ``strategyParams``: the literal ``strategy(...)`` kwargs the analyzer
72
+ surfaced (e.g. ``initial_capital``, ``pyramiding``).
73
+
74
+ Args mirror :func:`transpile`.
75
+
76
+ Returns:
77
+ ``{"cpp": str, "inputs": list[dict], "strategyParams": dict}``.
78
+ """
79
+ pragmas = extract_pf_trace_pragmas(pine_source)
80
+ tokens = Lexer(pine_source, filename=filename).tokenize()
81
+ ast = Parser(tokens, source=pine_source, filename=filename).parse()
82
+ if check_support:
83
+ check_support_or_raise(ast, filename=filename)
84
+ ctx = Analyzer(ast, filename=filename).analyze()
85
+ ctx.pf_trace_pragmas = pragmas
86
+ gen = CodeGen(ctx)
87
+ cpp = gen.generate()
88
+ return {
89
+ "cpp": cpp,
90
+ "inputs": gen.extract_input_manifest(),
91
+ "strategyParams": dict(ctx.strategy_params),
92
+ }
@@ -15,13 +15,32 @@ Mixin contract — host class must provide:
15
15
 
16
16
  from __future__ import annotations
17
17
 
18
- from ..ast_nodes import FuncCall, Identifier, MemberAccess, StringLiteral
18
+ from ..ast_nodes import (
19
+ BoolLiteral,
20
+ FuncCall,
21
+ Identifier,
22
+ MemberAccess,
23
+ NumberLiteral,
24
+ StringLiteral,
25
+ VarDecl,
26
+ )
19
27
  from .. import signatures as sigs
20
28
 
21
29
 
22
30
  class InputHelper:
23
31
  """``input.*`` call analysis helpers — defaults, titles, getter dispatch, enum guard."""
24
32
 
33
+ # Maps the input function short-name -> the form-facing type tag emitted in
34
+ # the input manifest (consumed by the host UI's override form). ``price``
35
+ # is a float slider, ``time`` an int timestamp; everything string-like
36
+ # collapses to "string".
37
+ _FORM_TYPE = {
38
+ "int": "int", "float": "float", "bool": "bool", "string": "string",
39
+ "source": "source", "enum": "enum", "price": "float", "time": "int",
40
+ "color": "string", "timeframe": "string", "session": "string",
41
+ "symbol": "string", "text_area": "string",
42
+ }
43
+
25
44
  def _is_input_call(self, node: FuncCall) -> bool:
26
45
  """True if ``node`` is an ``input(...)`` or ``input.<type>(...)`` call."""
27
46
  func_name, namespace = self._resolve_callee(node.callee)
@@ -187,3 +206,125 @@ class InputHelper:
187
206
  f"{ename}.{dv.member} is not a member of enum {ename} "
188
207
  "(internal: Analyzer should reject this first)"
189
208
  )
209
+
210
+ # ------------------------------------------------------------------
211
+ # Input manifest extraction (host UI override-form source of truth)
212
+ # ------------------------------------------------------------------
213
+
214
+ def _literal_or_none(self, node):
215
+ """Return a JSON scalar for a *const* literal AST node, else None.
216
+
217
+ ``None`` signals non-const (an identifier, computed expression, …) so
218
+ callers can omit a bound/option that references a runtime value.
219
+ Enum member refs (``Dir.Up``) collapse to the ``"Dir.Up"`` string tag.
220
+ """
221
+ if isinstance(node, StringLiteral):
222
+ return node.value
223
+ # BoolLiteral must be checked before NumberLiteral: a Pine ``true`` is a
224
+ # BoolLiteral (not a NumberLiteral), but guarding the order keeps intent
225
+ # explicit and future-proof against bool/int node overlap.
226
+ if isinstance(node, BoolLiteral):
227
+ return node.value
228
+ if isinstance(node, NumberLiteral):
229
+ return node.value
230
+ # enum member ref like ``Dir.Up`` -> "Dir.Up" (string tag)
231
+ if isinstance(node, MemberAccess) and isinstance(node.object, Identifier):
232
+ return f"{node.object.name}.{node.member}"
233
+ return None
234
+
235
+ def _merged_args(self, node: FuncCall, func_name, namespace):
236
+ """Merge positional args + kwargs into signature-positional order.
237
+
238
+ Returns ``(param_names | None, merged_list)``. Mirrors the merge logic
239
+ in :meth:`_get_input_title` / :meth:`_get_input_default` so manifest
240
+ extraction reads bounds/options off the same positions codegen does.
241
+ """
242
+ if namespace == "input" and func_name in sigs.INPUT_FUNCTIONS:
243
+ names = sigs.get_param_names("input", func_name)
244
+ elif func_name == "input" and namespace is None:
245
+ names = sigs.get_param_names(None, "input")
246
+ else:
247
+ names = None
248
+ merged = list(node.args)
249
+ if names:
250
+ for i, pname in enumerate(names):
251
+ if pname in node.kwargs:
252
+ while len(merged) <= i:
253
+ merged.append(None)
254
+ if merged[i] is None:
255
+ merged[i] = node.kwargs[pname]
256
+ return names, merged
257
+
258
+ def extract_input_manifest(self) -> list[dict]:
259
+ """Walk top-level ``var = input.*(...)`` decls into an InputDef list.
260
+
261
+ Each entry: ``{title, type, default[, min, max, step, options]}``. The
262
+ optional keys are emitted only when the corresponding signature
263
+ argument is a const literal; a bound/option referencing a non-literal
264
+ is omitted (never crashes). One pass over ``self.ctx.ast.body``.
265
+ """
266
+ out: list[dict] = []
267
+ for stmt in self.ctx.ast.body:
268
+ if not (
269
+ isinstance(stmt, VarDecl)
270
+ and isinstance(stmt.value, FuncCall)
271
+ and self._is_input_call(stmt.value)
272
+ ):
273
+ continue
274
+ node = stmt.value
275
+ func_name, namespace = self._resolve_callee(node.callee)
276
+ names, merged = self._merged_args(node, func_name, namespace)
277
+ title = self._get_input_title(node, var_name=stmt.name)
278
+ default_node = self._get_input_default(node)
279
+ default_val = (
280
+ self._literal_or_none(default_node)
281
+ if default_node is not None
282
+ else None
283
+ )
284
+ if namespace == "input":
285
+ form_type = self._FORM_TYPE.get(func_name, "string")
286
+ else:
287
+ # Plain ``input(...)``: Pine types the result by its defval.
288
+ # The codegen already emits the matching scalar getter, so the
289
+ # manifest must mirror that — infer from the resolved default's
290
+ # Python type. ``bool`` MUST be tested before ``int`` because
291
+ # ``isinstance(True, int)`` is True. A None/non-literal default
292
+ # falls back to "string".
293
+ if isinstance(default_val, bool):
294
+ form_type = "bool"
295
+ elif isinstance(default_val, int):
296
+ form_type = "int"
297
+ elif isinstance(default_val, float):
298
+ form_type = "float"
299
+ elif isinstance(default_val, str):
300
+ form_type = "string"
301
+ else:
302
+ form_type = "string"
303
+ entry: dict = {
304
+ "title": title,
305
+ "type": form_type,
306
+ "default": default_val,
307
+ }
308
+ # Pull min/max/step/options by signature param name; emit only
309
+ # const literals so the override form never references a runtime
310
+ # value it can't reproduce.
311
+ if names:
312
+ idx = {n: i for i, n in enumerate(names)}
313
+ for key, pname in (("min", "minval"), ("max", "maxval"), ("step", "step")):
314
+ i = idx.get(pname)
315
+ if i is not None and i < len(merged) and merged[i] is not None:
316
+ v = self._literal_or_none(merged[i])
317
+ # bool is an int subclass — exclude it from numeric bounds
318
+ if isinstance(v, (int, float)) and not isinstance(v, bool):
319
+ entry[key] = v
320
+ oi = idx.get("options")
321
+ if oi is not None and oi < len(merged) and merged[oi] is not None:
322
+ opts_node = merged[oi]
323
+ elems = getattr(opts_node, "elements", None)
324
+ if elems is not None:
325
+ vals = [self._literal_or_none(e) for e in elems]
326
+ # any non-const element -> omit the whole options list
327
+ if vals and all(isinstance(v, str) for v in vals):
328
+ entry["options"] = vals
329
+ out.append(entry)
330
+ return out
Binary file
package/release.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
- "codegen": "0.7.3",
2
+ "codegen": "0.7.4",
3
3
  "pyodide": "314.0.0",
4
4
  "python": "3.14.0",
5
5
  "emscripten": "emscripten_5_0_3",
6
- "sha256": "e23536bd4e2bbc4f3974459dd53e0adc72f5d62c78c587ab3536da18d31d581f"
6
+ "sha256": "cd01ab4408e2d24d3b1b6627fe2c92c533a3cc3d91f0d1386953d128bdcc5995"
7
7
  }
package/tables.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "CODEGEN_VERSION": "0.7.3",
2
+ "CODEGEN_VERSION": "0.7.4",
3
3
  "TA_CLASS_MAP_KEYS": [
4
4
  "sma",
5
5
  "ema",
@@ -18,13 +18,13 @@ import sys
18
18
  if "/codegen" not in sys.path:
19
19
  sys.path.insert(0, "/codegen")
20
20
 
21
- from pineforge_codegen import transpile
21
+ from pineforge_codegen import transpile_full
22
22
  from pineforge_codegen.errors import CompileError
23
23
 
24
24
 
25
25
  def transpile_json(source: str) -> str:
26
26
  try:
27
- cpp = transpile(source)
27
+ full = transpile_full(source)
28
28
  except CompileError as e:
29
29
  diags = []
30
30
  for d in e.diagnostics:
@@ -41,7 +41,7 @@ def transpile_json(source: str) -> str:
41
41
  entry["endCol"] = end_col
42
42
  diags.append(entry)
43
43
  return json.dumps({"ok": False, "error": str(e), "diagnostics": diags})
44
- return json.dumps({"ok": True, "cpp": cpp})
44
+ return json.dumps({"ok": True, "cpp": full["cpp"], "inputs": full["inputs"], "strategyParams": full["strategyParams"]})
45
45
  `;
46
46
 
47
47
  const post = (m) => self.postMessage(m);
Binary file