@pineforge/codegen-pyodide 0.10.3 → 1.0.0-rc.1

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.
Files changed (51) hide show
  1. package/README.md +16 -16
  2. package/glue.py +24 -16
  3. package/package.json +1 -1
  4. package/pineforge_codegen/__init__.py +125 -34
  5. package/pineforge_codegen/analyzer/__init__.py +2 -0
  6. package/pineforge_codegen/analyzer/base.py +767 -77
  7. package/pineforge_codegen/analyzer/call_handlers.py +268 -42
  8. package/pineforge_codegen/analyzer/contracts.py +37 -0
  9. package/pineforge_codegen/analyzer/diagnostics.py +30 -4
  10. package/pineforge_codegen/analyzer/tables.py +49 -8
  11. package/pineforge_codegen/analyzer/types.py +33 -1
  12. package/pineforge_codegen/ast_nodes.py +32 -1
  13. package/pineforge_codegen/block_locals.py +185 -0
  14. package/pineforge_codegen/builtin_keywords.py +42 -0
  15. package/pineforge_codegen/codegen/base.py +907 -159
  16. package/pineforge_codegen/codegen/constant_fold.py +131 -0
  17. package/pineforge_codegen/codegen/drawing.py +221 -79
  18. package/pineforge_codegen/codegen/emit_top.py +961 -213
  19. package/pineforge_codegen/codegen/helpers.py +435 -14
  20. package/pineforge_codegen/codegen/host_members.py +162 -0
  21. package/pineforge_codegen/codegen/input.py +252 -85
  22. package/pineforge_codegen/codegen/security.py +4372 -377
  23. package/pineforge_codegen/codegen/session_market.py +71 -0
  24. package/pineforge_codegen/codegen/ta.py +1198 -101
  25. package/pineforge_codegen/codegen/tables.py +205 -71
  26. package/pineforge_codegen/codegen/tv_number_format.py +270 -0
  27. package/pineforge_codegen/codegen/types.py +2055 -62
  28. package/pineforge_codegen/codegen/visit_call.py +929 -131
  29. package/pineforge_codegen/codegen/visit_expr.py +765 -63
  30. package/pineforge_codegen/codegen/visit_stmt.py +610 -52
  31. package/pineforge_codegen/external_requests.py +877 -0
  32. package/pineforge_codegen/lexer.py +104 -22
  33. package/pineforge_codegen/library_inline.py +1304 -0
  34. package/pineforge_codegen/library_modules.py +126 -0
  35. package/pineforge_codegen/library_v5.py +683 -0
  36. package/pineforge_codegen/limits.py +138 -0
  37. package/pineforge_codegen/method_binding.py +33 -0
  38. package/pineforge_codegen/parser.py +384 -68
  39. package/pineforge_codegen/pine_libraries.py +266 -0
  40. package/pineforge_codegen/pine_spelling.py +216 -0
  41. package/pineforge_codegen/pragmas.py +64 -10
  42. package/pineforge_codegen/security_contexts.py +1585 -0
  43. package/pineforge_codegen/session_reads.py +84 -0
  44. package/pineforge_codegen/signatures.py +48 -23
  45. package/pineforge_codegen/support_checker.py +1106 -85
  46. package/pineforge_codegen/symbols.py +4 -2
  47. package/pineforge_codegen-1.0.0-rc.1.tar.gz +0 -0
  48. package/release.json +2 -2
  49. package/tables.json +23 -21
  50. package/transpile.worker.mjs +24 -16
  51. package/pineforge_codegen-0.10.3.tar.gz +0 -0
