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
pine2pyne/codegen.py ADDED
@@ -0,0 +1,886 @@
1
+ """
2
+ Code generator: Emit Python source code from transformed AST.
3
+
4
+ Generates PEP 8 compliant Python code compatible with PyneCore.
5
+ """
6
+ import re
7
+ from typing import Dict, List, Union
8
+ from .ast_nodes import *
9
+ from .transformer import PyneTransformedScript
10
+ from .errors import CodeGenError
11
+ from .udt_field_defaults import field_value
12
+ from .ast_walk import walk
13
+ from .strict_boolean import strict_operands
14
+
15
+ # Pine binary operator precedence, lowest to highest, mirroring the parser's
16
+ # chain parse_or -> parse_multiplication. Every level is left-associative.
17
+ BINARY_OPERATOR_PRECEDENCE = {
18
+ 'or': 1,
19
+ 'and': 2,
20
+ '==': 3, '!=': 3,
21
+ '<': 4, '>': 4, '<=': 4, '>=': 4,
22
+ '+': 5, '-': 5,
23
+ '*': 6, '/': 6, '%': 6,
24
+ }
25
+ COMPARISON_OPERATORS = frozenset({'==', '!=', '<', '>', '<=', '>='})
26
+
27
+
28
+ class CodeGenerator:
29
+ """Generates Python source code from transformed AST."""
30
+
31
+ def __init__(self, indent_size: int = 4, keep_keyword_string_quotes: bool = False):
32
+ self.indent_size = indent_size
33
+ # False: a keyword argument's string is double-quoted (pine2pyne's layout);
34
+ # True: it keeps the quotes of the Pine source (what PyneComp prints)
35
+ self.keep_keyword_string_quotes = keep_keyword_string_quotes
36
+ self.indent_level = 0
37
+ self.output: List[str] = []
38
+ # The Pine line of the top-level statement being emitted. The parser
39
+ # stamps only top-level statements, so a refusal of a nested node
40
+ # reports this line (card #40)
41
+ self.top_level_line = 0
42
+ # Pine v5 and earlier evaluate both operands of and/or (card #65)
43
+ self.strict_boolean_operators = False
44
+ # Pine v6 gives a UDT bool field without a default false; before v6 it is na (card #76)
45
+ self.bool_is_never_na = True
46
+ self.user_function_names: frozenset = frozenset()
47
+ # Output line index -> the Pine line its statement came from. A layout
48
+ # printer (pine2pyne.emit) reads it to place blank lines the way the source does
49
+ self.line_origins: Dict[int, int] = {}
50
+
51
+ @staticmethod
52
+ def _esc(name: str) -> str:
53
+ """Escape Python reserved words used as identifiers."""
54
+ from .pine_builtins import sanitize_identifier
55
+ return sanitize_identifier(name)
56
+
57
+ def _get_all_type_hints(self, script) -> List[str]:
58
+ """Extract all type hints from script for Any detection."""
59
+ hints = []
60
+
61
+ # Check main parameters
62
+ for param in script.main_params:
63
+ if param.type_hint:
64
+ hints.append(param.type_hint)
65
+
66
+ # Check main body statements
67
+ for stmt in script.main_body:
68
+ if hasattr(stmt, 'type_hint') and stmt.type_hint:
69
+ hints.append(stmt.type_hint)
70
+
71
+ # Check global vars
72
+ for var in script.global_vars:
73
+ if hasattr(var, 'type_hint') and var.type_hint:
74
+ hints.append(var.type_hint)
75
+
76
+ # Check helper functions
77
+ for func in script.helper_functions:
78
+ if func.return_type:
79
+ hints.append(func.return_type)
80
+ for param in func.params:
81
+ if param.type_hint:
82
+ hints.append(param.type_hint)
83
+ # Check function body (handle both list and single statement)
84
+ body = func.body if isinstance(func.body, list) else [func.body]
85
+ for stmt in body:
86
+ if hasattr(stmt, 'type_hint') and stmt.type_hint:
87
+ hints.append(stmt.type_hint)
88
+
89
+ return hints
90
+
91
+ def generate(self, script: PyneTransformedScript) -> str:
92
+ """Generate complete Python source code."""
93
+ self.output = []
94
+ self.line_origins = {}
95
+ self.top_level_line = 0
96
+ self.strict_boolean_operators = script.strict_boolean_operators
97
+ self.bool_is_never_na = script.bool_is_never_na
98
+ self.user_function_names = frozenset(
99
+ node.name for node in walk([script.helper_functions, script.main_body, script.global_vars])
100
+ if isinstance(node, FuncDecl))
101
+
102
+ # 1. @pyne magic docstring with attribution
103
+ self.emit('"""')
104
+ self.emit('@pyne')
105
+ self.emit('')
106
+ self.emit('This code was compiled by Pine2Pyne — the Pine Script to PyneCore\'s Python compiler.')
107
+ self.emit('"""')
108
+ self.emit('')
109
+
110
+ # 2. Import statements
111
+ # Check if Any is used in type hints
112
+ uses_any = any('[Any]' in stmt or ': Any' in stmt for stmt in self._get_all_type_hints(script))
113
+
114
+ if uses_any:
115
+ self.emit('from typing import Any')
116
+
117
+ for import_stmt in script.imports:
118
+ self.emit(import_stmt)
119
+ if script.imports or uses_any:
120
+ self.emit('')
121
+
122
+ # 3-5. Module prelude in three phases (card #78): enum classes, then the module
123
+ # constants (they may read an enum member), then the UDT classes (a field default may
124
+ # read an enum member, and cannot read a constant)
125
+ # Enum declarations
126
+ for enum_decl in script.enum_declarations:
127
+ self.top_level_line = enum_decl.line
128
+ self.generate_enum_declaration(enum_decl)
129
+ self.emit('')
130
+
131
+ # Module-level constants (e.g., const string group labels, pre-main literals)
132
+ # These must be at module level because they are referenced by main()
133
+ # parameter defaults which Python evaluates at function definition time.
134
+ if script.module_constants:
135
+ for const_stmt in script.module_constants:
136
+ self.generate_top_level_statement(const_stmt)
137
+ self.emit('')
138
+
139
+ # Type declarations (custom types with @udt)
140
+ self._udt_names = {type_decl.name for type_decl in script.type_declarations}
141
+ # Pine v6: a gap in the source between two fields is a blank line between them (PyneComp)
142
+ self._keeps_field_gaps = getattr(script, 'pine_version', 6) >= 6
143
+ for type_decl in script.type_declarations:
144
+ self.top_level_line = type_decl.line
145
+ self.generate_type_declaration(type_decl)
146
+ self.emit('')
147
+
148
+ # A library's export surface: one Exported proxy per exported name, published in __all__ (card #111).
149
+ # pynecore's `@export` resolves the proxy by name in this module's globals
150
+ exported_names = list(dict.fromkeys(
151
+ statement.name for statement in script.main_body if isinstance(statement, FuncDecl) and statement.is_export))
152
+ if exported_names:
153
+ for exported_name in exported_names:
154
+ self.emit(f'{self._esc(exported_name)} = Exported()')
155
+ self.emit('')
156
+ self.emit('__all__ = [' + ', '.join(f"'{self._esc(name)}'" for name in exported_names) + ']')
157
+ self.emit('')
158
+
159
+ # 4. Main function with decorator (helper functions now inside main)
160
+ if script.main_decorator:
161
+ self.top_level_line = script.main_decorator.line
162
+ decorator = self.generate_decorator(script.main_decorator)
163
+ self.emit(decorator)
164
+
165
+ # Generate main function signature
166
+ self.emit('def main(')
167
+ self.indent_level += 1
168
+
169
+ # Generate parameters
170
+ if script.main_params:
171
+ for i, param in enumerate(script.main_params):
172
+ self.top_level_line = param.line # the Pine input it came from
173
+ param_str = self.generate_parameter(param)
174
+ if i < len(script.main_params) - 1:
175
+ param_str += ','
176
+ self.emit(param_str)
177
+ else:
178
+ # No parameters - but Python requires at least empty ()
179
+ pass
180
+
181
+ self.indent_level -= 1
182
+
183
+ # Close parameter list and add colon
184
+ if script.main_params:
185
+ self.emit('):')
186
+ else:
187
+ self.output[-1] += '):' # Add closing paren and colon to def line
188
+
189
+ self.indent_level += 1
190
+
191
+ main_body_start = len(self.output)
192
+
193
+ # Generate global var declarations (Persistent)
194
+ for var_decl in script.global_vars:
195
+ self.generate_top_level_statement(var_decl)
196
+
197
+ # Generate main body with blank lines between statements
198
+ # Rule: add blank line after function defs and between different statement kinds
199
+ prev_kind = None # 'persistent', 'func', 'body'
200
+ prev_source_line = 0
201
+ for i, stmt in enumerate(script.main_body):
202
+ source_line = getattr(stmt, 'line', 0)
203
+
204
+ # Determine statement kind
205
+ if isinstance(stmt, Assignment) and isinstance(stmt.type_hint, str) and 'Persistent' in stmt.type_hint:
206
+ kind = 'persistent'
207
+ elif isinstance(stmt, FuncDecl):
208
+ kind = 'func'
209
+ else:
210
+ kind = 'body'
211
+
212
+ # Add blank lines based on context
213
+ if i > 0:
214
+ gap = source_line - prev_source_line if source_line > 0 and prev_source_line > 0 else 0
215
+
216
+ if prev_kind == 'func':
217
+ # Always blank line after function definition
218
+ self.emit('')
219
+ elif prev_kind != kind:
220
+ # Transition between statement types
221
+ if gap > 5:
222
+ # Large gap (section change) - add extra blank lines
223
+ blanks = min((gap - 1) // 10 + 1, 3)
224
+ for _ in range(blanks):
225
+ self.emit('')
226
+ else:
227
+ self.emit('')
228
+ elif kind == 'persistent' and gap >= 3:
229
+ # Gap between Persistent groups (e.g., maps → arrays)
230
+ self.emit('')
231
+ elif kind == 'body' and gap >= 3:
232
+ # Gap between body statements (comment/blank line in source)
233
+ self.emit('')
234
+
235
+ self.generate_top_level_statement(stmt)
236
+ prev_kind = kind
237
+ prev_source_line = source_line
238
+
239
+ # A script with no executable statement (a type-only or comment-only
240
+ # Pine script) still needs a statement under `def main():` (card #15)
241
+ if len(self.output) == main_body_start:
242
+ self.emit('pass')
243
+
244
+ self.indent_level -= 1
245
+
246
+ return '\n'.join(self.output)
247
+
248
+ # ========================================================================
249
+ # Emission helpers
250
+ # ========================================================================
251
+
252
+ def emit(self, line: str = '') -> None:
253
+ """Emit a line of code with current indentation."""
254
+ if line:
255
+ indent = ' ' * (self.indent_level * self.indent_size)
256
+ self.output.append(indent + line)
257
+ else:
258
+ self.output.append('')
259
+
260
+ def emit_inline(self, text: str) -> None:
261
+ """Emit text without newline (append to last line)."""
262
+ if self.output:
263
+ self.output[-1] += text
264
+ else:
265
+ self.output.append(text)
266
+
267
+ # ========================================================================
268
+ # Top-level code generation
269
+ # ========================================================================
270
+
271
+ def generate_decorator(self, decl: Union[IndicatorDecl, StrategyDecl, LibraryDecl]) -> str:
272
+ """Generate @script.indicator, @script.strategy or @script.library decorator."""
273
+ name = {IndicatorDecl: 'indicator', LibraryDecl: 'library'}.get(type(decl), 'strategy')
274
+ # A None title is an expression, emitted as the 'title' keyword (card #55)
275
+ arguments = [] if decl.title is None else [f"'{self._escape_string(decl.title, chr(39))}'"]
276
+ arguments += [f'{key}={self.generate_decorator_value(value)}' for key, value in decl.kwargs.items()]
277
+ return f"@script.{name}({', '.join(arguments)})"
278
+
279
+ def generate_decorator_value(self, value: Any) -> str:
280
+ """Generate decorator kwarg value."""
281
+ if isinstance(value, bool):
282
+ return 'True' if value else 'False'
283
+ elif isinstance(value, (int, float)):
284
+ return str(value)
285
+ elif isinstance(value, str):
286
+ return f'"{value}"'
287
+ elif isinstance(value, Literal):
288
+ return self.generate_literal(value)
289
+ elif isinstance(value, MemberAccess):
290
+ return self.generate_member_access(value)
291
+ elif isinstance(value, Identifier):
292
+ return value.name
293
+ elif isinstance(value, Expression):
294
+ # Handle any other expression type
295
+ return self.generate_expression(value)
296
+ else:
297
+ return str(value)
298
+
299
+ def generate_parameter(self, param: Parameter) -> str:
300
+ """Generate function parameter."""
301
+ param_str = self._esc(param.name)
302
+
303
+ # Add type hint
304
+ if param.type_hint:
305
+ param_str += f': {param.type_hint}'
306
+
307
+ # Add default value
308
+ if param.default:
309
+ default_str = self.generate_expression(param.default)
310
+ # Fix NA() to na for function default parameters
311
+ if default_str == 'NA()':
312
+ default_str = 'na'
313
+ # PEP 8: spaces around = in defaults when type annotation present
314
+ if param.type_hint:
315
+ param_str += f' = {default_str}'
316
+ else:
317
+ param_str += f'={default_str}'
318
+
319
+ return param_str
320
+
321
+ # ========================================================================
322
+ # Type declaration generation
323
+ # ========================================================================
324
+
325
+ def generate_type_declaration(self, type_decl: TypeDecl) -> None:
326
+ """Generate custom type definition with @udt decorator."""
327
+ from .transformer import convert_pine_generic_to_python
328
+ from .pine_builtins import get_type_name
329
+
330
+ # Emit @udt decorator
331
+ self.emit('@udt')
332
+ self.emit(f'class {self._esc(type_decl.name)}:')
333
+ self.indent_level += 1
334
+
335
+ # Generate fields: a series built-in default is read per instance, a
336
+ # field without a default is na (card #17, udt_field_defaults)
337
+ lines = list(getattr(type_decl, 'field_lines', ()) or ())
338
+ for index, (field_name, field_type, default_value) in enumerate(type_decl.fields):
339
+ if getattr(self, '_keeps_field_gaps', False) and 0 < index < len(lines) and lines[index] - lines[index - 1] > 1:
340
+ self.emit('')
341
+ # Convert Pine type to Python type (handles generics like array<T> → list[T])
342
+ py_type = convert_pine_generic_to_python(field_type)
343
+ fname = self._esc(field_name)
344
+
345
+ # For NA() calls, use base type only (NA(list) not NA(list[X]))
346
+ na_type = py_type.split('[')[0] if '[' in py_type else py_type
347
+
348
+ # A field that holds a UDT (its own class, or one declared later) is annotated with a
349
+ # string, and its na is made per instance (PyneComp: `p: 'P' = field(default_factory=...)`)
350
+ self_referential = re.search(rf'\b{re.escape(type_decl.name)}\b', py_type) is not None
351
+ holds_a_udt = na_type in getattr(self, '_udt_names', ()) or na_type == type_decl.name
352
+ # and so is a collection of one (`tp: 'list[PosAction]' = na(list)`); an enum is not
353
+ mentions_a_udt = any(re.search(rf'\b{re.escape(name)}\b', py_type) for name in getattr(self, '_udt_names', ()))
354
+ annotation = f"'{py_type}'" if self_referential or holds_a_udt or mentions_a_udt else py_type
355
+ generated_default = (self.generate_expression(default_value)
356
+ if default_value is not None and not isinstance(default_value, NaLiteral) else None)
357
+ value = field_value(py_type, na_type, default_value, generated_default, holds_a_udt,
358
+ self.bool_is_never_na)
359
+ self.emit(f'{fname}: {annotation} = {value}')
360
+
361
+ self.indent_level -= 1
362
+
363
+ def generate_enum_declaration(self, enum_decl: EnumDecl) -> None:
364
+ """Generate an enum as PyneComp does: a `StrEnum` (imported as `Enum`) whose members
365
+ are their titles, a member without one its own name (`a = 'a'`)."""
366
+ self.uses_enum = True
367
+ self.emit(f'class {self._esc(enum_decl.name)}(Enum):')
368
+ self.indent_level += 1
369
+
370
+ for member in enum_decl.members:
371
+ if isinstance(member, tuple):
372
+ name, value = member
373
+ value_str = (repr(value.value) if isinstance(value, Literal) and isinstance(value.value, str)
374
+ else self.generate_expression(value))
375
+ self.emit(f'{self._esc(name)} = {value_str}')
376
+ else:
377
+ # No explicit value — use member name as string value
378
+ self.emit(f'{self._esc(member)} = {member!r}')
379
+
380
+ self.indent_level -= 1
381
+
382
+ # ========================================================================
383
+ # Function generation
384
+ # ========================================================================
385
+
386
+ def generate_function(self, func: FuncDecl) -> None:
387
+ """Generate function definition."""
388
+ # Emit @method decorator if this is a method
389
+ if hasattr(func, 'is_method') and func.is_method:
390
+ self.emit('@method')
391
+ # A library's exported function (card #111); pynecore's @export goes above @overload
392
+ if func.is_export:
393
+ self.emit('@export')
394
+ # Same-name declarations share one pynecore @overload dispatcher (card #39)
395
+ if func.is_overload:
396
+ self.emit('@overload')
397
+
398
+ # Function signature
399
+ params = ', '.join(self.generate_parameter(p) for p in func.params)
400
+ self.emit(f'def {self._esc(func.name)}({params}):')
401
+
402
+ self.indent_level += 1
403
+
404
+ # Track if we emitted any body content
405
+ body_was_empty = False
406
+
407
+ # Function body
408
+ if isinstance(func.body, (list, tuple)):
409
+ for i, stmt in enumerate(func.body):
410
+ is_last = (i == len(func.body) - 1)
411
+ # Last expression in body is the return value
412
+ if is_last and isinstance(stmt, Expression):
413
+ self.emit(f'return {self.generate_expression(stmt)}')
414
+ else:
415
+ self.generate_statement(stmt)
416
+ # If body list was empty or all statements were None, add pass
417
+ if not func.body or all(s is None for s in func.body):
418
+ self.emit('pass')
419
+ body_was_empty = True
420
+ elif isinstance(func.body, Expression):
421
+ # Single expression - add return
422
+ self.emit(f'return {self.generate_expression(func.body)}')
423
+ elif isinstance(func.body, ReturnStatement):
424
+ self.generate_statement(func.body)
425
+ else:
426
+ # Empty or None body - add pass
427
+ self.emit('pass')
428
+ body_was_empty = True
429
+
430
+ self.indent_level -= 1
431
+
432
+ # ========================================================================
433
+ # Statement generation
434
+ # ========================================================================
435
+
436
+ def generate_top_level_statement(self, stmt: Statement) -> None:
437
+ """Generate a statement of the module or of main(), recording its Pine line."""
438
+ self.top_level_line = getattr(stmt, 'line', 0)
439
+ self.generate_statement(stmt)
440
+
441
+ def refusal_line(self, node: ASTNode) -> int:
442
+ """The Pine line to report for a node codegen refuses: its own, or its
443
+ top-level statement's when the parser did not stamp it."""
444
+ return getattr(node, 'line', 0) or self.top_level_line
445
+
446
+ def generate_statement(self, stmt: Statement) -> None:
447
+ """Generate a statement."""
448
+ # Flatten lists of statements (from transforms that expand one stmt into multiple)
449
+ if isinstance(stmt, list):
450
+ for s in stmt:
451
+ self.generate_statement(s)
452
+ return
453
+ pine_line = getattr(stmt, 'line', 0) or getattr(stmt, 'source_line', 0)
454
+ if pine_line:
455
+ self.line_origins.setdefault(len(self.output), pine_line)
456
+ if isinstance(stmt, FuncDecl):
457
+ # Functions can now appear inside main()
458
+ self.generate_function(stmt)
459
+ self.emit('') # Add blank line after function
460
+ elif isinstance(stmt, Assignment):
461
+ self.generate_assignment(stmt)
462
+ elif isinstance(stmt, IfStatement):
463
+ self.generate_if_statement(stmt)
464
+ elif isinstance(stmt, ForLoop):
465
+ self.generate_for_loop(stmt)
466
+ elif isinstance(stmt, ForInLoop):
467
+ self.generate_for_in_loop(stmt)
468
+ elif isinstance(stmt, WhileLoop):
469
+ self.generate_while_loop(stmt)
470
+ elif isinstance(stmt, BreakStatement):
471
+ self.emit('break')
472
+ elif isinstance(stmt, ContinueStatement):
473
+ self.emit('continue')
474
+ elif isinstance(stmt, ReturnStatement):
475
+ self.generate_return_statement(stmt)
476
+ elif isinstance(stmt, ExpressionStatement):
477
+ self.emit(self.generate_expression(stmt.expr))
478
+ elif isinstance(stmt, RawCode):
479
+ for line in stmt.code.split('\n'):
480
+ self.emit(line)
481
+ else:
482
+ # Refuse rather than drop the statement: a comment in its place
483
+ # changes what the program computes, or leaves a block empty (card #40)
484
+ raise CodeGenError(f'Unsupported statement type: {type(stmt).__name__}',
485
+ line=self.refusal_line(stmt))
486
+
487
+ def generate_assignment(self, stmt: Assignment) -> None:
488
+ """Generate assignment statement."""
489
+ from .ast_nodes import BinaryOp, Identifier, MemberAccess, TupleDestructure
490
+
491
+ # Handle tuple destructuring: [a, b, c] = expr
492
+ if isinstance(stmt.target, TupleDestructure):
493
+ names = ', '.join(self._esc(n) for n in stmt.target.names)
494
+ value = self.generate_expression(stmt.value)
495
+ type_hint = f': {stmt.type_hint}' if stmt.type_hint else ''
496
+ self.emit(f'{names}{type_hint} = {value}')
497
+ return
498
+
499
+ target = self._esc(stmt.target if isinstance(stmt.target, str) else str(stmt.target))
500
+
501
+ # A compound assignment the source wrote as one (x += y), not x := x + y
502
+ if isinstance(stmt.value, BinaryOp) and stmt.value.compound and not stmt.type_hint:
503
+ left_matches = False
504
+
505
+ # Check if the left side of the BinaryOp matches the target
506
+ if isinstance(stmt.value.left, Identifier):
507
+ left_matches = (stmt.value.left.name == target)
508
+ elif isinstance(stmt.value.left, MemberAccess):
509
+ # For member access like this.wins, reconstruct the full name
510
+ left_name = f"{stmt.value.left.object}.{stmt.value.left.member}"
511
+ left_matches = (left_name == target)
512
+
513
+ # If it's a compound assignment pattern, generate +=, -=, etc.
514
+ if left_matches and stmt.value.op in ['+', '-', '*', '/', '%']:
515
+ compound_op = f'{stmt.value.op}='
516
+ right_value = self.generate_expression(stmt.value.right)
517
+ self.emit(f'{target} {compound_op} {right_value}')
518
+ return
519
+
520
+ # Regular assignment
521
+ value = self.generate_expression(stmt.value)
522
+
523
+ # Wrap string literals assigned to Color type as Color('...')
524
+ if stmt.type_hint == 'Color' and value.startswith("'") and value.endswith("'"):
525
+ value = f'Color({value})'
526
+
527
+ # Add type hint if present
528
+ if stmt.type_hint:
529
+ self.emit(f'{target}: {stmt.type_hint} = {value}')
530
+ else:
531
+ self.emit(f'{target} = {value}')
532
+
533
+ def generate_if_statement(self, stmt: IfStatement) -> None:
534
+ """Generate if statement."""
535
+ condition = self.generate_expression(stmt.condition)
536
+ self.emit(f'if {condition}:')
537
+
538
+ self.indent_level += 1
539
+ for s in stmt.body:
540
+ self.generate_statement(s)
541
+ self.indent_level -= 1
542
+
543
+ # Generate elif clauses
544
+ for elif_cond, elif_body in stmt.elseifs:
545
+ elif_condition = self.generate_expression(elif_cond)
546
+ self.emit(f'elif {elif_condition}:')
547
+ self.indent_level += 1
548
+ for s in elif_body:
549
+ self.generate_statement(s)
550
+ self.indent_level -= 1
551
+
552
+ # Generate else clause
553
+ if stmt.else_body:
554
+ self.emit('else:')
555
+ self.indent_level += 1
556
+ for s in stmt.else_body:
557
+ self.generate_statement(s)
558
+ self.indent_level -= 1
559
+
560
+ def generate_for_loop(self, stmt: ForLoop) -> None:
561
+ """Generate for loop with pine_range(), or with pine_loop() when the end
562
+ bound is re-read on every pass (Pine v6, card #82)."""
563
+ # Convert Pine 'for i = from to to_val [by step]' to Python pine_range()
564
+ from_val = self.generate_expression(stmt.from_val)
565
+ to_val = self.generate_expression(stmt.to_val)
566
+
567
+ # Pine's 'to' is inclusive, pine_range handles this correctly
568
+ # No need to add 1 like we did with range()
569
+ var = self._esc(stmt.var)
570
+ if stmt.counter_name:
571
+ # from and by are evaluated once, the end before every pass
572
+ step = f', {self.generate_expression(stmt.step)}' if stmt.step else ''
573
+ self.emit(f'{stmt.counter_name} = {stmt.counter_helper}({from_val}{step})')
574
+ self.emit(f'while {stmt.counter_name}.step({to_val}):')
575
+ self.indent_level += 1
576
+ self.emit(f'{var} = {stmt.counter_name}.value')
577
+ for s in stmt.body:
578
+ self.generate_statement(s)
579
+ self.indent_level -= 1
580
+ return
581
+ range_helper = stmt.range_helper or 'pine_range'
582
+ if stmt.step:
583
+ step = self.generate_expression(stmt.step)
584
+ self.emit(f'for {var} in {range_helper}({from_val}, {to_val}, {step}):')
585
+ else:
586
+ self.emit(f'for {var} in {range_helper}({from_val}, {to_val}):')
587
+
588
+ self.indent_level += 1
589
+ for s in stmt.body:
590
+ self.generate_statement(s)
591
+ self.indent_level -= 1
592
+
593
+ def generate_for_in_loop(self, stmt: ForInLoop) -> None:
594
+ """Generate for-in loop."""
595
+ vars_str = ', '.join(self._esc(v) for v in stmt.vars)
596
+ iterable = self.generate_expression(stmt.iterable)
597
+
598
+ # `for [k, v] in x` reaches here with its pairs already chosen by the
599
+ # transformer: enumerate(), a map's keys, or a run-time choice (card #17)
600
+ self.emit(f'for {vars_str} in {iterable}:')
601
+
602
+ self.indent_level += 1
603
+ for s in stmt.body:
604
+ self.generate_statement(s)
605
+ self.indent_level -= 1
606
+
607
+ def generate_while_loop(self, stmt: WhileLoop) -> None:
608
+ """Generate while loop."""
609
+ condition = self.generate_expression(stmt.condition)
610
+ self.emit(f'while {condition}:')
611
+
612
+ self.indent_level += 1
613
+ for s in stmt.body:
614
+ self.generate_statement(s)
615
+ self.indent_level -= 1
616
+
617
+ def generate_return_statement(self, stmt: ReturnStatement) -> None:
618
+ """Generate return statement."""
619
+ if stmt.expr:
620
+ self.emit(f'return {self.generate_expression(stmt.expr)}')
621
+ else:
622
+ self.emit('return')
623
+
624
+ # ========================================================================
625
+ # Expression generation
626
+ # ========================================================================
627
+
628
+ def generate_expression(self, expr: Expression) -> str:
629
+ """Generate an expression."""
630
+ if isinstance(expr, BinaryOp):
631
+ return self.generate_binary_op(expr)
632
+ elif isinstance(expr, UnaryOp):
633
+ return self.generate_unary_op(expr)
634
+ elif isinstance(expr, TernaryOp):
635
+ return self.generate_ternary_op(expr)
636
+ elif isinstance(expr, FunctionCall):
637
+ return self.generate_function_call(expr)
638
+ elif isinstance(expr, MethodCall):
639
+ return self.generate_method_call(expr)
640
+ elif isinstance(expr, MemberAccess):
641
+ return self.generate_member_access(expr)
642
+ elif isinstance(expr, IndexAccess):
643
+ return self.generate_index_access(expr)
644
+ elif isinstance(expr, ArrayLiteral):
645
+ return self.generate_array_literal(expr)
646
+ elif isinstance(expr, Identifier):
647
+ return self.generate_identifier(expr)
648
+ elif isinstance(expr, NamedExpression):
649
+ return self.generate_named_expression(expr)
650
+ elif isinstance(expr, Literal):
651
+ return self.generate_literal(expr)
652
+ elif isinstance(expr, NaLiteral):
653
+ return 'na'
654
+ else:
655
+ # A comment in expression position is not Python; refuse (card #40)
656
+ raise CodeGenError(f'Unsupported expression type: {type(expr).__name__}',
657
+ line=self.refusal_line(expr))
658
+
659
+ def generate_identifier(self, expr: Identifier) -> str:
660
+ """Generate identifier."""
661
+ from .pine_builtins import TA_VARIABLE_FUNCTIONS
662
+
663
+ name = self._esc(expr.name)
664
+
665
+ # Pine Script "variable-functions": used without () in Pine Script
666
+ # but implemented as functions in PyneCore (e.g., ta.vwap -> ta.vwap())
667
+ if name in TA_VARIABLE_FUNCTIONS:
668
+ # The table's value is the argument text; every entry is '' today
669
+ defaults = TA_VARIABLE_FUNCTIONS.get(name, '')
670
+ return f'{name}({defaults})'
671
+
672
+ return name
673
+
674
+ def generate_named_expression(self, expr: NamedExpression) -> str:
675
+ """Generate `(name := value)`. Always parenthesised: a bare walrus is
676
+ illegal in most positions, and the parentheses keep a comparison in
677
+ `value` from chaining with an enclosing `==`."""
678
+ return f'({self._esc(expr.name)} := {self.generate_expression(expr.value)})'
679
+
680
+ def generate_binary_op(self, expr: BinaryOp) -> str:
681
+ """Generate binary operation."""
682
+ if self.strict_boolean_operators:
683
+ strict = strict_operands(expr, self.user_function_names)
684
+ if strict:
685
+ return self._generate_strict_boolean(expr.op, strict)
686
+ left = self.generate_expression(expr.left)
687
+ right = self.generate_expression(expr.right)
688
+
689
+ if self._operand_needs_parentheses(expr.op, expr.left, is_right_operand=False):
690
+ left = f'({left})'
691
+ if self._operand_needs_parentheses(expr.op, expr.right, is_right_operand=True):
692
+ right = f'({right})'
693
+
694
+ return f'{left} {expr.op} {right}'
695
+
696
+ def _generate_strict_boolean(self, operator: str, operands: List[Expression]) -> str:
697
+ """`all((a, b))` / `any((a, b))`: the tuple evaluates every operand, in order (card #65)."""
698
+ function = 'all' if operator == 'and' else 'any'
699
+ return f'{function}(({", ".join(self.generate_expression(operand) for operand in operands)}))'
700
+
701
+ @staticmethod
702
+ def _operand_needs_parentheses(parent_operator: str, operand: Expression, is_right_operand: bool) -> bool:
703
+ """Whether an operand must be parenthesised to keep its Pine grouping.
704
+
705
+ The parser keeps no parenthesis node, so grouping exists only in the
706
+ tree shape and every operator-precedence parenthesis between binary
707
+ and unary operators is re-derived here (index and member access on a
708
+ grouped object are not: card #32).
709
+ """
710
+ if isinstance(operand, UnaryOp):
711
+ # Pine `not` binds tightest; Python `not` binds looser than any
712
+ # comparison or arithmetic, so `q == not r` would not even parse.
713
+ return operand.op == 'not'
714
+ if not isinstance(operand, BinaryOp):
715
+ return False
716
+ if parent_operator in COMPARISON_OPERATORS and operand.op in COMPARISON_OPERATORS:
717
+ # Python chains `a < b == c` into `a < b and b == c`.
718
+ return True
719
+ parent_precedence = BINARY_OPERATOR_PRECEDENCE[parent_operator]
720
+ operand_precedence = BINARY_OPERATOR_PRECEDENCE[operand.op]
721
+ if operand_precedence != parent_precedence:
722
+ return operand_precedence < parent_precedence
723
+ # Same level: Pine is left-associative, so a right operand at this
724
+ # level was grouped explicitly (`a - (b - c)`, `a * (b % c)`).
725
+ return is_right_operand
726
+
727
+ def generate_unary_op(self, expr: UnaryOp) -> str:
728
+ """Generate unary operation."""
729
+ operand = self.generate_expression(expr.operand)
730
+
731
+ # Convert Pine operators to Python
732
+ op_map = {
733
+ 'not': 'not ',
734
+ }
735
+ op = op_map.get(expr.op, expr.op)
736
+
737
+ # Pine unary operators bind tighter than every binary operator, so a
738
+ # binary operand was grouped explicitly: -(a + b), not (a == b).
739
+ if isinstance(expr.operand, BinaryOp):
740
+ return f'{op}({operand})'
741
+
742
+ return f'{op}{operand}'
743
+
744
+ def generate_ternary_op(self, expr: TernaryOp) -> str:
745
+ """Generate ternary operation (Python if/else expression)."""
746
+ condition = self.generate_expression(expr.condition)
747
+ true_expr = self.generate_expression(expr.true_expr)
748
+ false_expr = self.generate_expression(expr.false_expr) if expr.false_expr else 'None'
749
+
750
+ return f'({true_expr} if {condition} else {false_expr})'
751
+
752
+ def generate_function_call(self, call: FunctionCall) -> str:
753
+ """Generate function call."""
754
+ # Generate function name
755
+ if isinstance(call.func, str):
756
+ func = call.func
757
+ elif isinstance(call.func, MemberAccess):
758
+ func = self.generate_member_access(call.func)
759
+ elif isinstance(call.func, NaLiteral):
760
+ # Special case: na(x) is a function call, not NA() literal
761
+ func = 'na'
762
+ # If NA() with no args, just return 'na' (for default params)
763
+ if not call.args and not call.kwargs:
764
+ return 'na'
765
+ else:
766
+ func = self.generate_expression(call.func)
767
+
768
+ # Generate arguments (positional - use single quotes for strings)
769
+ args = [self.generate_expression(arg) for arg in call.args]
770
+
771
+ # Generate keyword arguments (use double quotes for string literals)
772
+ kwargs = []
773
+ for key, value in call.kwargs.items():
774
+ # Special case: options parameter should use tuple () instead of list []
775
+ if key == 'options' and isinstance(value, ArrayLiteral):
776
+ elements = [self._generate_kwarg_value(e) for e in value.elements]
777
+ kwargs.append(f'{key}=({", ".join(elements)})')
778
+ else:
779
+ kwargs.append(f'{key}={self._generate_kwarg_value(value)}')
780
+
781
+ all_args = args + kwargs
782
+ return f'{func}({", ".join(all_args)})'
783
+
784
+ def _generate_kwarg_value(self, value: Expression) -> str:
785
+ """Generate kwarg value with double quotes for strings (or the source's quotes)."""
786
+ if isinstance(value, Literal) and value.literal_type == 'string' and not self.keep_keyword_string_quotes:
787
+ return f'"{self._escape_string(value.value, chr(34))}"'
788
+ elif isinstance(value, Literal) and value.literal_type in ('int', 'float', 'bool'):
789
+ # For non-string literals, use normal generation
790
+ return self.generate_literal(value)
791
+ else:
792
+ return self.generate_expression(value)
793
+
794
+ def generate_method_call(self, call: MethodCall) -> str:
795
+ """Generate method call. The transformer routes every builtin method to its
796
+ namespace (card #8), so what reaches here is a Python method call."""
797
+ from .ast_nodes import BinaryOp, UnaryOp, TernaryOp, IfExpression
798
+
799
+ obj = self.generate_expression(call.object)
800
+ args = [self.generate_expression(arg) for arg in call.args]
801
+ kwargs = [f'{key}={self.generate_expression(value)}' for key, value in call.kwargs.items()]
802
+
803
+ # FIX: Wrap complex expressions in parentheses to preserve precedence
804
+ # This ensures (expression).method() keeps correct semantics
805
+ if isinstance(call.object, (BinaryOp, UnaryOp, TernaryOp, IfExpression)):
806
+ obj = f'({obj})'
807
+
808
+ # Otherwise, keep as method call
809
+ all_args = args + kwargs
810
+ return f'{obj}.{call.method}({", ".join(all_args)})'
811
+
812
+ def generate_member_access(self, expr: MemberAccess) -> str:
813
+ """Generate member access."""
814
+ from .pine_builtins import TA_VARIABLE_FUNCTIONS
815
+
816
+ if isinstance(expr.object, str):
817
+ obj = expr.object
818
+ elif isinstance(expr.object, Expression):
819
+ obj = self.generate_expression(expr.object)
820
+ else:
821
+ obj = str(expr.object)
822
+
823
+ result = f'{obj}.{expr.member}'
824
+
825
+ # Pine Script "variable-functions": used without () in Pine Script
826
+ # but implemented as functions in PyneCore (e.g., ta.vwap -> ta.vwap())
827
+ if result in TA_VARIABLE_FUNCTIONS:
828
+ defaults = TA_VARIABLE_FUNCTIONS.get(result, '')
829
+ result = f'{result}({defaults})'
830
+
831
+ return result
832
+
833
+ def generate_index_access(self, expr: IndexAccess) -> str:
834
+ """Generate index access."""
835
+ obj = self.generate_expression(expr.object)
836
+ index = self.generate_expression(expr.index)
837
+ return f'{obj}[{index}]'
838
+
839
+ def generate_array_literal(self, expr: ArrayLiteral) -> str:
840
+ """Generate a Pine tuple expression as a Python tuple.
841
+
842
+ Pine writes tuple literals with square brackets and has no bracket
843
+ syntax for arrays -- those come from ``array.new``/``array.from`` -- so
844
+ every bracket that reaches here is a tuple, never a collection literal.
845
+ Emitting a list instead makes the runtime take its array branch: it
846
+ answers ``request.seed``/``request.security`` with a single ``na``
847
+ rather than one ``na`` per element, and the caller's destructuring then
848
+ dies with "na is not iterable".
849
+ """
850
+ elements = [self.generate_expression(e) for e in expr.elements]
851
+ if len(elements) == 1:
852
+ # A lone parenthesised value is not a tuple without the comma.
853
+ return f'({elements[0]},)'
854
+ return f'({", ".join(elements)})'
855
+
856
+ def _escape_string(self, value: str, quote: str) -> str:
857
+ """Escape a string value for Python output."""
858
+ escaped = value.replace('\\', '\\\\')
859
+ escaped = escaped.replace('\n', '\\n')
860
+ escaped = escaped.replace('\t', '\\t')
861
+ escaped = escaped.replace('\r', '\\r')
862
+ escaped = escaped.replace(quote, f'\\{quote}')
863
+ return escaped
864
+
865
+ def generate_literal(self, expr: Literal) -> str:
866
+ """Generate literal value."""
867
+ if expr.literal_type == 'string':
868
+ if expr.is_double_quoted:
869
+ return f'"{self._escape_string(expr.value, chr(34))}"'
870
+ else:
871
+ return f"'{self._escape_string(expr.value, chr(39))}'"
872
+ elif expr.literal_type == 'bool':
873
+ return 'True' if expr.value else 'False'
874
+ elif expr.literal_type == 'color':
875
+ if expr.is_double_quoted:
876
+ return f'"{self._escape_string(expr.value, chr(34))}"'
877
+ else:
878
+ return f"'{self._escape_string(expr.value, chr(39))}'"
879
+ else:
880
+ return str(expr.value)
881
+
882
+
883
+ def generate_code(script: PyneTransformedScript) -> str:
884
+ """Convenience function to generate Python code from transformed script."""
885
+ codegen = CodeGenerator()
886
+ return codegen.generate(script)