pineforge-codegen 0.6.5__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 (35) hide show
  1. pineforge_codegen/__init__.py +53 -0
  2. pineforge_codegen/analyzer/__init__.py +60 -0
  3. pineforge_codegen/analyzer/base.py +1563 -0
  4. pineforge_codegen/analyzer/call_handlers.py +895 -0
  5. pineforge_codegen/analyzer/contracts.py +163 -0
  6. pineforge_codegen/analyzer/diagnostics.py +118 -0
  7. pineforge_codegen/analyzer/tables.py +204 -0
  8. pineforge_codegen/analyzer/types.py +250 -0
  9. pineforge_codegen/ast_nodes.py +293 -0
  10. pineforge_codegen/codegen/__init__.py +78 -0
  11. pineforge_codegen/codegen/base.py +1381 -0
  12. pineforge_codegen/codegen/emit_top.py +875 -0
  13. pineforge_codegen/codegen/helpers.py +163 -0
  14. pineforge_codegen/codegen/helpers_syminfo.py +134 -0
  15. pineforge_codegen/codegen/input.py +189 -0
  16. pineforge_codegen/codegen/security.py +1564 -0
  17. pineforge_codegen/codegen/ta.py +298 -0
  18. pineforge_codegen/codegen/tables.py +613 -0
  19. pineforge_codegen/codegen/types.py +573 -0
  20. pineforge_codegen/codegen/visit_call.py +1305 -0
  21. pineforge_codegen/codegen/visit_expr.py +701 -0
  22. pineforge_codegen/codegen/visit_stmt.py +729 -0
  23. pineforge_codegen/errors.py +98 -0
  24. pineforge_codegen/lexer.py +531 -0
  25. pineforge_codegen/parser.py +1198 -0
  26. pineforge_codegen/pragmas.py +117 -0
  27. pineforge_codegen/signatures.py +808 -0
  28. pineforge_codegen/support_checker.py +1111 -0
  29. pineforge_codegen/symbols.py +118 -0
  30. pineforge_codegen/tokens.py +406 -0
  31. pineforge_codegen/tv_input_choices.py +86 -0
  32. pineforge_codegen-0.6.5.dist-info/METADATA +462 -0
  33. pineforge_codegen-0.6.5.dist-info/RECORD +35 -0
  34. pineforge_codegen-0.6.5.dist-info/WHEEL +4 -0
  35. pineforge_codegen-0.6.5.dist-info/licenses/LICENSE +197 -0