@@ -0,0 +1,138 @@
1
+ """Portable limits for untrusted Pine source.
2
+
3
+ These checks run in both CPython and Pyodide. A limit exists only to turn a
4
+ crash or a hang into a located ``CompileError``; where TradingView documents a
5
+ limit, ours is at least as large. The structural limits are deterministic;
6
+ the elapsed-time limit is a cooperative last resort for work that remains
7
+ expensive below them.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import fields
13
+ import sys
14
+ from time import monotonic
15
+
16
+ from .ast_nodes import ASTNode, TypeField
17
+ from .errors import CompileError, Diagnostic, Level, Phase, SourceLocation
18
+
19
+
20
+ # TradingView: "The size of the compilation request for a script cannot exceed
21
+ # 5MB" (Pine Script v6 User Manual, Limitations). A source of 5 MiB characters
22
+ # is no larger than that request under either reading of "MB". TradingView
23
+ # measures script size in compiled tokens, not source lines or statements, so
24
+ # there is no separate statement or per-statement budget: statements and
25
+ # argument lists are processed iteratively and are bounded by this one.
26
+ MAX_SOURCE_CHARS = 5 * 1024 * 1024
27
+
28
+ # Nesting of every kind: brackets, blocks, prefix operators, ``?:`` and
29
+ # ``else if`` chains, and the syntax tree that the recursive passes walk.
30
+ # TradingView documents no nesting limit. The public corpus and the gate
31
+ # fixtures nest at most 10 levels. Before this budget, Python's default
32
+ # recursion limit stopped the transpiler at about 500 levels of ``?:``,
33
+ # ``else if`` or operator chains (and 90 of brackets); Pyodide on Node's
34
+ # default stack dies with a fatal RangeError at about 2,000 levels of nested
35
+ # calls, blocks or ``else if`` branches, and on freeing a tree about 4,000
36
+ # levels deep.
37
+ MAX_NESTING_DEPTH = 512
38
+
39
+ # The parser's recursive descent spends up to 12 Python frames per nesting
40
+ # level (a nested call), the later passes a few per tree level; this is more
41
+ # than three times the most either needs.
42
+ RECURSION_HEADROOM = 40 * MAX_NESTING_DEPTH
43
+
44
+ # TradingView: "A two-minute limit is imposed on compilation time".
45
+ MAX_TRANSPILE_SECONDS = 120
46
+
47
+
48
+ def ensure_recursion_headroom() -> None:
49
+ """Raise Python's recursion limit to cover the nesting budget.
50
+
51
+ The limit is only ever raised, never lowered, so concurrent callers cannot
52
+ undercut one another. CPython 3.11+ and Pyodide keep Python-to-Python
53
+ calls off the C stack.
54
+ """
55
+ if sys.getrecursionlimit() < RECURSION_HEADROOM:
56
+ sys.setrecursionlimit(RECURSION_HEADROOM)
57
+
58
+
59
+ def limit_error(message: str, location: SourceLocation, phase: Phase) -> CompileError:
60
+ error = CompileError([Diagnostic(
61
+ level=Level.ERROR, phase=phase, location=location, message=message,
62
+ )])
63
+ # A limit is no refusal a caller may recover from by lowering the
64
+ # script another way (codegen/security.py re-raises it).
65
+ error.limit = True
66
+ return error
67
+
68
+
69
+ def check_source_size(source: str, filename: str) -> None:
70
+ """Reject before pragma extraction or lexing can scan an oversized input."""
71
+ if len(source) <= MAX_SOURCE_CHARS:
72
+ return
73
+ prefix = source[:MAX_SOURCE_CHARS]
74
+ line = prefix.count("\n") + 1
75
+ col = MAX_SOURCE_CHARS - prefix.rfind("\n")
76
+ raise limit_error(
77
+ f"Source size exceeds {MAX_SOURCE_CHARS} characters.",
78
+ SourceLocation(filename, line, col, col + 1), Phase.LEXER,
79
+ )
80
+
81
+
82
+ def syntax_children(node: ASTNode):
83
+ """Yield the AST nodes directly under ``node``, without recursion.
84
+
85
+ Lists, tuples, dicts and TypeField defaults are flattened. Annotations
86
+ are analysis metadata, not syntax children; excluding them also avoids
87
+ re-walking call_arg_order aliases and any future back edges.
88
+ """
89
+ stack = [getattr(node, item.name) for item in fields(node)
90
+ if item.name not in {"loc", "annotations"}]
91
+ while stack:
92
+ value = stack.pop()
93
+ if isinstance(value, ASTNode):
94
+ yield value
95
+ elif isinstance(value, TypeField):
96
+ stack.append(value.default)
97
+ elif isinstance(value, dict):
98
+ stack.extend(value.values())
99
+ elif isinstance(value, (list, tuple)):
100
+ stack.extend(value)
101
+
102
+
103
+ def iter_ast_nodes(root: ASTNode):
104
+ """Walk syntax children without using Python's call stack."""
105
+ stack: list[tuple[ASTNode, int]] = [(root, 1)]
106
+ while stack:
107
+ node, depth = stack.pop()
108
+ yield node, depth
109
+ stack.extend((child, depth + 1) for child in syntax_children(node))
110
+
111
+
112
+ def check_ast_depth(root: ASTNode, filename: str) -> None:
113
+ """Catch long left-associative chains before recursive analysis/codegen."""
114
+ for node, depth in iter_ast_nodes(root):
115
+ if depth > MAX_NESTING_DEPTH:
116
+ raise limit_error(
117
+ f"AST nesting depth exceeds {MAX_NESTING_DEPTH} nodes.",
118
+ node.loc or SourceLocation(filename, 1, 1, 2), Phase.PARSER,
119
+ )
120
+
121
+
122
+ class TimeBudget:
123
+ """A cooperative wall-clock guard shared by the transpiler passes."""
124
+
125
+ def __init__(self, filename: str) -> None:
126
+ self.filename = filename
127
+ self.deadline = monotonic() + MAX_TRANSPILE_SECONDS
128
+ self.last_location = SourceLocation(filename, 1, 1, 2)
129
+
130
+ def check(self, location: SourceLocation | None = None,
131
+ phase: Phase = Phase.PARSER) -> None:
132
+ if location is not None:
133
+ self.last_location = location
134
+ if monotonic() >= self.deadline:
135
+ raise limit_error(
136
+ f"Transpilation time exceeds {MAX_TRANSPILE_SECONDS} seconds.",
137
+ self.last_location, phase,
138
+ )
@@ -110,6 +110,39 @@ def _written_actuals(call: FuncCall) -> list[ASTNode]:
110
110
  return [node for _index, node in indexed]
