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.
- pine2pyne/__init__.py +84 -0
- pine2pyne/__main__.py +12 -0
- pine2pyne/ast_nodes.py +405 -0
- pine2pyne/ast_walk.py +93 -0
- pine2pyne/block_value_lowering.py +694 -0
- pine2pyne/bool_builtins.py +42 -0
- pine2pyne/bool_value_typing.py +580 -0
- pine2pyne/cli.py +247 -0
- pine2pyne/codegen.py +886 -0
- pine2pyne/compat/__init__.py +19 -0
- pine2pyne/compat/__main__.py +6 -0
- pine2pyne/compat/cli.py +114 -0
- pine2pyne/compat/comparison.py +105 -0
- pine2pyne/compat/corpus.py +73 -0
- pine2pyne/compat/report.py +95 -0
- pine2pyne/compat/runner.py +63 -0
- pine2pyne/condition_typing.py +260 -0
- pine2pyne/conditional_history_calls.py +96 -0
- pine2pyne/core/__init__.py +26 -0
- pine2pyne/core/contracts.py +125 -0
- pine2pyne/di.py +173 -0
- pine2pyne/emit/__init__.py +5 -0
- pine2pyne/emit/legacy.py +14 -0
- pine2pyne/emit/pynecomp/__init__.py +20 -0
- pine2pyne/emit/pynecomp/banner.py +30 -0
- pine2pyne/emit/pynecomp/blank_lines.py +69 -0
- pine2pyne/emit/pynecomp/emitter.py +61 -0
- pine2pyne/emit/pynecomp/imports.py +65 -0
- pine2pyne/emit/pynecomp/passes/__init__.py +84 -0
- pine2pyne/emit/pynecomp/passes/annotations.py +422 -0
- pine2pyne/emit/pynecomp/passes/base.py +69 -0
- pine2pyne/emit/pynecomp/passes/builtins.py +169 -0
- pine2pyne/emit/pynecomp/passes/hoisting.py +205 -0
- pine2pyne/emit/pynecomp/passes/imports.py +112 -0
- pine2pyne/emit/pynecomp/passes/legacy_colors.py +101 -0
- pine2pyne/emit/pynecomp/passes/library.py +205 -0
- pine2pyne/emit/pynecomp/passes/rewrites.py +215 -0
- pine2pyne/emit/pynecomp/printer.py +288 -0
- pine2pyne/emit/pynecomp/unparser.py +186 -0
- pine2pyne/emitted_names.py +152 -0
- pine2pyne/errors.py +69 -0
- pine2pyne/for_loop_end_bound.py +172 -0
- pine2pyne/grouped_history_lowering.py +310 -0
- pine2pyne/import_resolver.py +469 -0
- pine2pyne/input_hoisting.py +195 -0
- pine2pyne/legacy_function_names.py +74 -0
- pine2pyne/legacy_sessions.py +68 -0
- pine2pyne/lexer.py +630 -0
- pine2pyne/library_collision.py +434 -0
- pine2pyne/namespace_members.py +247 -0
- pine2pyne/node_ids.py +235 -0
- pine2pyne/once_lowering.py +75 -0
- pine2pyne/overload_resolution.py +475 -0
- pine2pyne/parse_repair.py +398 -0
- pine2pyne/parser.py +1873 -0
- pine2pyne/pine_builtins.py +394 -0
- pine2pyne/pine_semantics.py +604 -0
- pine2pyne/pipeline/__init__.py +18 -0
- pine2pyne/pipeline/service.py +70 -0
- pine2pyne/pipeline/stages.py +73 -0
- pine2pyne/scope_renamer.py +965 -0
- pine2pyne/strategy_when_lowering.py +101 -0
- pine2pyne/strict_boolean.py +101 -0
- pine2pyne/symbol_table.py +166 -0
- pine2pyne/tokens.py +143 -0
- pine2pyne/transformer.py +2510 -0
- pine2pyne/type_inference.py +422 -0
- pine2pyne/udt_field_defaults.py +149 -0
- pine2pyne/value_blocks.py +357 -0
- pine2pyne-0.2.4.dist-info/METADATA +99 -0
- pine2pyne-0.2.4.dist-info/RECORD +75 -0
- pine2pyne-0.2.4.dist-info/WHEEL +5 -0
- pine2pyne-0.2.4.dist-info/entry_points.txt +3 -0
- pine2pyne-0.2.4.dist-info/licenses/LICENSE +201 -0
- 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)}
|