@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.
- package/README.md +16 -16
- package/glue.py +24 -16
- package/package.json +1 -1
- package/pineforge_codegen/__init__.py +125 -34
- package/pineforge_codegen/analyzer/__init__.py +2 -0
- package/pineforge_codegen/analyzer/base.py +767 -77
- package/pineforge_codegen/analyzer/call_handlers.py +268 -42
- package/pineforge_codegen/analyzer/contracts.py +37 -0
- package/pineforge_codegen/analyzer/diagnostics.py +30 -4
- package/pineforge_codegen/analyzer/tables.py +49 -8
- package/pineforge_codegen/analyzer/types.py +33 -1
- package/pineforge_codegen/ast_nodes.py +32 -1
- package/pineforge_codegen/block_locals.py +185 -0
- package/pineforge_codegen/builtin_keywords.py +42 -0
- package/pineforge_codegen/codegen/base.py +907 -159
- package/pineforge_codegen/codegen/constant_fold.py +131 -0
- package/pineforge_codegen/codegen/drawing.py +221 -79
- package/pineforge_codegen/codegen/emit_top.py +961 -213
- package/pineforge_codegen/codegen/helpers.py +435 -14
- package/pineforge_codegen/codegen/host_members.py +162 -0
- package/pineforge_codegen/codegen/input.py +252 -85
- package/pineforge_codegen/codegen/security.py +4372 -377
- package/pineforge_codegen/codegen/session_market.py +71 -0
- package/pineforge_codegen/codegen/ta.py +1198 -101
- package/pineforge_codegen/codegen/tables.py +205 -71
- package/pineforge_codegen/codegen/tv_number_format.py +270 -0
- package/pineforge_codegen/codegen/types.py +2055 -62
- package/pineforge_codegen/codegen/visit_call.py +929 -131
- package/pineforge_codegen/codegen/visit_expr.py +765 -63
- package/pineforge_codegen/codegen/visit_stmt.py +610 -52
- package/pineforge_codegen/external_requests.py +877 -0
- package/pineforge_codegen/lexer.py +104 -22
- package/pineforge_codegen/library_inline.py +1304 -0
- package/pineforge_codegen/library_modules.py +126 -0
- package/pineforge_codegen/library_v5.py +683 -0
- package/pineforge_codegen/limits.py +138 -0
- package/pineforge_codegen/method_binding.py +33 -0
- package/pineforge_codegen/parser.py +384 -68
- package/pineforge_codegen/pine_libraries.py +266 -0
- package/pineforge_codegen/pine_spelling.py +216 -0
- package/pineforge_codegen/pragmas.py +64 -10
- package/pineforge_codegen/security_contexts.py +1585 -0
- package/pineforge_codegen/session_reads.py +84 -0
- package/pineforge_codegen/signatures.py +48 -23
- package/pineforge_codegen/support_checker.py +1106 -85
- package/pineforge_codegen/symbols.py +4 -2
- package/pineforge_codegen-1.0.0-rc.1.tar.gz +0 -0
- package/release.json +2 -2
- package/tables.json +23 -21
- package/transpile.worker.mjs +24 -16
- 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,
|