pine2pyne 0.2.4__py3-none-any.whl

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 (75) hide show
  1. pine2pyne/__init__.py +84 -0
  2. pine2pyne/__main__.py +12 -0
  3. pine2pyne/ast_nodes.py +405 -0
  4. pine2pyne/ast_walk.py +93 -0
  5. pine2pyne/block_value_lowering.py +694 -0
  6. pine2pyne/bool_builtins.py +42 -0
  7. pine2pyne/bool_value_typing.py +580 -0
  8. pine2pyne/cli.py +247 -0
  9. pine2pyne/codegen.py +886 -0
  10. pine2pyne/compat/__init__.py +19 -0
  11. pine2pyne/compat/__main__.py +6 -0
  12. pine2pyne/compat/cli.py +114 -0
  13. pine2pyne/compat/comparison.py +105 -0
  14. pine2pyne/compat/corpus.py +73 -0
  15. pine2pyne/compat/report.py +95 -0
  16. pine2pyne/compat/runner.py +63 -0
  17. pine2pyne/condition_typing.py +260 -0
  18. pine2pyne/conditional_history_calls.py +96 -0
  19. pine2pyne/core/__init__.py +26 -0
  20. pine2pyne/core/contracts.py +125 -0
  21. pine2pyne/di.py +173 -0
  22. pine2pyne/emit/__init__.py +5 -0
  23. pine2pyne/emit/legacy.py +14 -0
  24. pine2pyne/emit/pynecomp/__init__.py +20 -0
  25. pine2pyne/emit/pynecomp/banner.py +30 -0
  26. pine2pyne/emit/pynecomp/blank_lines.py +69 -0
  27. pine2pyne/emit/pynecomp/emitter.py +61 -0
  28. pine2pyne/emit/pynecomp/imports.py +65 -0
  29. pine2pyne/emit/pynecomp/passes/__init__.py +84 -0
  30. pine2pyne/emit/pynecomp/passes/annotations.py +422 -0
  31. pine2pyne/emit/pynecomp/passes/base.py +69 -0
  32. pine2pyne/emit/pynecomp/passes/builtins.py +169 -0
  33. pine2pyne/emit/pynecomp/passes/hoisting.py +205 -0
  34. pine2pyne/emit/pynecomp/passes/imports.py +112 -0
  35. pine2pyne/emit/pynecomp/passes/legacy_colors.py +101 -0
  36. pine2pyne/emit/pynecomp/passes/library.py +205 -0
  37. pine2pyne/emit/pynecomp/passes/rewrites.py +215 -0
  38. pine2pyne/emit/pynecomp/printer.py +288 -0
  39. pine2pyne/emit/pynecomp/unparser.py +186 -0
  40. pine2pyne/emitted_names.py +152 -0
  41. pine2pyne/errors.py +69 -0
  42. pine2pyne/for_loop_end_bound.py +172 -0
  43. pine2pyne/grouped_history_lowering.py +310 -0
  44. pine2pyne/import_resolver.py +469 -0
  45. pine2pyne/input_hoisting.py +195 -0
  46. pine2pyne/legacy_function_names.py +74 -0
  47. pine2pyne/legacy_sessions.py +68 -0
  48. pine2pyne/lexer.py +630 -0
  49. pine2pyne/library_collision.py +434 -0
  50. pine2pyne/namespace_members.py +247 -0
  51. pine2pyne/node_ids.py +235 -0
  52. pine2pyne/once_lowering.py +75 -0
  53. pine2pyne/overload_resolution.py +475 -0
  54. pine2pyne/parse_repair.py +398 -0
  55. pine2pyne/parser.py +1873 -0
  56. pine2pyne/pine_builtins.py +394 -0
  57. pine2pyne/pine_semantics.py +604 -0
  58. pine2pyne/pipeline/__init__.py +18 -0
  59. pine2pyne/pipeline/service.py +70 -0
  60. pine2pyne/pipeline/stages.py +73 -0
  61. pine2pyne/scope_renamer.py +965 -0
  62. pine2pyne/strategy_when_lowering.py +101 -0
  63. pine2pyne/strict_boolean.py +101 -0
  64. pine2pyne/symbol_table.py +166 -0
  65. pine2pyne/tokens.py +143 -0
  66. pine2pyne/transformer.py +2510 -0
  67. pine2pyne/type_inference.py +422 -0
  68. pine2pyne/udt_field_defaults.py +149 -0
  69. pine2pyne/value_blocks.py +357 -0
  70. pine2pyne-0.2.4.dist-info/METADATA +99 -0
  71. pine2pyne-0.2.4.dist-info/RECORD +75 -0
  72. pine2pyne-0.2.4.dist-info/WHEEL +5 -0
  73. pine2pyne-0.2.4.dist-info/entry_points.txt +3 -0
  74. pine2pyne-0.2.4.dist-info/licenses/LICENSE +201 -0
  75. pine2pyne-0.2.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,186 @@
