@pineforge/codegen-pyodide 0.10.4 → 1.0.0
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.
- package/README.md +16 -16
- package/glue.py +24 -16
- package/package.json +1 -1
- package/pineforge_codegen/__init__.py +125 -34
- package/pineforge_codegen/analyzer/__init__.py +2 -0
- package/pineforge_codegen/analyzer/base.py +754 -76
- package/pineforge_codegen/analyzer/call_handlers.py +260 -40
- package/pineforge_codegen/analyzer/contracts.py +37 -0
- package/pineforge_codegen/analyzer/diagnostics.py +30 -4
- package/pineforge_codegen/analyzer/tables.py +49 -8
- package/pineforge_codegen/analyzer/types.py +33 -1
- package/pineforge_codegen/ast_nodes.py +32 -1
- package/pineforge_codegen/block_locals.py +185 -0
- package/pineforge_codegen/builtin_keywords.py +42 -0
- package/pineforge_codegen/codegen/base.py +896 -156
- package/pineforge_codegen/codegen/constant_fold.py +131 -0
- package/pineforge_codegen/codegen/drawing.py +221 -79
- package/pineforge_codegen/codegen/emit_top.py +946 -213
- package/pineforge_codegen/codegen/helpers.py +435 -14
- package/pineforge_codegen/codegen/host_members.py +162 -0
- package/pineforge_codegen/codegen/input.py +252 -85
- package/pineforge_codegen/codegen/security.py +4372 -377
- package/pineforge_codegen/codegen/session_market.py +71 -0
- package/pineforge_codegen/codegen/ta.py +1188 -100
- package/pineforge_codegen/codegen/tables.py +193 -71
- package/pineforge_codegen/codegen/tv_number_format.py +270 -0
- package/pineforge_codegen/codegen/types.py +1882 -78
- package/pineforge_codegen/codegen/visit_call.py +920 -131
- package/pineforge_codegen/codegen/visit_expr.py +738 -57
- package/pineforge_codegen/codegen/visit_stmt.py +595 -49
- package/pineforge_codegen/external_requests.py +877 -0
- package/pineforge_codegen/lexer.py +104 -22
- package/pineforge_codegen/library_inline.py +1304 -0
- package/pineforge_codegen/library_modules.py +126 -0
- package/pineforge_codegen/library_v5.py +683 -0
- package/pineforge_codegen/limits.py +138 -0
- package/pineforge_codegen/method_binding.py +33 -0
- package/pineforge_codegen/parser.py +384 -68
- package/pineforge_codegen/pine_libraries.py +266 -0
- package/pineforge_codegen/pine_spelling.py +216 -0
- package/pineforge_codegen/pragmas.py +64 -10
- package/pineforge_codegen/security_contexts.py +1585 -0
- package/pineforge_codegen/session_reads.py +84 -0
- package/pineforge_codegen/signatures.py +48 -23
- package/pineforge_codegen/support_checker.py +1106 -85
- package/pineforge_codegen-1.0.0.tar.gz +0 -0
- package/release.json +2 -2
- package/tables.json +23 -21
- package/transpile.worker.mjs +24 -16
- package/pineforge_codegen-0.10.4.tar.gz +0 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Where the sources of a script's imported Pine libraries come from.
|
|
2
|
+
|
|
3
|
+
``transpile(source, libraries=...)`` takes them from the caller: a mapping of
|
|
4
|
+
import path (``user/name/version``) to library source text. With
|
|
5
|
+
``libraries=None`` they come from the environment the campaign's case runner
|
|
6
|
+
sets (pineforge-workflow ``docs/xsym-requests.md``, "The environment
|
|
7
|
+
contract"):
|
|
8
|
+
|
|
9
|
+
- ``PINEFORGE_PINE_LIBRARIES=<dir>``: ``<dir>/libraries.json``
|
|
10
|
+
(``pineforge-pine-libraries/v1``) and ``<dir>/<user>/<name>/<version>.pine``,
|
|
11
|
+
the bytes pine-facade served. The directory is case-wide: it holds every
|
|
12
|
+
library any probe of the case pins.
|
|
13
|
+
- ``PINEFORGE_REQUESTS_ROOT=<dir>``: one ``<slug>/requests.json``
|
|
14
|
+
(``pineforge-probe-requests/v1``) per probe of the case that pins data, with
|
|
15
|
+
every file it references under ``<slug>/files/<sha256>``.
|
|
16
|
+
|
|
17
|
+
A script's imports resolve only through its own manifest, never through the
|
|
18
|
+
case-wide directory alone: otherwise a probe that pins no library would
|
|
19
|
+
transpile on a shard neighbour's pin, and measure differently by case
|
|
20
|
+
composition. ``transpile()`` is handed the source text, not a slug (the frozen
|
|
21
|
+
verifier, pineforge-lab 3bac0b7b ``scripts/verify-engine-local.py:1698``, calls
|
|
22
|
+
``transpile((d / "strategy.pine").read_text())``), so the manifest is found by
|
|
23
|
+
content: the one ``requests.json`` whose ``probe.strategySha256`` is the sha256
|
|
24
|
+
of the script. That sha is of the strategy file's bytes, and ``read_text()``
|
|
25
|
+
folds ``\\r\\n`` and ``\\r`` line ends to ``\\n``: the text matches a manifest
|
|
26
|
+
when its UTF-8 bytes do, or the same bytes with every ``\\n`` spelled
|
|
27
|
+
``\\r\\n``, or ``\\r``. A file mixing line ends matches none. No manifest, or
|
|
28
|
+
more than one, resolves no library: every import keeps its refusal. An import
|
|
29
|
+
the manifest does not pin is refused by name whatever ``libraries.json`` lists,
|
|
30
|
+
and each source is verified against the manifest entry's ``sha256``.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import hashlib
|
|
36
|
+
import json
|
|
37
|
+
import os
|
|
38
|
+
import re
|
|
39
|
+
from collections.abc import Mapping
|
|
40
|
+
from dataclasses import dataclass
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
|
|
43
|
+
LIBRARIES_ENV = "PINEFORGE_PINE_LIBRARIES"
|
|
44
|
+
REQUESTS_ENV = "PINEFORGE_REQUESTS_ROOT"
|
|
45
|
+
LIBRARIES_SCHEMA = "pineforge-pine-libraries/v1"
|
|
46
|
+
REQUESTS_SCHEMA = "pineforge-probe-requests/v1"
|
|
47
|
+
|
|
48
|
+
_SHA256_RE = re.compile(r"[0-9a-f]{64}")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class LibraryResolveError(Exception):
|
|
52
|
+
"""An import's library source cannot be used; the message names it."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class LibrarySource:
|
|
57
|
+
path: str
|
|
58
|
+
text: str
|
|
59
|
+
sha256: str | None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def source_digests(text: str) -> set[str]:
|
|
63
|
+
"""sha256 of every byte form ``text`` can have been read from with
|
|
64
|
+
``Path.read_text()``: its own UTF-8 bytes and, for a text holding no
|
|
65
|
+
``\\r``, the same bytes with CRLF or CR line ends."""
|
|
66
|
+
forms = [text]
|
|
67
|
+
if "\r" not in text and "\n" in text:
|
|
68
|
+
forms += [text.replace("\n", "\r\n"), text.replace("\n", "\r")]
|
|
69
|
+
digests = set()
|
|
70
|
+
for form in forms:
|
|
71
|
+
try:
|
|
72
|
+
data = form.encode("utf-8", "surrogateescape")
|
|
73
|
+
except UnicodeEncodeError:
|
|
74
|
+
continue
|
|
75
|
+
digests.add(hashlib.sha256(data).hexdigest())
|
|
76
|
+
return digests
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class LibraryResolver:
|
|
80
|
+
"""The library sources one transpile may read.
|
|
81
|
+
|
|
82
|
+
``configured`` is False when neither ``libraries=`` nor a requests
|
|
83
|
+
manifest of the script applies; every import then keeps today's
|
|
84
|
+
refusal, with ``reason`` (if any) saying why none applied.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
configured = True
|
|
88
|
+
reason: str | None = None
|
|
89
|
+
|
|
90
|
+
def pinned(self, path: str) -> bool:
|
|
91
|
+
raise NotImplementedError
|
|
92
|
+
|
|
93
|
+
def load(self, path: str) -> LibrarySource:
|
|
94
|
+
raise NotImplementedError
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class _Unconfigured(LibraryResolver):
|
|
98
|
+
configured = False
|
|
99
|
+
|
|
100
|
+
def __init__(self, reason: str | None = None) -> None:
|
|
101
|
+
self.reason = reason
|
|
102
|
+
|
|
103
|
+
def pinned(self, path: str) -> bool:
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
def load(self, path: str) -> LibrarySource:
|
|
107
|
+
raise LibraryResolveError(self.reason or "no library sources are configured")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class _Provided(LibraryResolver):
|
|
111
|
+
"""``transpile(..., libraries={path: source})``: the caller's sources."""
|
|
112
|
+
|
|
113
|
+
def __init__(self, sources: Mapping[str, str]) -> None:
|
|
114
|
+
self._sources: dict[str, str] = {}
|
|
115
|
+
for path, text in sources.items():
|
|
116
|
+
if isinstance(text, (bytes, bytearray)):
|
|
117
|
+
text = bytes(text).decode("utf-8")
|
|
118
|
+
if not isinstance(text, str):
|
|
119
|
+
raise TypeError(
|
|
120
|
+
f"libraries[{path!r}] must be the library's source text")
|
|
121
|
+
self._sources[str(path)] = text
|
|
122
|
+
|
|
123
|
+
def pinned(self, path: str) -> bool:
|
|
124
|
+
return path in self._sources
|
|
125
|
+
|
|
126
|
+
def load(self, path: str) -> LibrarySource:
|
|
127
|
+
if path not in self._sources:
|
|
128
|
+
raise LibraryResolveError(
|
|
129
|
+
f"library '{path}' is not among the libraries passed to transpile()")
|
|
130
|
+
text = self._sources[path]
|
|
131
|
+
return LibrarySource(path, text, None)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class _Pinned(LibraryResolver):
|
|
135
|
+
"""The libraries one probe's requests manifest pins."""
|
|
136
|
+
|
|
137
|
+
def __init__(self, library_dir: Path, requests_root: Path, slug_dir: Path,
|
|
138
|
+
manifest: dict) -> None:
|
|
139
|
+
self._library_dir = library_dir
|
|
140
|
+
self._requests_root = requests_root
|
|
141
|
+
self._slug_dir = slug_dir
|
|
142
|
+
self._where = f"{slug_dir.name}/requests.json"
|
|
143
|
+
self._pins: dict[str, dict] = {}
|
|
144
|
+
for entry in manifest.get("libraries") or []:
|
|
145
|
+
if isinstance(entry, dict) and isinstance(entry.get("import"), str):
|
|
146
|
+
self._pins.setdefault(entry["import"], entry)
|
|
147
|
+
self._index: dict | None = None
|
|
148
|
+
|
|
149
|
+
def pinned(self, path: str) -> bool:
|
|
150
|
+
return path in self._pins
|
|
151
|
+
|
|
152
|
+
def _library_index(self) -> dict:
|
|
153
|
+
if self._index is None:
|
|
154
|
+
self._index = {}
|
|
155
|
+
try:
|
|
156
|
+
doc = json.loads((self._library_dir / "libraries.json")
|
|
157
|
+
.read_text(encoding="utf-8"))
|
|
158
|
+
except (OSError, ValueError):
|
|
159
|
+
doc = None
|
|
160
|
+
if (isinstance(doc, dict)
|
|
161
|
+
and doc.get("schemaVersion", LIBRARIES_SCHEMA) == LIBRARIES_SCHEMA
|
|
162
|
+
and isinstance(doc.get("libraries"), dict)):
|
|
163
|
+
self._index = {k: v for k, v in doc["libraries"].items()
|
|
164
|
+
if isinstance(v, dict)}
|
|
165
|
+
return self._index
|
|
166
|
+
|
|
167
|
+
def load(self, path: str) -> LibrarySource:
|
|
168
|
+
entry = self._pins.get(path)
|
|
169
|
+
if entry is None:
|
|
170
|
+
raise LibraryResolveError(
|
|
171
|
+
f"library '{path}' is not pinned by this script's requests "
|
|
172
|
+
f"manifest ({self._where})")
|
|
173
|
+
access = entry.get("access")
|
|
174
|
+
if not (isinstance(access, str) and access.startswith("open")):
|
|
175
|
+
raise LibraryResolveError(
|
|
176
|
+
f"library '{path}' is not open-source (access {access!r}); "
|
|
177
|
+
"PineForge inlines open libraries only")
|
|
178
|
+
sha = entry.get("sha256")
|
|
179
|
+
if not (isinstance(sha, str) and _SHA256_RE.fullmatch(sha)):
|
|
180
|
+
raise LibraryResolveError(
|
|
181
|
+
f"library '{path}' has no valid sha256 in {self._where}")
|
|
182
|
+
indexed = self._library_index().get(path) or {}
|
|
183
|
+
indexed_access = indexed.get("access")
|
|
184
|
+
if indexed_access is not None and not (
|
|
185
|
+
isinstance(indexed_access, str) and indexed_access.startswith("open")):
|
|
186
|
+
raise LibraryResolveError(
|
|
187
|
+
f"library '{path}' is not open-source (access {indexed_access!r} "
|
|
188
|
+
"in libraries.json); PineForge inlines open libraries only")
|
|
189
|
+
candidates = [self._slug_dir / "files" / sha]
|
|
190
|
+
rel = indexed.get("file")
|
|
191
|
+
if isinstance(rel, str) and rel and not Path(rel).is_absolute() \
|
|
192
|
+
and ".." not in Path(rel).parts:
|
|
193
|
+
candidates.append(self._library_dir / rel)
|
|
194
|
+
candidates.append(self._library_dir / f"{path}.pine")
|
|
195
|
+
seen: list[str] = []
|
|
196
|
+
for candidate in dict.fromkeys(candidates):
|
|
197
|
+
try:
|
|
198
|
+
data = candidate.read_bytes()
|
|
199
|
+
except OSError:
|
|
200
|
+
continue
|
|
201
|
+
got = hashlib.sha256(data).hexdigest()
|
|
202
|
+
if got != sha:
|
|
203
|
+
seen.append(got)
|
|
204
|
+
continue
|
|
205
|
+
try:
|
|
206
|
+
text = data.decode("utf-8")
|
|
207
|
+
except UnicodeDecodeError as exc:
|
|
208
|
+
raise LibraryResolveError(
|
|
209
|
+
f"library '{path}' source is not UTF-8 text") from exc
|
|
210
|
+
return LibrarySource(path, text, sha)
|
|
211
|
+
if seen:
|
|
212
|
+
raise LibraryResolveError(
|
|
213
|
+
f"library '{path}' source does not match its pinned sha256 "
|
|
214
|
+
f"{sha[:12]}... (found {seen[0][:12]}...)")
|
|
215
|
+
raise LibraryResolveError(
|
|
216
|
+
f"library '{path}' source is missing (sha256 {sha[:12]}... under "
|
|
217
|
+
f"${REQUESTS_ENV}/{self._slug_dir.name}/files and ${LIBRARIES_ENV})")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _manifest_for(text: str, requests_root: Path) -> tuple[Path | None, dict | None, str]:
|
|
221
|
+
"""The one requests manifest under ``requests_root`` pinning ``text``."""
|
|
222
|
+
digests = source_digests(text)
|
|
223
|
+
matches: list[tuple[Path, dict]] = []
|
|
224
|
+
try:
|
|
225
|
+
candidates = sorted(requests_root.glob("*/requests.json"))
|
|
226
|
+
except OSError:
|
|
227
|
+
candidates = []
|
|
228
|
+
for path in candidates:
|
|
229
|
+
try:
|
|
230
|
+
doc = json.loads(path.read_text(encoding="utf-8"))
|
|
231
|
+
except (OSError, ValueError):
|
|
232
|
+
continue
|
|
233
|
+
if not isinstance(doc, dict) or doc.get("schemaVersion") != REQUESTS_SCHEMA:
|
|
234
|
+
continue
|
|
235
|
+
probe = doc.get("probe")
|
|
236
|
+
sha = probe.get("strategySha256") if isinstance(probe, dict) else None
|
|
237
|
+
if isinstance(sha, str) and sha.lower() in digests:
|
|
238
|
+
matches.append((path.parent, doc))
|
|
239
|
+
if len(matches) == 1:
|
|
240
|
+
return matches[0][0], matches[0][1], ""
|
|
241
|
+
if not matches:
|
|
242
|
+
return None, None, (
|
|
243
|
+
f"no requests manifest under ${REQUESTS_ENV} pins this script's "
|
|
244
|
+
"source")
|
|
245
|
+
slugs = ", ".join(sorted(p.name for p, _ in matches))
|
|
246
|
+
return None, None, (
|
|
247
|
+
f"{len(matches)} requests manifests under ${REQUESTS_ENV} pin this "
|
|
248
|
+
f"script's source ({slugs})")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def library_resolver(source: str, libraries: Mapping[str, str] | None) -> LibraryResolver:
|
|
252
|
+
"""The library sources ``transpile(source, libraries=libraries)`` reads."""
|
|
253
|
+
if libraries is not None:
|
|
254
|
+
return _Provided(libraries)
|
|
255
|
+
library_dir = os.environ.get(LIBRARIES_ENV)
|
|
256
|
+
if not library_dir:
|
|
257
|
+
return _Unconfigured()
|
|
258
|
+
requests_root = os.environ.get(REQUESTS_ENV)
|
|
259
|
+
if not requests_root:
|
|
260
|
+
return _Unconfigured(
|
|
261
|
+
f"${LIBRARIES_ENV} is set but ${REQUESTS_ENV} is not, so no "
|
|
262
|
+
"requests manifest pins this script's libraries")
|
|
263
|
+
slug_dir, manifest, reason = _manifest_for(source, Path(requests_root))
|
|
264
|
+
if manifest is None:
|
|
265
|
+
return _Unconfigured(reason)
|
|
266
|
+
return _Pinned(Path(library_dir), Path(requests_root), slug_dir, manifest)
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Pine source spellings that the analyzer hands the codegen as strings.
|
|
2
|
+
|
|
3
|
+
TA constructor arguments (``TACallSite.ctor_args``), user-function call-site
|
|
4
|
+
arguments and class-scope derived lengths travel as Pine source text that the
|
|
5
|
+
codegen scans for identifiers, substitutes into, folds and re-parses. An
|
|
6
|
+
inline ``input.*()`` call is a legitimate leaf of such an expression
|
|
7
|
+
(``ta.ema(close, input.int(9, "fast"))``), and its own argument text -- the
|
|
8
|
+
title string, keyword names, an ``options`` list -- is input metadata, not
|
|
9
|
+
part of the expression. These helpers keep string literals intact and let a
|
|
10
|
+
caller treat each inline input call as one leaf.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import dataclasses
|
|
16
|
+
import re
|
|
17
|
+
from typing import Callable, Iterator
|
|
18
|
+
|
|
19
|
+
from .ast_nodes import (
|
|
20
|
+
ASTNode, BinOp, BoolLiteral, FuncCall, FuncDef, Identifier, MemberAccess,
|
|
21
|
+
MethodDef, NaLiteral, NumberLiteral, StringLiteral, Subscript, Ternary,
|
|
22
|
+
TupleLiteral, UnaryOp, VarDecl,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
_STRING_OR_IDENT = re.compile(
|
|
26
|
+
r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'|[A-Za-z_][A-Za-z_0-9]*'
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def is_input_call(node) -> bool:
|
|
31
|
+
"""``input(...)`` or ``input.<type>(...)``, judged on the spelling alone."""
|
|
32
|
+
if not isinstance(node, FuncCall):
|
|
33
|
+
return False
|
|
34
|
+
callee = node.callee
|
|
35
|
+
if isinstance(callee, Identifier):
|
|
36
|
+
return callee.name == "input"
|
|
37
|
+
return (isinstance(callee, MemberAccess)
|
|
38
|
+
and isinstance(callee.object, Identifier)
|
|
39
|
+
and callee.object.name == "input")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def pine_string_literal(value: str) -> str:
|
|
43
|
+
"""The double-quoted literal the lexer reads back as ``value``."""
|
|
44
|
+
return '"' + (value.replace("\\", "\\\\").replace('"', '\\"')
|
|
45
|
+
.replace("\n", "\\n").replace("\t", "\\t")) + '"'
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def spell_input_call(node: FuncCall, title: str | None = None) -> str | None:
|
|
49
|
+
"""An input call spelled with every argument, keywords included, so the
|
|
50
|
+
text re-parses to the same call. None when an argument is not one of the
|
|
51
|
+
constant shapes an input takes (literal, name, ``display.none``-style
|
|
52
|
+
member, signed number, ``options`` list).
|
|
53
|
+
|
|
54
|
+
``title`` spells an untitled call's key as its ``title=`` argument: the
|
|
55
|
+
re-parsed call has lost the declaration that named it."""
|
|
56
|
+
parts = [_spell_input_arg(a) for a in node.args]
|
|
57
|
+
parts += [None if (v := _spell_input_arg(value)) is None else f"{key}={v}"
|
|
58
|
+
for key, value in node.kwargs.items()]
|
|
59
|
+
if title is not None:
|
|
60
|
+
parts.append(f"title={pine_string_literal(title)}")
|
|
61
|
+
callee = _spell_input_arg(node.callee)
|
|
62
|
+
if callee is None or None in parts:
|
|
63
|
+
return None
|
|
64
|
+
return f"{callee}({', '.join(parts)})"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _spell_input_arg(node) -> str | None:
|
|
68
|
+
if isinstance(node, NumberLiteral):
|
|
69
|
+
return str(node.value)
|
|
70
|
+
if isinstance(node, StringLiteral):
|
|
71
|
+
return pine_string_literal(node.value)
|
|
72
|
+
if isinstance(node, BoolLiteral):
|
|
73
|
+
return "true" if node.value else "false"
|
|
74
|
+
if isinstance(node, NaLiteral):
|
|
75
|
+
return "na"
|
|
76
|
+
if isinstance(node, Identifier):
|
|
77
|
+
return node.name
|
|
78
|
+
if isinstance(node, MemberAccess):
|
|
79
|
+
obj = _spell_input_arg(node.object)
|
|
80
|
+
return None if obj is None else f"{obj}.{node.member}"
|
|
81
|
+
if isinstance(node, UnaryOp) and node.op in ("-", "+"):
|
|
82
|
+
operand = _spell_input_arg(node.operand)
|
|
83
|
+
return None if operand is None else f"{node.op}{operand}"
|
|
84
|
+
if isinstance(node, TupleLiteral):
|
|
85
|
+
elems = [_spell_input_arg(e) for e in node.elements]
|
|
86
|
+
return None if None in elems else "[" + ", ".join(elems) + "]"
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def expr_start(node: ASTNode) -> ASTNode:
|
|
91
|
+
"""The leftmost sub-node of an expression, whose location is the
|
|
92
|
+
expression's first token (a call's own location is its ``(``)."""
|
|
93
|
+
while True:
|
|
94
|
+
if isinstance(node, FuncCall):
|
|
95
|
+
child = node.callee
|
|
96
|
+
elif isinstance(node, (MemberAccess, Subscript)):
|
|
97
|
+
child = node.object
|
|
98
|
+
elif isinstance(node, BinOp):
|
|
99
|
+
child = node.left
|
|
100
|
+
elif isinstance(node, Ternary):
|
|
101
|
+
child = node.condition
|
|
102
|
+
else:
|
|
103
|
+
return node
|
|
104
|
+
if getattr(child, "loc", None) is None:
|
|
105
|
+
return node
|
|
106
|
+
node = child
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# Statement fields holding a local block. Pine declares script inputs at
|
|
110
|
+
# global scope only.
|
|
111
|
+
_LOCAL_SCOPE_FIELDS = frozenset({"body", "else_body", "default_body"})
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def global_input_calls(node) -> Iterator[FuncCall]:
|
|
115
|
+
"""The input calls ``node`` makes at global scope, in source order -- not
|
|
116
|
+
inside if/for/while/switch blocks or callable bodies."""
|
|
117
|
+
if isinstance(node, list):
|
|
118
|
+
for item in node:
|
|
119
|
+
yield from global_input_calls(item)
|
|
120
|
+
return
|
|
121
|
+
if not isinstance(node, ASTNode) or isinstance(node, (FuncDef, MethodDef)):
|
|
122
|
+
return
|
|
123
|
+
if is_input_call(node):
|
|
124
|
+
yield node
|
|
125
|
+
return
|
|
126
|
+
for f in dataclasses.fields(node):
|
|
127
|
+
if f.name in ("loc", "annotations") or f.name in _LOCAL_SCOPE_FIELDS:
|
|
128
|
+
continue
|
|
129
|
+
value = getattr(node, f.name)
|
|
130
|
+
if f.name == "cases":
|
|
131
|
+
value = [case_expr for case_expr, _stmts in value]
|
|
132
|
+
elif isinstance(value, dict):
|
|
133
|
+
value = list(value.values())
|
|
134
|
+
yield from global_input_calls(value)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def input_binding_names(body) -> dict[int, str]:
|
|
138
|
+
"""``id(call) -> name`` for every global-scope input call a declaration
|
|
139
|
+
holds: bound straight to ``name = input.*()`` or nested anywhere in the
|
|
140
|
+
declaration's value (``n = input.int(9) * 2``, ``x = ta.ema(close,
|
|
141
|
+
input.int(9))``, a ``var`` initializer). TradingView keys an input with no
|
|
142
|
+
title by that variable name ("If not specified, the variable name is used
|
|
143
|
+
as the input's title"), and so does every PineForge getter and the
|
|
144
|
+
manifest."""
|
|
145
|
+
names: dict[int, str] = {}
|
|
146
|
+
for stmt in body or []:
|
|
147
|
+
if not isinstance(stmt, VarDecl):
|
|
148
|
+
continue
|
|
149
|
+
for node in global_input_calls(stmt):
|
|
150
|
+
names[id(node)] = stmt.name
|
|
151
|
+
return names
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def blank_string_literals(text: str) -> str:
|
|
155
|
+
"""``text`` with every string literal emptied, so an identifier scan
|
|
156
|
+
never reads a literal's contents (``mode == "Fast"``) as a name."""
|
|
157
|
+
def _one(match: re.Match) -> str:
|
|
158
|
+
token = match.group(0)
|
|
159
|
+
return token[0] * 2 if token[0] in "\"'" else token
|
|
160
|
+
return _STRING_OR_IDENT.sub(_one, text)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def sub_identifiers(text: str, repl: Callable[[re.Match], str]) -> str:
|
|
164
|
+
"""``re.sub`` over identifier tokens only; string literals pass through."""
|
|
165
|
+
def _one(match: re.Match) -> str:
|
|
166
|
+
if match.group(0)[0] in "\"'":
|
|
167
|
+
return match.group(0)
|
|
168
|
+
return repl(match)
|
|
169
|
+
return _STRING_OR_IDENT.sub(_one, text)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def input_call_spans(text: str) -> list[tuple[int, int]]:
|
|
173
|
+
"""``(start, end)`` of each ``input(...)`` / ``input.<type>(...)`` call in
|
|
174
|
+
``text``. Parentheses inside string literals do not count, and a call
|
|
175
|
+
nested in another input call's arguments is part of the outer span."""
|
|
176
|
+
spans: list[tuple[int, int]] = []
|
|
177
|
+
if "input" not in text:
|
|
178
|
+
return spans
|
|
179
|
+
for match in _STRING_OR_IDENT.finditer(text):
|
|
180
|
+
start = match.start()
|
|
181
|
+
if match.group(0) != "input" or (spans and start < spans[-1][1]):
|
|
182
|
+
continue
|
|
183
|
+
if start > 0 and text[start - 1] == ".":
|
|
184
|
+
continue # a member named ``input``, not the namespace
|
|
185
|
+
pos = match.end()
|
|
186
|
+
member = re.match(r"\.[A-Za-z_][A-Za-z_0-9]*", text[pos:])
|
|
187
|
+
if member is not None:
|
|
188
|
+
pos += member.end()
|
|
189
|
+
if pos >= len(text) or text[pos] != "(":
|
|
190
|
+
continue
|
|
191
|
+
end = _matching_paren(text, pos)
|
|
192
|
+
if end is not None:
|
|
193
|
+
spans.append((start, end))
|
|
194
|
+
return spans
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _matching_paren(text: str, open_pos: int) -> int | None:
|
|
198
|
+
"""Index just past the ``)`` closing the ``(`` at ``open_pos``."""
|
|
199
|
+
depth = 0
|
|
200
|
+
pos = open_pos
|
|
201
|
+
while pos < len(text):
|
|
202
|
+
ch = text[pos]
|
|
203
|
+
if ch in "\"'":
|
|
204
|
+
literal = _STRING_OR_IDENT.match(text, pos)
|
|
205
|
+
if literal is None:
|
|
206
|
+
return None # unterminated string literal
|
|
207
|
+
pos = literal.end()
|
|
208
|
+
continue
|
|
209
|
+
if ch == "(":
|
|
210
|
+
depth += 1
|
|
211
|
+
elif ch == ")":
|
|
212
|
+
depth -= 1
|
|
213
|
+
if depth == 0:
|
|
214
|
+
return pos + 1
|
|
215
|
+
pos += 1
|
|
216
|
+
return None
|
|
@@ -14,7 +14,10 @@ Why a pre-pass?
|
|
|
14
14
|
:class:`Parser` machinery used for normal Pine expressions. This
|
|
15
15
|
keeps a single source of truth for Pine syntax and ensures pragma
|
|
16
16
|
expressions support the full grammar (logical operators, member
|
|
17
|
-
access, function calls, ternaries, ...).
|
|
17
|
+
access, function calls, ternaries, ...). A ``ta.*`` call inside a
|
|
18
|
+
pragma is not computed, though: no TA state is allocated for it, so
|
|
19
|
+
the codegen renders it as ``na`` with an ``/* unsupported */`` marker.
|
|
20
|
+
Trace a script variable that holds the ``ta.*`` value instead.
|
|
18
21
|
|
|
19
22
|
Pragma syntax (kept deliberately strict so unrelated comments are
|
|
20
23
|
untouched)::
|
|
@@ -40,12 +43,14 @@ import re
|
|
|
40
43
|
from dataclasses import dataclass
|
|
41
44
|
from typing import Any
|
|
42
45
|
|
|
46
|
+
from .errors import CompileError, SourceLocation
|
|
43
47
|
from .lexer import Lexer
|
|
44
|
-
from .
|
|
48
|
+
from .limits import TimeBudget, check_ast_depth
|
|
49
|
+
from .parser import ParseError, Parser
|
|
45
50
|
|
|
46
51
|
|
|
47
|
-
# Anchored to start/end of line
|
|
48
|
-
#
|
|
52
|
+
# Anchored to start/end of line; lexical string spans are excluded below.
|
|
53
|
+
# ``\s+`` after
|
|
49
54
|
# ``//`` requires at least one space before ``@pf-trace`` (the spec is
|
|
50
55
|
# ``// @pf-trace ``, distinct from Pine's ``//@version=N``).
|
|
51
56
|
_PRAGMA_RE = re.compile(
|
|
@@ -53,6 +58,27 @@ _PRAGMA_RE = re.compile(
|
|
|
53
58
|
)
|
|
54
59
|
|
|
55
60
|
|
|
61
|
+
class _StringSpanLexer(Lexer):
|
|
62
|
+
"""Use the Pine lexer itself to locate lines inside string literals."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, source: str, filename: str = "<input>",
|
|
65
|
+
budget: TimeBudget | None = None) -> None:
|
|
66
|
+
super().__init__(source, filename=filename, budget=budget)
|
|
67
|
+
self.string_lines: set[int] = set()
|
|
68
|
+
|
|
69
|
+
def _record_string_lines(self, start_line: int) -> None:
|
|
70
|
+
if self.line > start_line:
|
|
71
|
+
self.string_lines.update(range(start_line + 1, self.line + 1))
|
|
72
|
+
|
|
73
|
+
def _read_multiline(self, quote: str, start_line: int, start_col: int) -> None:
|
|
74
|
+
super()._read_multiline(quote, start_line, start_col)
|
|
75
|
+
self._record_string_lines(start_line)
|
|
76
|
+
|
|
77
|
+
def _read_quoted(self, quote: str, start_line: int, start_col: int) -> None:
|
|
78
|
+
super()._read_quoted(quote, start_line, start_col)
|
|
79
|
+
self._record_string_lines(start_line)
|
|
80
|
+
|
|
81
|
+
|
|
56
82
|
@dataclass
|
|
57
83
|
class PfTracePragma:
|
|
58
84
|
"""One ``// @pf-trace name=expr`` annotation extracted from Pine source.
|
|
@@ -75,7 +101,8 @@ class PfTracePragma:
|
|
|
75
101
|
line: int
|
|
76
102
|
|
|
77
103
|
|
|
78
|
-
def extract_pf_trace_pragmas(source: str
|
|
104
|
+
def extract_pf_trace_pragmas(source: str, *, filename: str = "<input>",
|
|
105
|
+
budget: TimeBudget | None = None) -> list[PfTracePragma]:
|
|
79
106
|
"""Scan ``source`` for ``// @pf-trace`` line comments.
|
|
80
107
|
|
|
81
108
|
Returns the pragmas in source order. The expression on the
|
|
@@ -93,10 +120,22 @@ def extract_pf_trace_pragmas(source: str) -> list[PfTracePragma]:
|
|
|
93
120
|
scripts) — callers should treat this as the zero-overhead
|
|
94
121
|
path.
|
|
95
122
|
"""
|
|
123
|
+
candidates = [(lineno, match)
|
|
124
|
+
for lineno, raw in enumerate(source.splitlines(), start=1)
|
|
125
|
+
if (match := _PRAGMA_RE.match(raw)) is not None]
|
|
126
|
+
if not candidates:
|
|
127
|
+
return []
|
|
128
|
+
lexer = _StringSpanLexer(source, filename=filename, budget=budget)
|
|
129
|
+
try:
|
|
130
|
+
lexer.tokenize()
|
|
131
|
+
except CompileError:
|
|
132
|
+
# This lexical pass only finds string spans. The main Lexer run owns
|
|
133
|
+
# syntax diagnostics; extraction itself has historically accepted
|
|
134
|
+
# arbitrary source text, including malformed block comments.
|
|
135
|
+
pass
|
|
96
136
|
pragmas: list[PfTracePragma] = []
|
|
97
|
-
for lineno,
|
|
98
|
-
|
|
99
|
-
if not m:
|
|
137
|
+
for lineno, m in candidates:
|
|
138
|
+
if lineno in lexer.string_lines:
|
|
100
139
|
continue
|
|
101
140
|
name = m.group(1)
|
|
102
141
|
expr_source = m.group(2)
|
|
@@ -104,8 +143,23 @@ def extract_pf_trace_pragmas(source: str) -> list[PfTracePragma]:
|
|
|
104
143
|
# the expression body in isolation. ``Parser._parse_expression``
|
|
105
144
|
# is the same entry the statement parser uses for RHS values,
|
|
106
145
|
# so anything legal in ``x = <expr>`` is legal here.
|
|
107
|
-
|
|
108
|
-
|
|
146
|
+
try:
|
|
147
|
+
tokens = Lexer(expr_source, filename=filename, budget=budget).tokenize()
|
|
148
|
+
parser = Parser(tokens, source=expr_source, filename=filename,
|
|
149
|
+
budget=budget)
|
|
150
|
+
try:
|
|
151
|
+
expr_node = parser._parse_expression()
|
|
152
|
+
parser._expect_statement_end()
|
|
153
|
+
except ParseError as error:
|
|
154
|
+
parser._raise_syntax_error(error)
|
|
155
|
+
check_ast_depth(expr_node, filename)
|
|
156
|
+
except CompileError as exc:
|
|
157
|
+
for diagnostic in exc.diagnostics:
|
|
158
|
+
loc = diagnostic.location
|
|
159
|
+
diagnostic.location = SourceLocation(
|
|
160
|
+
filename, loc.line + lineno - 1, loc.col, loc.end_col,
|
|
161
|
+
)
|
|
162
|
+
raise CompileError(exc.diagnostics) from exc
|
|
109
163
|
pragmas.append(
|
|
110
164
|
PfTracePragma(
|
|
111
165
|
name=name,
|