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,215 @@
1
+ """The PyneComp rewrites, one class per difference.
2
+
3
+ Each docstring says what PyneComp does and how often ``assets/pyne`` shows it.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import ast
8
+ import re
9
+
10
+ from .base import PassContext, function_scopes, main_function, own_nodes, statements_in_order
11
+
12
+ _HEX_COLOR = re.compile(r'#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})')
13
+
14
+
15
+ def _double_quoted(text: str) -> str:
16
+ return '"' + text.replace('\\', '\\\\').replace('"', '\\"').replace('\n', '\\n') + '"'
17
+
18
+
19
+ class DeclarationCallShapePass:
20
+ """``@script.indicator(...)`` keeps the call shape of the Pine declaration:
21
+ the arguments the source passed positionally stay positional
22
+ (``indicator('T', 'S', overlay=true)`` -> ``('T', 'S', overlay=True)``), a
23
+ title passed by keyword stays ``title=``, and a Pine v6 title keeps its quotes."""
24
+
25
+ def apply(self, tree: ast.Module, context: PassContext) -> None:
26
+ main = main_function(tree)
27
+ declaration = getattr(context.unit.pine_script, 'script_decl', None)
28
+ if main is None or declaration is None:
29
+ return
30
+ for decorator in main.decorator_list:
31
+ if (isinstance(decorator, ast.Call) and isinstance(decorator.func, ast.Attribute)
32
+ and isinstance(decorator.func.value, ast.Name) and decorator.func.value.id == 'script'):
33
+ self._reshape(decorator, declaration, context)
34
+
35
+ @staticmethod
36
+ def _reshape(call: ast.Call, declaration, context: PassContext) -> None:
37
+ positional = getattr(declaration, 'positional_count', 0)
38
+ order = tuple(getattr(declaration, 'keyword_order', ()))
39
+ title = call.args[0] if call.args else None
40
+ if title is None: # a title that is an expression: the first keyword
41
+ if positional:
42
+ call.args = [keyword.value for keyword in call.keywords[:positional]]
43
+ call.keywords = call.keywords[positional:]
44
+ return
45
+ if positional == 0:
46
+ call.args = []
47
+ at = order.index('title') if 'title' in order else 0
48
+ call.keywords = call.keywords[:at] + [ast.keyword(arg='title', value=title)] + call.keywords[at:]
49
+ elif positional > 1:
50
+ taken = call.keywords[:positional - 1]
51
+ call.args = [title] + [keyword.value for keyword in taken]
52
+ call.keywords = call.keywords[positional - 1:]
53
+ if (title is not None and getattr(declaration, 'title_double_quoted', False)
54
+ and context.pine_version >= 6 and isinstance(title, ast.Constant)):
55
+ context.spellings[id(title)] = _double_quoted(title.value)
56
+
57
+
58
+ class StrategyDefaultsPass:
59
+ """A Pine v4/v5 ``strategy()`` is compiled with its version's defaults made
60
+ explicit, appended after the source's own arguments in this order (every
61
+ v4/v5 strategy of the reference): ``margin_long=0, margin_short=0,
62
+ initial_capital=100000`` (v4) or ``1000000`` (v5), ``default_qty_type=strategy.fixed,
63
+ default_qty_value=1, close_by_id=True``. An argument the source gives keeps its value."""
64
+
65
+ def apply(self, tree: ast.Module, context: PassContext) -> None:
66
+ if context.pine_version >= 6:
67
+ return
68
+ main = main_function(tree)
69
+ for decorator in main.decorator_list if main else ():
70
+ if (isinstance(decorator, ast.Call) and isinstance(decorator.func, ast.Attribute)
71
+ and decorator.func.attr == 'strategy'):
72
+ self._add_defaults(decorator, context.pine_version)
73
+
74
+ @staticmethod
75
+ def _add_defaults(call: ast.Call, version: int) -> None:
76
+ given = {keyword.arg for keyword in call.keywords}
77
+ defaults = [
78
+ ('margin_long', ast.Constant(0)),
79
+ ('margin_short', ast.Constant(0)),
80
+ ('initial_capital', ast.Constant(100000 if version <= 4 else 1000000)),
81
+ ('default_qty_type', ast.Attribute(value=ast.Name(id='strategy', ctx=ast.Load()), attr='fixed',
82
+ ctx=ast.Load())),
83
+ ('default_qty_value', ast.Constant(1)),
84
+ ('close_by_id', ast.Constant(True)),
85
+ ]
86
+ added = [ast.keyword(arg=name, value=value) for name, value in defaults if name not in given]
87
+ # PyneComp reconciles the imports before it adds these: `strategy.fixed` alone imports nothing
88
+ for keyword in added:
89
+ for node in ast.walk(keyword.value):
90
+ node.added_default = True
91
+ tail = [keyword for keyword in call.keywords if keyword.arg == 'na_bool']
92
+ call.keywords = [keyword for keyword in call.keywords if keyword.arg != 'na_bool'] + added + tail
93
+
94
+
95
+ class NaBoolFlagPass:
96
+ """``na_bool=True`` (Pine's three-state bool) is kept for a Pine v4 script
97
+ (all 295 of the reference have it). A Pine v5 script keeps it only when it
98
+ reads a named bool where it may be na: ``b[1]`` or ``na(b)`` (probe scripts
99
+ compiled by PyneComp v6.0.80; 82 of the 327 v5 scripts of the reference)."""
100
+
101
+ def apply(self, tree: ast.Module, context: PassContext) -> None:
102
+ if context.pine_version >= 6 and getattr(context.unit.transformed, 'reads_na_of_bool', False):
103
+ main = main_function(tree) # Pine v6: only `na()` of a bool collection marks it (probe nb6_02)
104
+ for decorator in main.decorator_list if main else ():
105
+ if isinstance(decorator, ast.Call) and not any(k.arg == 'na_bool' for k in decorator.keywords):
106
+ decorator.keywords.append(ast.keyword(arg='na_bool', value=ast.Constant(True)))
107
+ return
108
+ if context.pine_version != 5 or getattr(context.unit.transformed, 'reads_na_of_bool', False):
109
+ return
110
+ main = main_function(tree)
111
+ for decorator in main.decorator_list if main else ():
112
+ if isinstance(decorator, ast.Call):
113
+ decorator.keywords = [keyword for keyword in decorator.keywords if keyword.arg != 'na_bool']
114
+
115
+
116
+ class MathNamespacePass:
117
+ """``abs``, ``max``, ``min``, ``pow`` and ``round`` are always called through
118
+ ``math.`` (the reference never calls the Python builtins: 956 call sites)."""
119
+
120
+ NAMES = frozenset({'abs', 'max', 'min', 'pow', 'round'})
121
+
122
+ def apply(self, tree: ast.Module, context: PassContext) -> None:
123
+ bound = {node.id for node in ast.walk(tree) if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store)}
124
+ bound |= {node.name for node in ast.walk(tree) if isinstance(node, (ast.FunctionDef, ast.ClassDef))}
125
+ for node in ast.walk(tree):
126
+ if (isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
127
+ and node.func.id in self.NAMES and node.func.id not in bound):
128
+ node.func = ast.copy_location(ast.Attribute(
129
+ value=ast.Name(id='math', ctx=ast.Load()), attr=node.func.id, ctx=ast.Load()), node.func)
130
+
131
+
132
+ class FlatBoolOpPass:
133
+ """``a and (b and c)`` is one ``and`` of three operands: the reference has no
134
+ ``and`` directly inside an ``and`` (nor ``or`` in ``or``), though its sources
135
+ write them (119 ``and (... and ...)`` groups)."""
136
+
137
+ def apply(self, tree: ast.Module, context: PassContext) -> None:
138
+ for node in ast.walk(tree):
139
+ if isinstance(node, ast.BoolOp):
140
+ node.values = self._flat(node)
141
+
142
+ def _flat(self, node: ast.BoolOp) -> list:
143
+ values = []
144
+ for value in node.values:
145
+ if isinstance(value, ast.BoolOp) and type(value.op) is type(node.op):
146
+ values.extend(self._flat(value))
147
+ else:
148
+ values.append(value)
149
+ return values
150
+
151
+
152
+ class ArithmeticReassociationPass:
153
+ """Pine v4/v5: a parenthesised right operand is re-associated to the left where
154
+ the operators allow it: ``a + (b - c)`` is ``a + b - c`` and ``a * (b * c)`` is
155
+ ``a * b * c``; ``a * (b / c)`` and every right operand of ``-`` or ``/`` keep
156
+ their parentheses. Pine v6 keeps the source's grouping. Probe scripts compiled
157
+ by PyneComp v6.0.80 (``tmp/probes/par4..6``)."""
158
+
159
+ def apply(self, tree: ast.Module, context: PassContext) -> None:
160
+ if context.pine_version >= 6:
161
+ return
162
+ _Reassociation().visit(tree)
163
+
164
+
165
+ class _Reassociation(ast.NodeTransformer):
166
+ def visit_BinOp(self, node: ast.BinOp) -> ast.AST:
167
+ self.generic_visit(node)
168
+ return _reassociated(node)
169
+
170
+
171
+ def _reassociated(node: ast.BinOp) -> ast.BinOp:
172
+ right = node.right
173
+ joins = (isinstance(node.op, ast.Add) and isinstance(right, ast.BinOp) and isinstance(right.op, (ast.Add, ast.Sub))
174
+ or isinstance(node.op, ast.Mult) and isinstance(right, ast.BinOp) and isinstance(right.op, ast.Mult))
175
+ if not joins:
176
+ return node
177
+ left = _reassociated(ast.copy_location(ast.BinOp(left=node.left, op=node.op, right=right.left), node))
178
+ return _reassociated(ast.copy_location(ast.BinOp(left=left, op=right.op, right=right.right), node))
179
+
180
+
181
+ class HexColorPass:
182
+ """A Pine colour literal is ``color.new('#RRGGBB')``, upper case
183
+ (all 5410 hex colours of the reference)."""
184
+
185
+ def apply(self, tree: ast.Module, context: PassContext) -> None:
186
+ tree.body[:] = [self._visit(node) for node in tree.body]
187
+
188
+ def _visit(self, node):
189
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
190
+ return node
191
+ return _HexColorRewriter().visit(node)
192
+
193
+
194
+ class _HexColorRewriter(ast.NodeTransformer):
195
+ def visit_Constant(self, node: ast.Constant):
196
+ if isinstance(node.value, str) and _HEX_COLOR.fullmatch(node.value):
197
+ spelled = node.value.upper()
198
+ if len(spelled) == 9 and spelled.endswith('FF'):
199
+ spelled = spelled[:7] # an opaque alpha is left out: '#00FF00FF' is '#00FF00'
200
+ call = ast.Call(
201
+ func=ast.Attribute(value=ast.Name(id='color', ctx=ast.Load()), attr='new', ctx=ast.Load()),
202
+ args=[ast.Constant(value=spelled)], keywords=[])
203
+ return ast.copy_location(call, node)
204
+ return node
205
+
206
+
207
+ __all__ = [
208
+ 'DeclarationCallShapePass',
209
+ 'FlatBoolOpPass',
210
+ 'MathNamespacePass',
211
+ 'NaBoolFlagPass',
212
+ 'StrategyDefaultsPass',
213
+ 'HexColorPass',
214
+ 'statements_in_order',
215
+ ]
@@ -0,0 +1,288 @@
1
+ """Module layout of PyneComp output, printed from a Python AST.
2
+
3
+ The printer owns layout only: what a statement *is* comes from the code
4
+ generator; how it is spread over lines, indented and separated comes from
5
+ here. Expressions go through :class:`~.unparser.ExpressionPrinter`, the
6
+ import block through :class:`~.imports.ImportBlockFormatter`, blank lines in
7
+ function bodies through a :class:`~.blank_lines.BlankLinePolicy`.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import ast
12
+ import re
13
+ from dataclasses import dataclass, field
14
+ from typing import Callable, Mapping
15
+
16
+ from .banner import PyneCompBanner
17
+ from .blank_lines import AfterDefinitionPolicy, BlankLinePolicy, SourceLayoutPolicy
18
+ from .imports import ImportBlockFormatter
19
+ from .unparser import ExpressionPrinter, header_strings, source_quoted_strings
20
+
21
+ INDENT = ' '
22
+ _DEFINITIONS = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class LayoutSource:
27
+ """What the printer reads: the generated code and where it came from."""
28
+
29
+ python_source: str
30
+ pine_source: str = ''
31
+ pine_version: int | None = None
32
+ #: Python line number -> Pine line number of the statement printed there
33
+ origins: Mapping[int, int] = field(default_factory=dict)
34
+
35
+ @property
36
+ def keeps_source_layout(self) -> bool:
37
+ """Pine v6 keeps its blank lines and string quotes; earlier versions do not."""
38
+ return (self.pine_version or 6) >= 6
39
+
40
+
41
+ class _ModuleWriter:
42
+ """Prints one module; created per render, so the printer stays stateless."""
43
+
44
+ def __init__(self, expression: Callable[[ast.AST], str], blank_lines: BlankLinePolicy,
45
+ written_as_elif: Callable[[ast.If], bool] = lambda node: True,
46
+ blank_in_source: Callable[[ast.stmt], bool] = lambda node: False) -> None:
47
+ self._expression = expression
48
+ self._blank_lines = blank_lines
49
+ self._written_as_elif = written_as_elif
50
+ self._blank_in_source = blank_in_source
51
+
52
+ # -- functions ---------------------------------------------------------
53
+
54
+ def _argument(self, arg: ast.arg, default: ast.expr | None, spaced: bool = False) -> str:
55
+ """`name=default` for main()'s inputs; `name = default` for a user function or an annotation."""
56
+ text = arg.arg
57
+ if arg.annotation is not None:
58
+ text += f': {self._expression(arg.annotation)}'
59
+ if default is not None:
60
+ text += f' = {self._expression(default)}'
61
+ elif default is not None:
62
+ text += f' = {self._expression(default)}' if spaced else f'={self._expression(default)}'
63
+ return text
64
+
65
+ def _arguments(self, args: ast.arguments, spaced: bool = False) -> list[str]:
66
+ positional = list(args.posonlyargs) + list(args.args)
67
+ defaults = [None] * (len(positional) - len(args.defaults)) + list(args.defaults)
68
+ parts = [self._argument(arg, default, spaced) for arg, default in zip(positional, defaults)]
69
+ if args.posonlyargs:
70
+ parts.insert(len(args.posonlyargs), '/')
71
+ if args.vararg is not None:
72
+ parts.append('*' + self._argument(args.vararg, None))
73
+ elif args.kwonlyargs:
74
+ parts.append('*')
75
+ parts.extend(self._argument(arg, default) for arg, default in zip(args.kwonlyargs, args.kw_defaults))
76
+ if args.kwarg is not None:
77
+ parts.append('**' + self._argument(args.kwarg, None))
78
+ return parts
79
+
80
+ def _function(self, node: ast.FunctionDef, depth: int, one_parameter_per_line: bool) -> list[str]:
81
+ indent = INDENT * depth
82
+ lines = [f'{indent}@{self._expression(decorator)}' for decorator in node.decorator_list]
83
+ keyword = 'async def' if isinstance(node, ast.AsyncFunctionDef) else 'def'
84
+ returns = f' -> {self._expression(node.returns)}' if node.returns is not None else ''
85
+ parameters = self._arguments(node.args, spaced=not one_parameter_per_line)
86
+ if one_parameter_per_line and parameters:
87
+ lines.append(f'{indent}{keyword} {node.name}(')
88
+ lines.append(',\n'.join(f'{indent}{INDENT}{parameter}' for parameter in parameters))
89
+ lines.append(f'{indent}){returns}:')
90
+ elif getattr(node, 'protocol_call', False) and parameters == ['self']:
91
+ lines.append(f'{indent}{keyword} {node.name}(self, ){returns}:') # PyneComp's spelling
92
+ else:
93
+ lines.append(f'{indent}{keyword} {node.name}({", ".join(parameters)}){returns}:')
94
+ if _is_stub(node.body):
95
+ lines[-1] += ' ...'
96
+ return lines
97
+ return lines + self.block(node.body, depth + 1)
98
+
99
+ def _class(self, node: ast.ClassDef, depth: int) -> list[str]:
100
+ indent = INDENT * depth
101
+ lines = [f'{indent}@{self._expression(decorator)}' for decorator in node.decorator_list]
102
+ bases = [self._expression(base) for base in node.bases]
103
+ bases += [self._expression(keyword) for keyword in node.keywords]
104
+ head = f'{indent}class {node.name}({", ".join(bases)}):' if bases else f'{indent}class {node.name}:'
105
+ body: list[str] = []
106
+ for index, field in enumerate(node.body):
107
+ if index and self._blank_in_source(field): # a Pine v6 UDT keeps the gaps between its fields
108
+ body.append('')
109
+ body += self.statement(field, depth + 1)
110
+ return lines + [head] + body
111
+
112
+ # -- statements --------------------------------------------------------
113
+
114
+ def _if(self, node: ast.If, depth: int, keyword: str = 'if') -> list[str]:
115
+ indent = INDENT * depth
116
+ lines = [f'{indent}{keyword} {self._expression(node.test)}:'] + self.block(node.body, depth + 1)
117
+ if node.orelse:
118
+ if len(node.orelse) == 1 and isinstance(node.orelse[0], ast.If) and self._written_as_elif(node.orelse[0]):
119
+ lines += self._if(node.orelse[0], depth, 'elif')
120
+ else:
121
+ lines += [f'{indent}else:'] + self.block(node.orelse, depth + 1)
122
+ return lines
123
+
124
+ def _loop(self, node: ast.For | ast.While, depth: int) -> list[str]:
125
+ indent = INDENT * depth
126
+ if isinstance(node, ast.While):
127
+ head = f'{indent}while {self._expression(node.test)}:'
128
+ else:
129
+ target = node.target
130
+ spelled = (', '.join(self._expression(element) for element in target.elts)
131
+ if isinstance(target, ast.Tuple) and target.elts else self._expression(target))
132
+ head = f'{indent}for {spelled} in {self._expression(node.iter)}:'
133
+ lines = [head] + self.block(node.body, depth + 1)
134
+ if node.orelse:
135
+ lines += [f'{indent}else:'] + self.block(node.orelse, depth + 1)
136
+ return lines
137
+
138
+ def statement(self, node: ast.stmt, depth: int, one_parameter_per_line: bool = False) -> list[str]:
139
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
140
+ return self._function(node, depth, one_parameter_per_line)
141
+ if isinstance(node, ast.ClassDef):
142
+ return self._class(node, depth)
143
+ if isinstance(node, ast.If):
144
+ return self._if(node, depth)
145
+ if isinstance(node, (ast.For, ast.While)):
146
+ return self._loop(node, depth)
147
+ indent = INDENT * depth
148
+ if isinstance(node, (ast.Try, ast.With, ast.Match, ast.AsyncFor, ast.AsyncWith)):
149
+ return [indent + line for line in ast.unparse(node).split('\n')] # PyneComp does not emit these
150
+ return [indent + self._expression(node)]
151
+
152
+ def block(self, body: list[ast.stmt], depth: int) -> list[str]:
153
+ lines: list[str] = []
154
+ previous: ast.stmt | None = None
155
+ for node in body:
156
+ if previous is not None:
157
+ lines += [''] * self._blank_lines.blank_lines_before(previous, node)
158
+ lines += self.statement(node, depth)
159
+ previous = node
160
+ return lines
161
+
162
+
163
+ def _is_stub(body: list[ast.stmt]) -> bool:
164
+ return (len(body) == 1 and isinstance(body[0], ast.Expr)
165
+ and isinstance(body[0].value, ast.Constant) and body[0].value.value is ...)
166
+
167
+
168
+ def _is_docstring(node: ast.stmt) -> bool:
169
+ return isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str)
170
+
171
+
172
+ def _is_main_guard(node: ast.stmt) -> bool:
173
+ return (isinstance(node, ast.If) and isinstance(node.test, ast.Compare)
174
+ and isinstance(node.test.left, ast.Name) and node.test.left.id == '__name__')
175
+
176
+
177
+ def _binds_shadowed_namespace(node: ast.stmt) -> bool:
178
+ return (isinstance(node, ast.Assign) and isinstance(node.value, ast.Call)
179
+ and isinstance(node.value.func, ast.Name) and node.value.func.id == 'shadowed_namespace')
180
+
181
+
182
+ def _declares_an_export(node: ast.stmt | None) -> bool:
183
+ """`name: _ProtocolName = Exported()`."""
184
+ value = getattr(node, 'value', None)
185
+ return isinstance(value, ast.Call) and isinstance(value.func, ast.Name) and value.func.id == 'Exported'
186
+
187
+
188
+ def _assigned_names(node: ast.stmt) -> set[str]:
189
+ targets = node.targets if isinstance(node, ast.Assign) else [getattr(node, 'target', None)]
190
+ return {target.id for target in targets if isinstance(target, ast.Name)}
191
+
192
+
193
+ def _moved_lines(source: LayoutSource, tree: ast.Module | None) -> list[int]:
194
+ """The Pine lines of the statements moved out of main(): the module-level assignments and the
195
+ enums and UDTs (found by their declaration in the Pine source)."""
196
+ if tree is None:
197
+ return []
198
+ lines = [source.origins.get(node.lineno, 0) for node in tree.body
199
+ if isinstance(node, (ast.Assign, ast.AnnAssign)) and source.origins.get(node.lineno, 0)]
200
+ for node in tree.body:
201
+ if isinstance(node, ast.ClassDef):
202
+ pine_name = re.sub(r'__ren__$', '', node.name)
203
+ match = re.search(rf'^(?:export\s+)?(?:type|enum)\s+{re.escape(pine_name)}\b', source.pine_source, re.M)
204
+ if match:
205
+ lines.append(source.pine_source.count('\n', 0, match.start()) + 1)
206
+ return lines
207
+
208
+
209
+ class PyneCompModulePrinter:
210
+ """Prints a generated module in PyneComp's layout."""
211
+
212
+ def __init__(self, banner: PyneCompBanner | None = None, imports: ImportBlockFormatter | None = None) -> None:
213
+ self._banner = banner or PyneCompBanner()
214
+ self._imports = imports or ImportBlockFormatter()
215
+
216
+ def blank_line_policy(self, source: LayoutSource, tree: ast.Module | None = None) -> BlankLinePolicy:
217
+ if source.keeps_source_layout:
218
+ return SourceLayoutPolicy(source.origins, source.pine_source.split('\n'), _moved_lines(source, tree))
219
+ return AfterDefinitionPolicy()
220
+
221
+ _declares_types = False # set per render: the module has a UDT or an enum
222
+
223
+ def _module_gap(self, previous: ast.stmt | None, current: ast.stmt) -> int:
224
+ """Blank lines between two module-level statements."""
225
+ if _binds_shadowed_namespace(current):
226
+ return 2 if previous is None or not _binds_shadowed_namespace(previous) else 0
227
+ if previous is not None and _binds_shadowed_namespace(previous) and not isinstance(current, _DEFINITIONS):
228
+ return 1
229
+ if previous is None: # right after the import block
230
+ if isinstance(current, _DEFINITIONS) or '__all__' in _assigned_names(current):
231
+ return 2
232
+ return 1
233
+ if isinstance(previous, _DEFINITIONS) or isinstance(current, _DEFINITIONS):
234
+ return 2
235
+ if '__all__' in _assigned_names(previous) or '__all__' in _assigned_names(current):
236
+ return 2
237
+ if _declares_an_export(previous) != _declares_an_export(current):
238
+ # the Exported() declarations are one group; a constant after them is one line away, two in a
239
+ # library that declares a UDT or an enum (probes const2, ex1..ex8; 16 of 16 libraries)
240
+ if _declares_an_export(previous):
241
+ return 2 if self._declares_types else 1
242
+ return 2
243
+ return 0
244
+
245
+ def render(self, source: LayoutSource, tree: ast.Module | None = None,
246
+ spellings: Mapping[int, str] | None = None) -> str:
247
+ """Print ``tree`` (default: ``source.python_source`` parsed) in PyneComp's layout.
248
+
249
+ ``tree`` may be a rewritten parse of ``source.python_source``: nodes that
250
+ kept their positions still find their source spelling and Pine line.
251
+ """
252
+ if tree is None:
253
+ tree = ast.parse(source.python_source)
254
+ source_quoted = source_quoted_strings(tree) if source.keeps_source_layout else frozenset()
255
+ single_quoted = frozenset() if source.keeps_source_layout else header_strings(tree)
256
+ # the strings PyneComp writes as the source has them keep their non-printable characters raw
257
+ raw_strings = source_quoted if source.keeps_source_layout else source_quoted_strings(tree)
258
+ expression = ExpressionPrinter(source.python_source, source_quoted, spellings, single_quoted, raw_strings)
259
+ source_lines = source.python_source.split('\n')
260
+
261
+ def written_as_elif(node: ast.If) -> bool:
262
+ """Pine `else if` is `elif`; an `if` nested in an `else` block stays nested."""
263
+ lineno = getattr(node, 'lineno', 0)
264
+ return not lineno or source_lines[lineno - 1].lstrip().startswith('elif ')
265
+
266
+ def blank_in_source(node: ast.stmt) -> bool:
267
+ """The generated source has a blank line just above `node` (a gap between two UDT fields)."""
268
+ lineno = getattr(node, 'lineno', 0)
269
+ return lineno > 1 and not source_lines[lineno - 2].strip()
270
+
271
+ writer = _ModuleWriter(expression, self.blank_line_policy(source, tree), written_as_elif, blank_in_source)
272
+
273
+ self._declares_types = any(
274
+ isinstance(node, ast.ClassDef) and not any(isinstance(base, ast.Name) and base.id == 'Protocol'
275
+ for base in node.bases) for node in tree.body)
276
+ body = [node for node in tree.body if not _is_docstring(node) and not _is_main_guard(node)]
277
+ import_nodes = [node for node in body if isinstance(node, (ast.Import, ast.ImportFrom))]
278
+ others = [node for node in body if not isinstance(node, (ast.Import, ast.ImportFrom))]
279
+
280
+ lines = self._banner.header() + self._imports.format(import_nodes)
281
+ previous: ast.stmt | None = None
282
+ for node in others:
283
+ lines += [''] * self._module_gap(previous, node)
284
+ is_main = isinstance(node, ast.FunctionDef) and node.name == 'main'
285
+ lines += writer.statement(node, 0, one_parameter_per_line=is_main)
286
+ previous = node
287
+ lines += ['', ''] + self._banner.footer()
288
+ return '\n'.join(lines) + '\n'