1
+ """Expression printing: :func:`ast.unparse`, as PyneComp uses it.
2
+
3
+ PyneComp builds a Python AST and prints every expression with the standard
4
+ library unparser: minimal parentheses (``a and (not b)`` but ``not a and b``),
5
+ ``repr`` of numbers (``3.141592653589793``), single-quoted strings. Of 141 579
6
+ one-line statements in ``assets/pyne`` all but the string literals print
7
+ exactly as :func:`ast.unparse` prints them. Two string rules differ:
8
+
9
+ * printable non-ASCII characters are written as they are (``' '`` is an
10
+ em space in the file, not an escape), only control characters are escaped;
11
+ * some string literals of a Pine v6 script keep the quotes the source wrote
12
+ them with (which ones: :func:`source_quoted_strings`).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import ast
17
+ import re
18
+ import unicodedata
19
+ from typing import Collection, Mapping, Sequence
20
+
21
+ _ESCAPE = re.compile(r'\\\\|\\x[0-9a-f]{2}|\\u[0-9a-f]{4}|\\U[0-9a-f]{8}')
22
+
23
+
24
+ def string_literal(value: str, single_quoted: bool = True, raw: bool = True) -> str:
25
+ """A single-quoted literal (``'it\\'s'``: PyneComp never switches to double
26
+ quotes) with ``repr``'s escapes. ``raw``: a string PyneComp writes as the source
27
+ has it (header, input and whole-assignment strings, :func:`source_quoted_strings`)
28
+ keeps its non-printable non-ASCII characters (``\\u200b``, no-break spaces)
29
+ unescaped; any other string keeps ``repr``'s escapes for them (probe
30
+ ``tmp/probes/esc5.pine``, ``esc6.pine``). Control characters are always escaped."""
31
+ def unescape(match: re.Match) -> str:
32
+ text = match.group(0)
33
+ if text == '\\\\':
34
+ return text
35
+ character = chr(int(text[2:], 16))
36
+ return text if unicodedata.category(character) == 'Cc' else character
37
+ text = repr(value)
38
+ if single_quoted and text.startswith('"'): # repr chose double quotes: value has a ' and no "
39
+ text = "'" + text[1:-1].replace("'", "\\'") + "'"
40
+ return _ESCAPE.sub(unescape, text) if raw else text
41
+
42
+
43
+ def header_strings(tree: ast.Module) -> set[int]:
44
+ """``id()`` of the strings a Pine v4/v5 module writes in single quotes whatever they hold
45
+ (``'it\\'s'``): the direct arguments of main()'s decorator and of its parameter defaults (an
46
+ element of a tuple argument too), and a module constant's whole value. A part of a
47
+ concatenation (``'a' + "b's"``) is spelled as ``repr`` spells it (the reference)."""
48
+ found: set[int] = set()
49
+
50
+ def is_string(node) -> bool:
51
+ return isinstance(node, ast.Constant) and isinstance(node.value, str)
52
+
53
+ def direct(call) -> None:
54
+ if not isinstance(call, ast.Call):
55
+ return
56
+ for argument in [*call.args, *(keyword.value for keyword in call.keywords)]:
57
+ if is_string(argument):
58
+ found.add(id(argument))
59
+ elif isinstance(argument, (ast.Tuple, ast.List)):
60
+ found.update(id(element) for element in argument.elts if is_string(element))
61
+
62
+ for node in tree.body:
63
+ if isinstance(node, ast.FunctionDef) and node.name == 'main':
64
+ for part in node.decorator_list + node.args.defaults + [d for d in node.args.kw_defaults if d is not None]:
65
+ direct(part)
66
+ elif isinstance(node, (ast.Assign, ast.AnnAssign)) and is_string(node.value):
67
+ found.add(id(node.value))
68
+ # and a declaration's whole string value anywhere (`ERR_TXT: str = '... \'{0}\' ...'`, PineCoders/lower_tf)
69
+ for node in ast.walk(tree):
70
+ if isinstance(node, ast.AnnAssign) and is_string(node.value):
71
+ found.add(id(node.value))
72
+ return found
73
+
74
+
75
+ def source_quoted_strings(tree: ast.Module) -> set[int]:
76
+ """``id()`` of the string literals PyneComp prints with their source quotes
77
+ in a Pine v6 script: decorator and ``main`` parameter (input) arguments, a
78
+ string that is the whole value of an assignment or an element of an
79
+ assigned tuple. A string elsewhere in the body (a call argument, a
80
+ comparison operand) is re-quoted (measured on ``assets/pyne``)."""
81
+ keep: set[int] = set()
82
+
83
+ def is_string(node: ast.AST) -> bool:
84
+ return isinstance(node, ast.Constant) and isinstance(node.value, str)
85
+
86
+ def mark_arguments(call: ast.AST) -> None:
87
+ """The direct arguments of a call, and the elements of a tuple or list passed to it."""
88
+ if not isinstance(call, ast.Call):
89
+ return
90
+ for argument in [*call.args, *(keyword.value for keyword in call.keywords)]:
91
+ if is_string(argument):
92
+ keep.add(id(argument))
93
+ elif isinstance(argument, (ast.Tuple, ast.List)):
94
+ keep.update(id(element) for element in argument.elts if is_string(element))
95
+
96
+ for node in ast.walk(tree):
97
+ if isinstance(node, ast.FunctionDef) and node.name == 'main':
98
+ for decorator in node.decorator_list:
99
+ mark_arguments(decorator)
100
+ for default in node.args.defaults + [d for d in node.args.kw_defaults if d is not None]:
101
+ mark_arguments(default)
102
+ elif isinstance(node, ast.FunctionDef):
103
+ # a user function's string default keeps its source quotes (and the protocol that shares it)
104
+ keep.update(id(default) for default in node.args.defaults if is_string(default))
105
+ elif (isinstance(node, ast.Compare) and isinstance(node.left, ast.Name)
106
+ and node.left.id.startswith('__switch')):
107
+ # a switch case keeps its source quotes: `__switch__ == "Top Right"`
108
+ keep.update(id(operand) for operand in node.comparators if is_string(operand))
109
+ elif isinstance(node, (ast.Assign, ast.AnnAssign, ast.AugAssign)) and node.value is not None:
110
+ if is_string(node.value):
111
+ keep.add(id(node.value))
112
+ elif isinstance(node.value, ast.Tuple):
113
+ keep.update(id(element) for element in node.value.elts if is_string(element))
114
+ return keep
115
+
116
+
117
+ class SourceQuotingUnparser(ast._Unparser): # the stdlib unparser, extended at one node type
118
+ """Unparses like :func:`ast.unparse`, with PyneComp's string rules."""
119
+
120
+ def __init__(self, source_lines: Sequence[bytes] = (), source_quoted: Collection[int] = frozenset(),
121
+ spellings: Mapping[int, str] | None = None, single_quoted: Collection[int] = frozenset(),
122
+ raw_strings: Collection[int] | None = None) -> None:
123
+ super().__init__()
124
+ self._source_lines = source_lines
125
+ self._source_quoted = source_quoted
126
+ self._spellings = spellings or {}
127
+ self._single_quoted = single_quoted
128
+ self._raw_strings = raw_strings
129
+
130
+ def _source_text(self, node: ast.AST) -> str | None:
131
+ lineno = getattr(node, 'lineno', None)
132
+ end_lineno = getattr(node, 'end_lineno', None)
133
+ if not lineno or not end_lineno or end_lineno > len(self._source_lines):
134
+ return None
135
+ col, end_col = node.col_offset, node.end_col_offset # UTF-8 byte offsets
136
+ if lineno == end_lineno:
137
+ raw = self._source_lines[lineno - 1][col:end_col]
138
+ else:
139
+ parts = [self._source_lines[lineno - 1][col:]]
140
+ parts.extend(self._source_lines[lineno:end_lineno - 1])
141
+ parts.append(self._source_lines[end_lineno - 1][:end_col])
142
+ raw = b'\n'.join(parts)
143
+ try:
144
+ return raw.decode('utf-8')
145
+ except UnicodeDecodeError:
146
+ return None
147
+
148
+ def visit_Constant(self, node: ast.Constant) -> None:
149
+ spelled = self._spellings.get(id(node))
150
+ if spelled is not None:
151
+ self.write(spelled)
152
+ return
153
+ if not isinstance(node.value, str) or getattr(self, '_avoid_backslashes', False):
154
+ super().visit_Constant(node)
155
+ return
156
+ if id(node) in self._source_quoted:
157
+ text = self._source_text(node)
158
+ if text and text[0] in '\'"' and '\n' not in text:
159
+ try:
160
+ same = ast.literal_eval(text) == node.value
161
+ except (ValueError, SyntaxError):
162
+ same = False
163
+ if same:
164
+ self.write(text)
165
+ return
166
+ if node.kind == 'u':
167
+ self.write('u')
168
+ raw = self._raw_strings is None or id(node) in self._raw_strings
169
+ self.write(string_literal(node.value, id(node) in self._single_quoted, raw))
170
+
171
+
172
+ class ExpressionPrinter:
173
+ """Prints one node (an expression or a simple statement) on one line."""
174
+
175
+ def __init__(self, python_source: str = '', source_quoted: Collection[int] = frozenset(),
176
+ spellings: Mapping[int, str] | None = None, single_quoted: Collection[int] = frozenset(),
177
+ raw_strings: Collection[int] | None = None) -> None:
178
+ self._source_lines = [line.encode('utf-8') for line in python_source.split('\n')]
179
+ self._source_quoted = source_quoted
180
+ self._spellings = spellings or {}
181
+ self._single_quoted = single_quoted
182
+ self._raw_strings = raw_strings
183
+
184
+ def __call__(self, node: ast.AST) -> str:
185
+ return SourceQuotingUnparser(self._source_lines, self._source_quoted, self._spellings,
186
+ self._single_quoted, self._raw_strings).visit(node)
@@ -0,0 +1,152 @@
1
+ """Emitted spellings that must not collide with the names a script already uses
2
+ (card #77, review FIX-7).
3
+
4
+ Pine names that are Python reserved words (`lambda`, `from`, `pass`) cannot be
5
+ emitted as they are. They used to be spelled by `sanitize_identifier` (`lambda_`)
6
+ independently at each place, so `lambda` and a user's `lambda_` became one name,
7
+ and a reference that the escaping missed (a call keyword, a field read) stayed
8
+ invalid Python. Here a spelling is chosen ONCE per name against the complete set
9
+ of names the script holds: the preferred spelling is taken only when it is free,
10
+ else the lowest free numeric suffix (`lambda_1`).
11
+
12
+ Two spelling domains, because Python keeps them apart:
13
+ - variables, parameters, functions, methods and type names share one scope, so
14
+ `variable()` avoids every identifier of the script;
15
+ - fields and enum members are attributes, so `attribute()` avoids only attribute
16
+ names (declared fields, enum members, and the tail of dotted names). A field
17
+ `lambda` therefore still becomes `lambda_` next to a variable `lambda_`.
18
+
19
+ Both maps are uniform: one Pine name has one spelling in a domain whatever the
20
+ receiver's type, which is what lets an untyped `p.lambda` read the right field.
21
+
22
+ What never occupies a spelling: a string literal or a script title (`"lambda_"`).
23
+ """
24
+ import dataclasses
25
+ import re
26
+ from typing import Dict, Set
27
+
28
+ from .pine_builtins import PYNECORE_LIB_MODULES, PYTHON_RESERVED_WORDS, sanitize_identifier
29
+
30
+ # Names the generated module imports or defines itself; no generated spelling may equal one
31
+ MODULE_HELPER_NAMES = frozenset({
32
+ 'Series', 'Persistent', 'udt', 'method', 'pine_range', 'pine_loop', 'method_call', 'na', 'main', 'script',
33
+ })
34
+ EMITTED_HELPER_NAMES = MODULE_HELPER_NAMES | frozenset(PYNECORE_LIB_MODULES)
35
+
36
+ # A library alias spelled like one of these is renamed (scope_renamer): `lib` is the pynecore library object pynecore's
37
+ # own transformers read in a compiled module, the rest are the helpers the module imports. A namespace or series name
38
+ # is not here: `import x as ta` is pynecore's shadowed_namespace, which a rename would drop (import_resolver refuses it)
39
+ LIBRARY_ALIAS_RENAMED_NAMES = MODULE_HELPER_NAMES | {'lib'}
40
+
41
+ # Fields that hold text, not names: a title or a literal is not an identifier
42
+ _TEXT_FIELDS = {('IndicatorDecl', 'title'), ('StrategyDecl', 'title'), ('LibraryDecl', 'title'),
43
+ ('RawCode', 'code')}
44
+ _TEXT_NODES = {'Literal', 'ImportDecl'}
45
+
46
+
47
+ def identifier_names(tree) -> Set[str]:
48
+ """Every identifier-bearing string of the tree (first and later segments of a
49
+ dotted name, targets, fields, parameters, type-hint tokens, keyword names). A
50
+ string literal, a title or an import is not one."""
51
+ names: Set[str] = set()
52
+ _identifiers_in(tree, names)
53
+ return names
54
+
55
+
56
+ def _identifiers_in(value, names: Set[str]) -> None:
57
+ if isinstance(value, str):
58
+ names.update(re.findall(r'[A-Za-z_][A-Za-z0-9_]*', value))
59
+ elif isinstance(value, (list, tuple, set, frozenset)):
60
+ for item in value:
61
+ _identifiers_in(item, names)
62
+ elif isinstance(value, dict):
63
+ for key, item in value.items():
64
+ _identifiers_in(key, names)
65
+ _identifiers_in(item, names)
66
+ elif dataclasses.is_dataclass(value) and type(value).__name__ not in _TEXT_NODES:
67
+ kind = type(value).__name__
68
+ for field in dataclasses.fields(value):
69
+ if field.name in ('line', 'column', 'literal_type') or (kind, field.name) in _TEXT_FIELDS:
70
+ continue
71
+ _identifiers_in(getattr(value, field.name), names)
72
+
73
+
74
+ def attribute_names(tree) -> Set[str]:
75
+ """Names that are attributes: the declared fields and enum members, and every
76
+ segment after the head of a dotted name."""
77
+ names: Set[str] = set()
78
+ _attributes_in(tree, names)
79
+ return names
80
+
81
+
82
+ def _attributes_in(value, names: Set[str]) -> None:
83
+ kind = type(value).__name__
84
+ if isinstance(value, str):
85
+ names.update(re.findall(r'[A-Za-z_][A-Za-z0-9_]*', value.partition('.')[2]))
86
+ elif isinstance(value, (list, tuple, set, frozenset)):
87
+ for item in value:
88
+ _attributes_in(item, names)
89
+ elif isinstance(value, dict):
90
+ for item in value.values():
91
+ _attributes_in(item, names)
92
+ elif dataclasses.is_dataclass(value) and kind not in _TEXT_NODES:
93
+ if kind == 'TypeDecl':
94
+ names.update(field[0] for field in value.fields)
95
+ elif kind == 'EnumDecl':
96
+ names.update(member[0] if isinstance(member, tuple) else member for member in value.members)
97
+ elif kind == 'MemberAccess':
98
+ names.add(value.member)
99
+ for field in dataclasses.fields(value):
100
+ if field.name in ('line', 'column', 'literal_type') or (kind, field.name) in _TEXT_FIELDS:
101
+ continue
102
+ _attributes_in(getattr(value, field.name), names)
103
+
104
+
105
+ def free_spelling(preferred: str, occupied: Set[str]) -> str:
106
+ """`preferred` when no name in `occupied` holds it, else the lowest free
107
+ numeric suffix of it; the result is recorded as occupied."""
108
+ spelling, index = preferred, 0
109
+ while spelling in occupied:
110
+ index += 1
111
+ spelling = f'{preferred}{index}'
112
+ occupied.add(spelling)
113
+ return spelling
114
+
115
+
116
+ class KeywordSpellings:
117
+ """The spelling of every Pine name that is a Python reserved word, in the two domains."""
118
+
119
+ def __init__(self, tree, occupied: Set[str]):
120
+ # shared with the renamer: identifiers of the script, helper names, names planned so far
121
+ self.occupied = occupied
122
+ self.attribute_occupied = attribute_names(tree)
123
+ self.variables: Dict[str, str] = {}
124
+ self.attributes: Dict[str, str] = {}
125
+ # a UDT field named like a pynecore name is `name__ren__` wherever it is read (PyneComp:
126
+ # `this.map__ren__`, `table__ren__`); a builtin series keeps its name (`bar.close`)
127
+ from .ast_nodes import TypeDecl
128
+ from .pine_builtins import PYNECOMP_RESERVED_NAMES, SERIES_VARIABLES
129
+ self.reserved_fields = {field[0] for node in getattr(tree, 'declarations', ()) if isinstance(node, TypeDecl)
130
+ for field in node.fields} & (PYNECOMP_RESERVED_NAMES - SERIES_VARIABLES)
131
+
132
+ def variable(self, name: str) -> str:
133
+ """The spelling of a variable, parameter, function, method or type named `name`."""
134
+ if name not in PYTHON_RESERVED_WORDS:
135
+ return name
136
+ if name not in self.variables:
137
+ self.variables[name] = free_spelling(sanitize_identifier(name), self.occupied)
138
+ return self.variables[name]
139
+
140
+ def attribute(self, name: str) -> str:
141
+ """The spelling of a field or enum member named `name`, whatever its type."""
142
+ if name in self.reserved_fields:
143
+ return f'{name}__ren__'
144
+ if name not in PYTHON_RESERVED_WORDS:
145
+ return name
146
+ if name not in self.attributes:
147
+ self.attributes[name] = free_spelling(sanitize_identifier(name), self.attribute_occupied)
148
+ return self.attributes[name]
149
+
150
+ @property
151
+ def changed(self) -> bool:
152
+ return bool(self.variables or self.attributes)
pine2pyne/errors.py ADDED
@@ -0,0 +1,69 @@
1
+ """
2
+ Custom error types for the Pine Script transpiler with source location tracking.
3
+ """
4
+
5
+
6
+ class Pine2PyneError(Exception):
7
+ """Base exception for all transpiler errors."""
8
+ def __init__(self, message: str, line: int = 0, column: int = 0, source_file: str = None):
9
+ self.message = message
10
+ self.line = line
11
+ self.column = column
12
+ self.source_file = source_file
13
+ super().__init__(self.format_message())
14
+
15
+ def format_message(self) -> str:
16
+ """Format error message with location information."""
17
+ location = f"{self.line}:{self.column}"
18
+ if self.source_file:
19
+ location = f"{self.source_file}:{location}"
20
+ return f"Error at {location}: {self.message}"
21
+
22
+
23
+ class LexerError(Pine2PyneError):
24
+ """Error during lexical analysis (tokenization)."""
25
+ pass
26
+
27
+
28
+ class ParserError(Pine2PyneError):
29
+ """Error during parsing (syntax error)."""
30
+ pass
31
+
32
+
33
+ class TransformerError(Pine2PyneError):
34
+ """Error during AST transformation."""
35
+ pass
36
+
37
+
38
+ class CodeGenError(Pine2PyneError):
39
+ """Error during code generation."""
40
+ pass
41
+
42
+
43
+ class SemanticError(Pine2PyneError):
44
+ """A program TradingView's compiler refuses, refused at its Pine line and
45
+ column (card #28). `code` names the rule, e.g. 'history-target'; `more`
46
+ counts the further refusals in the same script."""
47
+ def __init__(self, code: str, message: str, line: int = 0, column: int = 0, more: int = 0):
48
+ suffix = f' (and {more} more refusal{"s" if more != 1 else ""})' if more else ''
49
+ super().__init__(f'Pine refuses: {message} [{code}]{suffix}', line, column)
50
+ self.code = code
51
+
52
+
53
+ class SemanticWarning(UserWarning):
54
+ """A program TradingView refuses that is still transpiled, output unchanged
55
+ (operator decision 2026-09-24, card #28): a same-scope redeclaration, a read
56
+ before the declaration; and, though TradingView compiles it, a `ta.*` call
57
+ that runs on only some bars (card #96). Carries the rule's `code` and the Pine position;
58
+ under `-W error` it propagates as an exception like a refusal."""
59
+ def __init__(self, code: str, message: str, line: int = 0, column: int = 0):
60
+ super().__init__(f'{line}:{column}: {message} [{code}]')
61
+ self.code, self.text, self.line, self.column = code, message, line, column
62
+
63
+
64
+ class UnsupportedFeatureError(Pine2PyneError):
65
+ """Feature not yet supported by the transpiler."""
66
+ def __init__(self, feature: str, line: int = 0, column: int = 0):
67
+ message = f"Unsupported feature: {feature}"
68
+ super().__init__(message, line, column)
69
+ self.feature = feature
@@ -0,0 +1,172 @@
1
+ """A `for` loop whose end may change in its body reads it again before every pass.
2
+
3
+ MEASURED on TradingView (2026-10-05, `//@version=6`): `n = 10; for i = 0 to n:
4
+ count += 1; n -= 1` runs 6 times and leaves n at 4. The Pine docs (Loops) say the
5
+ direction is fixed when the loop starts and the end is checked on each pass;
6
+ `from` and `by` are evaluated once.
7
+
8
+ `for i in pine_range(from, to)` reads the end once. PyneComp (v6.0.80, probe
9
+ scripts `tmp/probes/loop5..9`) emits the runtime's `pine_loop` for a loop of any
10
+ Pine version whose end is not provably the same on every pass; its counter is
11
+ named by the loop's block depth in its function, `__loop_<depth>__`:
12
+
13
+ __loop_0__ = pine_loop(from, by) # `by` written out where pine_range writes it
14
+ while __loop_0__.step(to):
15
+ i = __loop_0__.value
16
+
17
+ An end is invariant when it is built from literals, operators, casts, names the
18
+ body does not assign (dotted names excepted: `timeframe.multiplier` is not), history
19
+ reads of those and `array.size(x)` / `matrix.rows(x)` / `matrix.columns(x)` /
20
+ `map.size(x)` of a container whose size the body does not change. Any other call
21
+ (`math.max`, `nz`, `na`, `str.length`, `ta.*`, a user function) makes it dynamic.
22
+ The body changes the size of `x` when it calls a resizing function on it (`push`,
23
+ `pop`, `insert`, `remove`, `shift`, `unshift`, `clear`, `concat`, `slice`, ...) or
24
+ passes it to a user function; `set`, `get`, `sort`, `reverse`, `fill` and `copy`
25
+ do not. Runs after the scope renamer, so a body local that shadows an outer name
26
+ has its own name and does not count as an assignment of the outer one.
27
+ """
28
+ import dataclasses
29
+ from typing import AbstractSet, Set
30
+
31
+ from .ast_nodes import (
32
+ Assignment, BinaryOp, ForInLoop, ForLoop, FuncDecl, FunctionCall, Identifier, IfStatement, IndexAccess, Literal,
33
+ MethodCall, NaLiteral, Reassignment, Script, SwitchStatement, TernaryOp, TupleDestructure, UnaryOp, VarDecl,
34
+ VaripDecl, WhileLoop,
35
+ )
36
+ from .ast_walk import walk
37
+ from .scope_renamer import collect_names, fresh_name
38
+
39
+ COUNTER_STEM = '__loop_{depth}__'
40
+ HELPER_NAME = 'pine_loop'
41
+ #: size functions an invariant end may call on a container the body does not resize
42
+ SIZE_FUNCTIONS = frozenset({'array.size', 'matrix.rows', 'matrix.columns', 'map.size'})
43
+ #: functions and methods that change the size of their first argument (or receiver)
44
+ RESIZING_FUNCTIONS = frozenset({
45
+ 'push', 'pop', 'insert', 'remove', 'shift', 'unshift', 'clear', 'concat', 'slice', 'put',
46
+ 'add_row', 'add_col', 'remove_row', 'remove_col', 'put_all',
47
+ 'row'}) # PyneComp counts `m.row(r)` among them (probes loop10, loop11)
48
+ _BLOCK_STATEMENTS = (ForLoop, ForInLoop, WhileLoop, IfStatement, SwitchStatement)
49
+
50
+
51
+ def mark_dynamic_end_bounds(script: Script) -> Script:
52
+ """A copy of `script` in which every `for` loop whose end may change between
53
+ passes carries its counter name; `script` itself when it has no such loop."""
54
+ if not any(isinstance(node, ForLoop) for node in walk(script)):
55
+ return script
56
+ used_names = collect_names(script)
57
+ function_names = {node.name for node in walk(script) if isinstance(node, FuncDecl)}
58
+ marker = _EndBoundMarker(used_names, function_names)
59
+ return dataclasses.replace(script, declarations=[marker.mark(item, 0) for item in script.declarations],
60
+ body=[marker.mark(item, 0) for item in script.body])
61
+
62
+
63
+ class _EndBoundMarker:
64
+ def __init__(self, used_names: Set[str], function_names: AbstractSet[str]):
65
+ self.function_names = function_names
66
+ # a script name `pine_loop` keeps its spelling; the import takes an alias
67
+ self.helper_name = fresh_name(HELPER_NAME, used_names) if HELPER_NAME in used_names else HELPER_NAME
68
+
69
+ def mark(self, node, depth: int):
70
+ """`node` rebuilt with every dynamic loop in it marked; `depth` is the block depth of
71
+ `node` in its function (a function's body is depth 0 again)."""
72
+ if isinstance(node, list):
73
+ return [self.mark(item, depth) for item in node]
74
+ if isinstance(node, FuncDecl):
75
+ body = self.mark(node.body, 0) if isinstance(node.body, list) else node.body
76
+ return dataclasses.replace(node, body=body)
77
+ if isinstance(node, IfStatement):
78
+ return dataclasses.replace(
79
+ node, body=self.mark(node.body, depth + 1),
80
+ elseifs=[(condition, self.mark(body, depth + 1)) for condition, body in node.elseifs],
81
+ else_body=self.mark(node.else_body, depth + 1) if node.else_body is not None else None)
82
+ if isinstance(node, SwitchStatement):
83
+ return dataclasses.replace(
84
+ node, cases=[(case, self.mark(body, depth + 1) if isinstance(body, list) else body)
85
+ for case, body in node.cases],
86
+ default=self.mark(node.default, depth + 1) if isinstance(node.default, list) else node.default)
87
+ if isinstance(node, (ForInLoop, WhileLoop)):
88
+ return dataclasses.replace(node, body=self.mark(node.body, depth + 1))
89
+ if isinstance(node, ForLoop):
90
+ marks = {}
91
+ if not self.is_invariant(node.to_val, node.body, _assigned_in(node.body) | {node.var}):
92
+ marks = {'counter_name': COUNTER_STEM.format(depth=depth), 'counter_helper': self.helper_name}
93
+ return dataclasses.replace(node, body=self.mark(node.body, depth + 1), **marks)
94
+ return node
95
+
96
+ def is_invariant(self, expression, body, assigned: AbstractSet[str]) -> bool:
97
+ def invariant(part) -> bool:
98
+ if part is None or isinstance(part, (Literal, NaLiteral)):
99
+ return True
100
+ if isinstance(part, Identifier):
101
+ return '.' not in part.name and part.name not in assigned
102
+ if isinstance(part, BinaryOp):
103
+ return invariant(part.left) and invariant(part.right)
104
+ if isinstance(part, UnaryOp):
105
+ return invariant(part.operand)
106
+ if isinstance(part, TernaryOp):
107
+ return invariant(part.condition) and invariant(part.true_expr) and invariant(part.false_expr)
108
+ if isinstance(part, IndexAccess):
109
+ return invariant(part.object) and invariant(part.index)
110
+ if isinstance(part, FunctionCall) and isinstance(part.func, str):
111
+ if part.func in CASTS:
112
+ return all(invariant(argument) for argument in part.args)
113
+ if part.func in SIZE_FUNCTIONS and len(part.args) == 1 and isinstance(part.args[0], Identifier):
114
+ return part.args[0].name not in assigned and not self.resizes(body, part.args[0].name)
115
+ receiver, _, method = part.func.rpartition('.')
116
+ if (not part.args and method in SIZE_METHODS and receiver and '.' not in receiver
117
+ and receiver not in BUILTIN_HEADS): # `x.size()`
118
+ return receiver not in assigned and not self.resizes(body, receiver)
119
+ return False
120
+ if isinstance(part, MethodCall) and isinstance(part.object, Identifier) and not part.args \
121
+ and part.method in SIZE_METHODS:
122
+ return part.object.name not in assigned and not self.resizes(body, part.object.name)
123
+ return False
124
+ return invariant(expression)
125
+
126
+ def resizes(self, body, name: str) -> bool:
127
+ """Whether `body` may change the size of the container `name`."""
128
+ def names(arguments) -> bool:
129
+ return any(isinstance(argument, Identifier) and argument.name == name for argument in arguments)
130
+ for node in walk(body):
131
+ if isinstance(node, FunctionCall) and isinstance(node.func, str):
132
+ head, _, function = node.func.rpartition('.')
133
+ if function in RESIZING_FUNCTIONS and head in ('array', 'matrix', 'map') and names(node.args[:1]):
134
+ return True
135
+ if function in RESIZING_FUNCTIONS and head == name: # `x.push(...)`
136
+ return True
137
+ if function in self.function_names and (head == name or names(node.args)):
138
+ return True
139
+ if node.func in self.function_names and names(node.args):
140
+ return True
141
+ elif isinstance(node, MethodCall):
142
+ if node.method in RESIZING_FUNCTIONS and isinstance(node.object, Identifier) \
143
+ and node.object.name == name:
144
+ return True
145
+ if node.method in self.function_names and (names(node.args) or (
146
+ isinstance(node.object, Identifier) and node.object.name == name)):
147
+ return True
148
+ return False
149
+
150
+
151
+ CASTS = frozenset({'int', 'float', 'bool', 'string', 'color'})
152
+ SIZE_METHODS = frozenset({'size', 'rows', 'columns'})
153
+ BUILTIN_HEADS = frozenset({'array', 'matrix', 'map', 'str', 'math', 'ta', 'request', 'timeframe', 'syminfo',
154
+ 'strategy', 'line', 'label', 'box', 'table', 'polyline', 'linefill', 'color', 'chart'})
155
+
156
+
157
+ def _assigned_in(body) -> Set[str]:
158
+ """The head of every name a statement in `body` assigns or declares."""
159
+ names: Set[str] = set()
160
+ for node in walk(body):
161
+ if isinstance(node, Assignment):
162
+ target = node.target
163
+ names.update(target.names if isinstance(target, TupleDestructure) else [target])
164
+ elif isinstance(node, Reassignment):
165
+ names.add(node.target)
166
+ elif isinstance(node, (VarDecl, VaripDecl)):
167
+ names.add(node.name)
168
+ elif isinstance(node, ForLoop):
169
+ names.add(node.var)
170
+ elif isinstance(node, ForInLoop):
171
+ names.update(node.vars)
172
+ return {name.partition('.')[0] for name in names if isinstance(name, str)}