@@ -0,0 +1,118 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass, field
3
+ from enum import Enum
4
+ from typing import Any
5
+ from .errors import SourceLocation
6
+
7
+
8
+ class PineType(Enum):
9
+ INT = "int"
10
+ FLOAT = "float"
11
+ BOOL = "bool"
12
+ STRING = "string"
13
+ COLOR = "color"
14
+ VOID = "void"
15
+ NA = "na"
16
+ UNKNOWN = "unknown"
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class TypeSpec:
21
+ """Structured Pine type metadata for collections and UDTs.
22
+
23
+ ``PineType`` intentionally stays small because much of the older analyzer
24
+ expects primitive enum values. TypeSpec carries the extra shape needed by
25
+ codegen and diagnostics without forcing a broad type-system rewrite.
26
+ """
27
+
28
+ kind: str
29
+ name: str | None = None
30
+ element: TypeSpec | None = None
31
+ key: TypeSpec | None = None
32
+ value: TypeSpec | None = None
33
+
34
+ @classmethod
35
+ def primitive(cls, name: str) -> TypeSpec:
36
+ return cls(kind="primitive", name=name)
37
+
38
+ @classmethod
39
+ def udt(cls, name: str) -> TypeSpec:
40
+ return cls(kind="udt", name=name)
41
+
42
+ @classmethod
43
+ def array(cls, element: TypeSpec) -> TypeSpec:
44
+ return cls(kind="array", element=element)
45
+
46
+ @classmethod
47
+ def map(cls, key: TypeSpec, value: TypeSpec) -> TypeSpec:
48
+ return cls(kind="map", key=key, value=value)
49
+
50
+ @classmethod
51
+ def matrix(cls, element: TypeSpec) -> TypeSpec:
52
+ return cls(kind="matrix", element=element)
53
+
54
+ def __str__(self) -> str:
55
+ if self.kind == "array" and self.element is not None:
56
+ return f"array<{self.element}>"
57
+ if self.kind == "map" and self.key is not None and self.value is not None:
58
+ return f"map<{self.key},{self.value}>"
59
+ return self.name or self.kind
60
+
61
+
62
+ @dataclass
63
+ class Symbol:
64
+ name: str
65
+ pine_type: PineType
66
+ is_series: bool
67
+ is_var: bool
68
+ is_const: bool
69
+ const_value: Any
70
+ scope: str
71
+ loc: SourceLocation
72
+ # User-defined enum: Pine `enum` is a derived type (not a primitive). We still
73
+ # use PineType.INT for variables holding the selected member’s ordinal in C++.
74
+ # enum_type_name links to the enum’s name so str.tostring / field payloads work.
75
+ enum_type_name: str | None = None
76
+ # User-defined type: variable holds an instance of this UDT (from TypeName.new(...)).
77
+ udt_type_name: str | None = None
78
+ # Structured collection/UDT type, when a primitive PineType is insufficient.
79
+ type_spec: TypeSpec | None = None
80
+
81
+
82
+ class Scope:
83
+ def __init__(self, name: str, parent: Scope | None):
84
+ self.name = name
85
+ self.parent = parent
86
+ self.symbols: dict[str, Symbol] = {}
87
+
88
+ def define(self, symbol: Symbol) -> None:
89
+ self.symbols[symbol.name] = symbol
90
+
91
+ def resolve(self, name: str) -> Symbol | None:
92
+ if name in self.symbols:
93
+ return self.symbols[name]
94
+ if self.parent is not None:
95
+ return self.parent.resolve(name)
96
+ return None
97
+
98
+
99
+ class SymbolTable:
100
+ def __init__(self):
101
+ self.global_scope = Scope(name="global", parent=None)
102
+ self.current_scope = self.global_scope
103
+ self.all_scopes: list[Scope] = [self.global_scope]
104
+
105
+ def enter_scope(self, name: str) -> None:
106
+ new_scope = Scope(name=name, parent=self.current_scope)
107
+ self.all_scopes.append(new_scope)
108
+ self.current_scope = new_scope
109
+
110
+ def exit_scope(self) -> None:
111
+ if self.current_scope.parent is not None:
112
+ self.current_scope = self.current_scope.parent
113
+
114
+ def define(self, symbol: Symbol) -> None:
115
+ self.current_scope.define(symbol)
116
+
117
+ def resolve(self, name: str) -> Symbol | None:
118
+ return self.current_scope.resolve(name)
@@ -0,0 +1,406 @@
1
+ """Tokenizer for PineScript v6 source code."""
2
+
3
+ from dataclasses import dataclass
4
+ from enum import Enum, auto
5
+
6
+
7
+ class TokenType(Enum):
8
+ # Literals
9
+ NUMBER = auto()
10
+ STRING = auto()
11
+ IDENT = auto()
12
+
13
+ # Structure
14
+ NEWLINE = auto()
15
+ INDENT = auto()
16
+ DEDENT = auto()
17
+ EOF_TOKEN = auto()
18
+
19
+ # Delimiters
20
+ LPAREN = auto()
21
+ RPAREN = auto()
22
+ LBRACKET = auto()
23
+ RBRACKET = auto()
24
+ COMMA = auto()
25
+ DOT = auto()
26
+
27
+ # Assignment
28
+ EQUALS = auto()
29
+ COLON_EQUALS = auto()
30
+ PLUS_EQUALS = auto()
31
+ MINUS_EQUALS = auto()
32
+ STAR_EQUALS = auto()
33
+ SLASH_EQUALS = auto()
34
+
35
+ # Arithmetic
36
+ PLUS = auto()
37
+ MINUS = auto()
38
+ STAR = auto()
39
+ SLASH = auto()
40
+ PERCENT = auto()
41
+
42
+ # Comparison
43
+ EQEQ = auto() # ==
44
+ NOTEQ = auto() # !=
45
+ GT = auto() # >
46
+ LT = auto() # <
47
+ GE = auto() # >=
48
+ LE = auto() # <=
49
+
50
+ # Logical (keywords)
51
+ AND = auto()
52
+ OR = auto()
53
+ NOT = auto()
54
+
55
+ # Ternary
56
+ QUESTION = auto()
57
+ COLON = auto()
58
+
59
+ # Arrow
60
+ FAT_ARROW = auto() # =>
61
+
62
+ # Keywords
63
+ IF = auto()
64
+ ELSE = auto()
65
+ FOR = auto()
66
+ WHILE = auto()
67
+ SWITCH = auto()
68
+ BREAK = auto()
69
+ CONTINUE = auto()
70
+ VAR = auto()
71
+ VARIP = auto()
72
+ TO = auto()
73
+ BY = auto()
74
+ TRUE = auto()
75
+ FALSE = auto()
76
+ NA = auto()
77
+
78
+ # Color literal (#rrggbb / #rrggbbaa)
79
+ COLOR = auto()
80
+
81
+ # Type keywords
82
+ TYPE_INT = auto()
83
+ TYPE_FLOAT = auto()
84
+ TYPE_BOOL = auto()
85
+ TYPE_STRING = auto()
86
+
87
+
88
+ @dataclass
89
+ class Token:
90
+ type: TokenType
91
+ value: str
92
+ line: int
93
+ col: int
94
+
95
+ def __repr__(self) -> str:
96
+ return f"Token({self.type.name}, {self.value!r}, L{self.line}:{self.col})"
97
+
98
+
99
+ KEYWORDS = {
100
+ "if": TokenType.IF,
101
+ "else": TokenType.ELSE,
102
+ "for": TokenType.FOR,
103
+ "while": TokenType.WHILE,
104
+ "switch": TokenType.SWITCH,
105
+ "break": TokenType.BREAK,
106
+ "continue": TokenType.CONTINUE,
107
+ "var": TokenType.VAR,
108
+ "varip": TokenType.VARIP,
109
+ "to": TokenType.TO,
110
+ "by": TokenType.BY,
111
+ "not": TokenType.NOT,
112
+ "and": TokenType.AND,
113
+ "or": TokenType.OR,
114
+ "true": TokenType.TRUE,
115
+ "false": TokenType.FALSE,
116
+ "na": TokenType.NA,
117
+ "int": TokenType.TYPE_INT,
118
+ "float": TokenType.TYPE_FLOAT,
119
+ "bool": TokenType.TYPE_BOOL,
120
+ "string": TokenType.TYPE_STRING,
121
+ }
122
+
123
+
124
+ class Tokenizer:
125
+ """Converts PineScript v6 source string into a list of tokens."""
126
+
127
+ def __init__(self, source: str) -> None:
128
+ self.source = source
129
+ self.pos = 0
130
+ self.line = 1
131
+ self.col = 1
132
+ self.tokens: list[Token] = []
133
+ self.indent_stack: list[int] = [0]
134
+ self.paren_depth = 0 # Track () and [] nesting to suppress NEWLINE/INDENT/DEDENT
135
+
136
+ def _peek(self, offset: int = 0) -> str:
137
+ idx = self.pos + offset
138
+ return self.source[idx] if idx < len(self.source) else "\0"
139
+
140
+ def _advance(self) -> str:
141
+ ch = self.source[self.pos]
142
+ self.pos += 1
143
+ if ch == "\n":
144
+ self.line += 1
145
+ self.col = 1
146
+ else:
147
+ self.col += 1
148
+ return ch
149
+
150
+ def _at_end(self) -> bool:
151
+ return self.pos >= len(self.source)
152
+
153
+ def _emit(self, tt: TokenType, value: str, line: int, col: int) -> None:
154
+ self.tokens.append(Token(tt, value, line, col))
155
+
156
+ def _skip_line(self) -> None:
157
+ while not self._at_end() and self.source[self.pos] != "\n":
158
+ self._advance()
159
+ if not self._at_end():
160
+ self._advance()
161
+
162
+ def _skip_comment(self) -> None:
163
+ while not self._at_end() and self.source[self.pos] != "\n":
164
+ self._advance()
165
+
166
+ def tokenize(self) -> list[Token]:
167
+ while not self._at_end():
168
+ self._tokenize_line()
169
+
170
+ while len(self.indent_stack) > 1:
171
+ self.indent_stack.pop()
172
+ self._emit(TokenType.DEDENT, "", self.line, self.col)
173
+
174
+ self._emit(TokenType.EOF_TOKEN, "", self.line, self.col)
175
+ return self.tokens
176
+
177
+ def _tokenize_line(self) -> None:
178
+ if self._at_end():
179
+ return
180
+
181
+ line_start = self.pos
182
+
183
+ # Peek at line content to detect blank/comment lines
184
+ temp = self.pos
185
+ while temp < len(self.source) and self.source[temp] in (" ", "\t"):
186
+ temp += 1
187
+ if temp >= len(self.source) or self.source[temp] == "\n":
188
+ self._advance_to(min(temp + 1, len(self.source)))
189
+ return
190
+ if self.source[temp: temp + 3] == "//@":
191
+ self._skip_line()
192
+ return
193
+ if self.source[temp: temp + 2] == "//":
194
+ self._skip_line()
195
+ return
196
+
197
+ # Inside parens/brackets: skip indentation handling, treat as continuation
198
+ if self.paren_depth > 0:
199
+ while not self._at_end() and self.source[self.pos] in (" ", "\t"):
200
+ self._advance()
201
+ emitted_in_parens = False
202
+ while not self._at_end() and self.source[self.pos] != "\n":
203
+ self._skip_whitespace_inline()
204
+ if self._at_end() or self.source[self.pos] == "\n":
205
+ break
206
+ if self.source[self.pos: self.pos + 2] == "//":
207
+ self._skip_comment()
208
+ break
209
+ emitted_in_parens = True
210
+ self._read_token()
211
+ if not self._at_end() and self.source[self.pos] == "\n":
212
+ self._advance()
213
+ # If parens closed on this line, emit NEWLINE so parser sees end of statement
214
+ if self.paren_depth == 0 and emitted_in_parens:
215
+ self._emit(TokenType.NEWLINE, "\\n", self.line - 1, self.col)
216
+ return
217
+
218
+ # Indentation
219
+ indent_level = 0
220
+ while not self._at_end() and self.source[self.pos] in (" ", "\t"):
221
+ ch = self._advance()
222
+ indent_level += 1
223
+
224
+ raw = self.source[line_start: self.pos]
225
+ if "\t" in raw:
226
+ indent_level = raw.count("\t")
227
+ else:
228
+ indent_level = len(raw) // 4
229
+
230
+ current_indent = self.indent_stack[-1]
231
+ if indent_level > current_indent:
232
+ self.indent_stack.append(indent_level)
233
+ self._emit(TokenType.INDENT, "", self.line, 1)
234
+ elif indent_level < current_indent:
235
+ while len(self.indent_stack) > 1 and self.indent_stack[-1] > indent_level:
236
+ self.indent_stack.pop()
237
+ self._emit(TokenType.DEDENT, "", self.line, 1)
238
+
239
+ # Tokens on this line
240
+ emitted_something = False
241
+ while not self._at_end() and self.source[self.pos] != "\n":
242
+ self._skip_whitespace_inline()
243
+ if self._at_end() or self.source[self.pos] == "\n":
244
+ break
245
+ if self.source[self.pos: self.pos + 2] == "//":
246
+ self._skip_comment()
247
+ break
248
+ emitted_something = True
249
+ self._read_token()
250
+
251
+ if not self._at_end() and self.source[self.pos] == "\n":
252
+ self._advance()
253
+
254
+ if emitted_something and self.paren_depth == 0:
255
+ self._emit(TokenType.NEWLINE, "\\n", self.line - 1, self.col)
256
+
257
+ def _advance_to(self, target: int) -> None:
258
+ while self.pos < target and self.pos < len(self.source):
259
+ self._advance()
260
+
261
+ def _skip_whitespace_inline(self) -> None:
262
+ while not self._at_end() and self.source[self.pos] in (" ", "\t"):
263
+ self._advance()
264
+
265
+ def _read_token(self) -> None:
266
+ ch = self.source[self.pos]
267
+ start_line = self.line
268
+ start_col = self.col
269
+
270
+ # Numbers
271
+ if ch.isdigit():
272
+ self._read_number(start_line, start_col)
273
+ return
274
+
275
+ # Strings
276
+ if ch == '"':
277
+ self._read_string(start_line, start_col)
278
+ return
279
+ if ch == "'":
280
+ self._read_string_single(start_line, start_col)
281
+ return
282
+
283
+ # Color literals (#rrggbb or #rrggbbaa)
284
+ if ch == "#":
285
+ self._advance() # consume #
286
+ buf = []
287
+ while not self._at_end() and (self.source[self.pos] in "0123456789abcdefABCDEF"):
288
+ buf.append(self._advance())
289
+ self._emit(TokenType.COLOR, "#" + "".join(buf), start_line, start_col)
290
+ return
291
+
292
+ # Identifiers / keywords
293
+ if ch.isalpha() or ch == "_":
294
+ self._read_ident(start_line, start_col)
295
+ return
296
+
297
+ # Two-character operators (check first)
298
+ two = self.source[self.pos: self.pos + 2] if self.pos + 1 < len(self.source) else ""
299
+ if two == ":=":
300
+ self._advance(); self._advance()
301
+ self._emit(TokenType.COLON_EQUALS, ":=", start_line, start_col)
302
+ return
303
+ if two == "==":
304
+ self._advance(); self._advance()
305
+ self._emit(TokenType.EQEQ, "==", start_line, start_col)
306
+ return
307
+ if two == "!=":
308
+ self._advance(); self._advance()
309
+ self._emit(TokenType.NOTEQ, "!=", start_line, start_col)
310
+ return
311
+ if two == ">=":
312
+ self._advance(); self._advance()
313
+ self._emit(TokenType.GE, ">=", start_line, start_col)
314
+ return
315
+ if two == "<=":
316
+ self._advance(); self._advance()
317
+ self._emit(TokenType.LE, "<=", start_line, start_col)
318
+ return
319
+ if two == "=>":
320
+ self._advance(); self._advance()
321
+ self._emit(TokenType.FAT_ARROW, "=>", start_line, start_col)
322
+ return
323
+ if two == "+=":
324
+ self._advance(); self._advance()
325
+ self._emit(TokenType.PLUS_EQUALS, "+=", start_line, start_col)
326
+ return
327
+ if two == "-=":
328
+ self._advance(); self._advance()
329
+ self._emit(TokenType.MINUS_EQUALS, "-=", start_line, start_col)
330
+ return
331
+ if two == "*=":
332
+ self._advance(); self._advance()
333
+ self._emit(TokenType.STAR_EQUALS, "*=", start_line, start_col)
334
+ return
335
+ if two == "/=":
336
+ self._advance(); self._advance()
337
+ self._emit(TokenType.SLASH_EQUALS, "/=", start_line, start_col)
338
+ return
339
+
340
+ # Single-character operators
341
+ singles = {
342
+ "(": TokenType.LPAREN,
343
+ ")": TokenType.RPAREN,
344
+ "[": TokenType.LBRACKET,
345
+ "]": TokenType.RBRACKET,
346
+ ",": TokenType.COMMA,
347
+ ".": TokenType.DOT,
348
+ "=": TokenType.EQUALS,
349
+ "+": TokenType.PLUS,
350
+ "-": TokenType.MINUS,
351
+ "*": TokenType.STAR,
352
+ "/": TokenType.SLASH,
353
+ "%": TokenType.PERCENT,
354
+ ">": TokenType.GT,
355
+ "<": TokenType.LT,
356
+ "?": TokenType.QUESTION,
357
+ ":": TokenType.COLON,
358
+ }
359
+ if ch in singles:
360
+ self._advance()
361
+ tt = singles[ch]
362
+ if tt in (TokenType.LPAREN, TokenType.LBRACKET):
363
+ self.paren_depth += 1
364
+ elif tt in (TokenType.RPAREN, TokenType.RBRACKET):
365
+ self.paren_depth = max(0, self.paren_depth - 1)
366
+ self._emit(tt, ch, start_line, start_col)
367
+ return
368
+
369
+ # Unknown — skip
370
+ self._advance()
371
+
372
+ def _read_number(self, start_line: int, start_col: int) -> None:
373
+ buf = []
374
+ while not self._at_end() and (self.source[self.pos].isdigit() or self.source[self.pos] == "."):
375
+ buf.append(self._advance())
376
+ self._emit(TokenType.NUMBER, "".join(buf), start_line, start_col)
377
+
378
+ def _read_string(self, start_line: int, start_col: int) -> None:
379
+ self._advance() # consume "
380
+ buf = []
381
+ while not self._at_end() and self.source[self.pos] != '"':
382
+ if self.source[self.pos] == "\\" and self.pos + 1 < len(self.source):
383
+ self._advance() # skip backslash
384
+ buf.append(self._advance())
385
+ if not self._at_end():
386
+ self._advance() # consume closing "
387
+ self._emit(TokenType.STRING, "".join(buf), start_line, start_col)
388
+
389
+ def _read_string_single(self, start_line: int, start_col: int) -> None:
390
+ self._advance() # consume '
391
+ buf = []
392
+ while not self._at_end() and self.source[self.pos] != "'":
393
+ if self.source[self.pos] == "\\" and self.pos + 1 < len(self.source):
394
+ self._advance()
395
+ buf.append(self._advance())
396
+ if not self._at_end():
397
+ self._advance()
398
+ self._emit(TokenType.STRING, "".join(buf), start_line, start_col)
399
+
400
+ def _read_ident(self, start_line: int, start_col: int) -> None:
401
+ buf = []
402
+ while not self._at_end() and (self.source[self.pos].isalnum() or self.source[self.pos] == "_"):
403
+ buf.append(self._advance())
404
+ word = "".join(buf)
405
+ tt = KEYWORDS.get(word, TokenType.IDENT)
406
+ self._emit(tt, word, start_line, start_col)
@@ -0,0 +1,86 @@
1
+ """TradingView-aligned choice sets for `input.*` (compile-time metadata).
2
+
3
+ PineForge does not render the TradingView Settings UI; these frozensets document
4
+ the same **identifier** choices TV offers in common dropdowns so the analyzer
5
+ can validate **const** `defval` / `options` expressions with warnings (not hard
6
+ errors), matching TV’s intent without a runtime UI.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+
13
+ # Built-in OHLCV and common derived series (typical "Source" dropdown entries).
14
+ INPUT_SOURCE_SERIES_IDS: frozenset[str] = frozenset({
15
+ "open",
16
+ "high",
17
+ "low",
18
+ "close",
19
+ "volume",
20
+ "hl2",
21
+ "hlc3",
22
+ "ohlc4",
23
+ "hlcc4",
24
+ })
25
+
26
+ # Common Pine timeframe strings (chart + typical dropdown values).
27
+ # See: https://www.tradingview.com/pine-script-reference/v6/#fun_timeframe
28
+ _INPUT_TF_NUMERIC = frozenset(
29
+ str(m) for m in (
30
+ 1, 2, 3, 4, 5, 10, 15, 30, 45, 60, 90, 120, 180, 240, 360, 720,
31
+ )
32
+ )
33
+ _INPUT_TF_SEC = frozenset(f"{s}S" for s in (1, 5, 15, 30, 45))
34
+ _INPUT_TF_DAY = frozenset(("1D", "2D", "3D", "D"))
35
+ _INPUT_TF_WEEK = frozenset(("1W", "2W", "3W", "W"))
36
+ _INPUT_TF_MONTH = frozenset(("1M", "2M", "3M", "6M", "12M", "M"))
37
+
38
+ INPUT_TIMEFRAME_CHOICES: frozenset[str] = (
39
+ frozenset({""})
40
+ | _INPUT_TF_NUMERIC
41
+ | _INPUT_TF_SEC
42
+ | _INPUT_TF_DAY
43
+ | _INPUT_TF_WEEK
44
+ | _INPUT_TF_MONTH
45
+ )
46
+
47
+ # Loose pattern for valid Pine timeframe literals beyond the canonical set above
48
+ # (e.g. custom minute counts).
49
+ _TIMEFRAME_RE = re.compile(
50
+ r"^(?:"
51
+ r"\s*|" # chart
52
+ r"[0-9]+S?|" # minutes or N-second bars
53
+ r"[0-9]+[DWM]|" # e.g. 2D, 3W
54
+ r"[DWM]" # bare D / W / M
55
+ r")$"
56
+ )
57
+
58
+
59
+ def is_valid_timeframe_string(s: str) -> bool:
60
+ """Return True if *s* is a plausible Pine timeframe literal."""
61
+ if s in INPUT_TIMEFRAME_CHOICES:
62
+ return True
63
+ return bool(_TIMEFRAME_RE.match(s))
64
+
65
+
66
+ # Typical session presets (informational; many custom strings are valid).
67
+ INPUT_SESSION_PRESETS: frozenset[str] = frozenset({
68
+ "",
69
+ "24x7",
70
+ "0000-2359",
71
+ })
72
+
73
+ # HHMM-HHMM Mon-Fri style (same idea as TV session strings).
74
+ _SESSION_RANGE_RE = re.compile(r"^\d{4}-\d{4}$")
75
+
76
+
77
+ def is_plausible_session_string(s: str) -> bool:
78
+ """Return True if *s* looks like a valid `input.session` defval."""
79
+ if s in INPUT_SESSION_PRESETS:
80
+ return True
81
+ if _SESSION_RANGE_RE.match(s):
82
+ return True
83
+ # e.g. "0930-1600:23456" with weekday flags
84
+ if re.match(r"^\d{4}-\d{4}(:[0-9]{7})?$", s):
85
+ return True
86
+ return False