PineForge HPO 0.1.0
Native hyperparameter optimization for PineForge strategies
Loading...
Searching...
No Matches
transpile.py
Go to the documentation of this file.
1"""One-pass PineScript transpiler bridge with structured diagnostics."""
2
3from __future__ import annotations
4
5from dataclasses import dataclass, field
6import hashlib
7import importlib
8from importlib import metadata
9from pathlib import Path
10from typing import Any, Mapping
11
12try:
13 import pineforge_codegen
14 from pineforge_codegen import transpile_full
15 from pineforge_codegen.errors import CompileError
16except ModuleNotFoundError:
17 pineforge_codegen = None
18 transpile_full = None
19 CompileError = None
20
21
22@dataclass(frozen=True)
24 """A stable diagnostic envelope independent of codegen's Python classes."""
25
26 severity: str
27 phase: str
28 message: str
29 filename: str | None = None
30 line: int | None = None
31 column: int | None = None
32 end_column: int | None = None
33 hint: str | None = None
34
35 def to_dict(self) -> dict[str, Any]:
36 return {
37 "severity": self.severityseverity,
38 "phase": self.phasephase,
39 "message": self.messagemessage,
40 "filename": self.filenamefilename,
41 "line": self.lineline,
42 "column": self.columncolumn,
43 "end_column": self.end_columnend_column,
44 "hint": self.hinthint,
45 }
46
47
48@dataclass(frozen=True)
50 """Result of exactly one ``transpile_full`` pipeline invocation."""
51
52 generated_cpp: str | None
53 inputs: tuple[dict[str, Any], ...] = ()
54 strategy_params: Mapping[str, Any] = field(default_factory=dict)
55 diagnostics: tuple[TranspileDiagnostic, ...] = ()
56
57 @property
58 def ok(self) -> bool:
59 return self.generated_cpp is not None and not any(
60 diagnostic.severity == "error" for diagnostic in self.diagnostics
61 )
62
63 def require_success(self) -> str:
64 if not self.ok or self.generated_cpp is None:
65 raise TranspileFailure(self)
66 return self.generated_cpp
67
68
69class TranspileFailure(RuntimeError):
70 """Raised when an artifact build cannot proceed past transpilation."""
71
72 def __init__(self, result: TranspileResult):
73 self.result = result
74 summary = "; ".join(d.message for d in result.diagnostics) or "transpile failed"
75 super().__init__(summary)
76
77
78@dataclass(frozen=True)
80 """Installed codegen version, source hash, and resolved module location."""
81
82 version: str
83 implementation_sha256: str
84 module_path: str
85
86 def to_dict(self) -> dict[str, str]:
87 return {
88 "version": self.versionversion,
89 "implementation_sha256": self.implementation_sha256implementation_sha256,
90 "module_path": self.module_pathmodule_path,
91 }
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138def transpile_source(pine_source: str, *, filename: str = "<input>") -> TranspileResult:
139 """Transpile source once and normalize all expected diagnostics.
140
141 Pine language errors are returned in ``TranspileResult`` rather than being
142 conflated with native compiler failures. Unexpected bridge failures are also
143 represented as an ``INTERNAL`` diagnostic so a CLI can emit one stable JSON
144 envelope.
145 """
146
147 try:
148 transpiler, compile_error = _load_codegen()
149 except ModuleNotFoundError:
150 return TranspileResult(
151 generated_cpp=None,
152 diagnostics=(
154 severity="error",
155 phase="CONFIGURATION",
156 message=(
157 "pineforge-codegen is required to compile PineScript; "
158 "install pineforge-hpo[transpile] or provide a precompiled artifact"
159 ),
160 filename=filename,
161 ),
162 ),
163 )
164
165 try:
166 payload = transpiler(pine_source, filename=filename)
167 except compile_error as error:
168 return TranspileResult(
169 generated_cpp=None,
170 diagnostics=tuple(_diagnostic_from_codegen(d) for d in error.diagnostics),
171 )
172 except Exception as error: # keep the initialization error JSON-serializable
173 return TranspileResult(
174 generated_cpp=None,
175 diagnostics=(
177 severity="error",
178 phase="INTERNAL",
179 message=f"{type(error).__name__}: {error}",
180 filename=filename,
181 ),
182 ),
183 )
184
185 if not isinstance(payload, Mapping):
186 return TranspileResult(
187 generated_cpp=None,
188 diagnostics=(
190 severity="error",
191 phase="INTERNAL",
192 message="transpile_full returned a non-object result",
193 filename=filename,
194 ),
195 ),
196 )
197
198 cpp = payload.get("cpp")
199 inputs = payload.get("inputs", [])
200 strategy_params = payload.get("strategyParams", {})
201 if (
202 not isinstance(cpp, str)
203 or not isinstance(inputs, list)
204 or not isinstance(strategy_params, Mapping)
205 ):
206 return TranspileResult(
207 generated_cpp=None,
208 diagnostics=(
210 severity="error",
211 phase="INTERNAL",
212 message="transpile_full returned an invalid result shape",
213 filename=filename,
214 ),
215 ),
216 )
217
218 normalized_inputs: list[dict[str, Any]] = []
219 for item in inputs:
220 if not isinstance(item, Mapping):
221 return TranspileResult(
222 generated_cpp=None,
223 diagnostics=(
225 severity="error",
226 phase="INTERNAL",
227 message="transpile_full input manifest contains a non-object entry",
228 filename=filename,
229 ),
230 ),
231 )
232 normalized_inputs.append(dict(item))
233
234 return TranspileResult(
235 generated_cpp=cpp,
236 inputs=tuple(normalized_inputs),
237 strategy_params=dict(strategy_params),
238 diagnostics=_diagnostics_from_payload(payload),
239 )
240
241
242def codegen_identity() -> CodegenIdentity:
243 """Return version plus a hash that detects dirty/editable codegen installs."""
244
245 _transpiler, _compile_error = _load_codegen()
246 assert pineforge_codegen is not None
247
248 try:
249 version = metadata.version("pineforge-codegen")
250 except metadata.PackageNotFoundError:
251 version = str(getattr(pineforge_codegen, "__version__", "unknown"))
252
253 module_file = Path(pineforge_codegen.__file__ or "").resolve()
254 package_root = module_file.parent
255 digest = hashlib.sha256()
256 files = (
257 sorted(package_root.rglob("*.py")) if package_root.is_dir() else [module_file]
258 )
259 for path in files:
260 if not path.is_file():
261 continue
262 try:
263 relative = path.relative_to(package_root).as_posix()
264 except ValueError:
265 relative = path.name
266 digest.update(relative.encode("utf-8"))
267 digest.update(b"\0")
268 digest.update(path.read_bytes())
269 digest.update(b"\0")
270 return CodegenIdentity(
271 version=version,
272 implementation_sha256=digest.hexdigest(),
273 module_path=str(module_file),
274 )
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
__init__(self, TranspileResult result)
Definition transpile.py:72
CodegenIdentity codegen_identity()
Definition transpile.py:242