111
111
 
112
112
 
113
+ def bind_function_defaults(
114
+ param_names: list[str] | tuple[str, ...],
115
+ param_defaults: list[ASTNode | None] | tuple[ASTNode | None, ...],
116
+ call: FuncCall,
117
+ ) -> list[ASTNode] | None:
118
+ """Declaration-order arguments of a plain user-function call that omits
119
+ parameters with declared defaults: ``f(x, y = 2, z = 3)`` called as
120
+ ``f(1)`` or ``f(1, z = 5)`` binds ``[1, 2, 3]`` / ``[1, 2, 5]``.
121
+
122
+ ``None`` when the call writes every parameter (callers keep their
123
+ established binding) or omits one that has no default (TradingView
124
+ rejects that call).
125
+ """
126
+ names = list(param_names)
127
+ bound: list[ASTNode | None] = [None] * len(names)
128
+ for index, value in enumerate(call.args):
129
+ if index < len(bound):
130
+ bound[index] = value
131
+ for name, value in call.kwargs.items():
132
+ if name in names:
133
+ bound[names.index(name)] = value
134
+ if all(value is not None for value in bound):
135
+ return None
136
+ defaults = list(param_defaults)
137
+ for index, value in enumerate(bound):
138
+ if value is None:
139
+ default = defaults[index] if index < len(defaults) else None
140
+ if default is None:
141
+ return None
142
+ bound[index] = default
143
+ return bound
144
+
145
+
113
146
  def bind_method_call(
114
147
  signature: MethodSignature,
115
148
  call: FuncCall,