PineForge HPO 1.11.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
6from functools import lru_cache
7import hashlib
8import importlib
9from importlib import metadata
10from pathlib import Path
11import re
12from typing import Any, Callable, Mapping
13
14from .error import HpoError
15
16try:
17 import pineforge_codegen
18 from pineforge_codegen import transpile_full
19 from pineforge_codegen.errors import CompileError
20except ModuleNotFoundError:
21 pineforge_codegen = None
22 transpile_full = None
23 CompileError = None
24
25
26
27
28@dataclass(frozen=True)
30 """A stable diagnostic envelope independent of codegen's Python classes."""
31
32 severity: str
33 phase: str
34 message: str
35 filename: str | None = None
36 line: int | None = None
37 column: int | None = None
38 end_column: int | None = None
39 hint: str | None = None
40
41 def to_dict(self) -> dict[str, Any]:
42 return {
43 "severity": self.severityseverity,
44 "phase": self.phasephase,
45 "message": self.messagemessage,
46 "filename": self.filenamefilename,
47 "line": self.lineline,
48 "column": self.columncolumn,
49 "end_column": self.end_columnend_column,
50 "hint": self.hinthint,
51 }
52
53
54@dataclass(frozen=True)
56 """Result of the strategy pipeline, with a separately cached capability canary."""
57
58 generated_cpp: str | None
59 inputs: tuple[dict[str, Any], ...] = ()
60 strategy_params: Mapping[str, Any] = field(default_factory=dict)
61 diagnostics: tuple[TranspileDiagnostic, ...] = ()
62 input_kind_schema: int | None = None
63 request_feed_inventory: Mapping[str, Any] | None = None
64
65 @property
66 def ok(self) -> bool:
67 return self.generated_cpp is not None and not any(
68 diagnostic.severity == "error" for diagnostic in self.diagnostics
69 )
70
71 def require_success(self) -> str:
72 if not self.ok or self.generated_cpp is None:
73 raise TranspileFailure(self)
74 return self.generated_cpp
75
76
77class TranspileFailure(HpoError, RuntimeError):
78 """Raised when an artifact build cannot proceed past transpilation."""
79
80 def __init__(self, result: TranspileResult):
81 self.result = result
82 summary = "; ".join(d.message for d in result.diagnostics) or "transpile failed"
83 super().__init__(summary)
84 self.with_failure("hpo_transpile_failed", {})
85 if any(
86 diagnostic.phase == "CONFIGURATION" for diagnostic in result.diagnostics
87 ):
88 self.with_failure("hpo_toolchain_unavailable", {"reason": "configuration"})
89 elif any(diagnostic.phase == "INTERNAL" for diagnostic in result.diagnostics):
90 self.with_failure(
91 "hpo_toolchain_unavailable", {"reason": "transpile_internal"}
92 )
93
94
95@dataclass(frozen=True)
97 """Installed codegen version, source hash, and resolved module location."""
98
99 version: str
100 implementation_sha256: str
101 module_path: str
102
103 def to_dict(self) -> dict[str, str]:
104 return {
105 "version": self.versionversion,
106 "implementation_sha256": self.implementation_sha256implementation_sha256,
107 "module_path": self.module_pathmodule_path,
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
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219def transpile_source(pine_source: str, *, filename: str = "<input>") -> TranspileResult:
220 """Transpile source once and normalize all expected diagnostics.
221
222 Pine language errors are returned in ``TranspileResult`` rather than being
223 conflated with native compiler failures. Unexpected bridge failures are also
224 represented as an ``INTERNAL`` diagnostic so a CLI can emit one stable JSON
225 envelope. When available, the inventory producer replaces ``transpile_full``
226 for this source. It stays chart-unbound; requests for the chart's timeframe
227 therefore remain unknown. The input-kind canary still uses its own source.
228 """
229
230 try:
231 transpiler, compile_error = _load_codegen()
232 except ModuleNotFoundError:
233 return TranspileResult(
234 generated_cpp=None,
235 diagnostics=(
237 severity="error",
238 phase="CONFIGURATION",
239 message=(
240 "pineforge-codegen is required to compile PineScript; "
241 "install pineforge-hpo[transpile] or provide a precompiled artifact"
242 ),
243 filename=filename,
244 ),
245 ),
246 )
247
248 produces_inventory = codegen_supports_request_inventory()
249 strategy_transpiler = (
250 pineforge_codegen.transpile_with_request_inventory
251 if produces_inventory
252 else transpiler
253 )
254 try:
255 payload = strategy_transpiler(pine_source, filename=filename)
256 except compile_error as error:
257 return TranspileResult(
258 generated_cpp=None,
259 diagnostics=tuple(_diagnostic_from_codegen(d) for d in error.diagnostics),
260 )
261 except Exception as error: # keep the initialization error JSON-serializable
262 return TranspileResult(
263 generated_cpp=None,
264 diagnostics=(
266 severity="error",
267 phase="INTERNAL",
268 message=f"{type(error).__name__}: {error}",
269 filename=filename,
270 ),
271 ),
272 )
273
274 if not isinstance(payload, Mapping):
275 return TranspileResult(
276 generated_cpp=None,
277 diagnostics=(
279 severity="error",
280 phase="INTERNAL",
281 message="transpile_full returned a non-object result",
282 filename=filename,
283 ),
284 ),
285 )
286
287 cpp = payload.get("cpp")
288 inputs = payload.get("inputs", [])
289 strategy_params = payload.get("strategyParams", {})
290 if (
291 not isinstance(cpp, str)
292 or not isinstance(inputs, list)
293 or not isinstance(strategy_params, Mapping)
294 ):
295 return TranspileResult(
296 generated_cpp=None,
297 diagnostics=(
299 severity="error",
300 phase="INTERNAL",
301 message="transpile_full returned an invalid result shape",
302 filename=filename,
303 ),
304 ),
305 )
306
307 normalized_inputs: list[dict[str, Any]] = []
308 for item in inputs:
309 if not isinstance(item, Mapping):
310 return TranspileResult(
311 generated_cpp=None,
312 diagnostics=(
314 severity="error",
315 phase="INTERNAL",
316 message="transpile_full input manifest contains a non-object entry",
317 filename=filename,
318 ),
319 ),
320 )
321 normalized_inputs.append(dict(item))
322
323 inventory = payload.get("request_feed_inventory") if produces_inventory else None
324 if produces_inventory and (
325 not isinstance(inventory, Mapping)
326 or inventory.get("schema") != REQUEST_FEED_INVENTORY_SCHEMA
327 ):
328 return TranspileResult(
329 generated_cpp=None,
330 diagnostics=(
332 severity="error",
333 phase="INTERNAL",
334 message=(
335 "transpile_with_request_inventory returned an invalid "
336 "request_feed_inventory object or schema"
337 ),
338 filename=filename,
339 ),
340 ),
341 )
342
343 return TranspileResult(
344 generated_cpp=cpp,
345 inputs=tuple(normalized_inputs),
346 strategy_params=dict(strategy_params),
347 diagnostics=_diagnostics_from_payload(payload),
348 input_kind_schema=(
349 1
350 if isinstance(payload.get("requests"), list)
351 and all(isinstance(item, Mapping) for item in payload["requests"])
352 and _input_kind_canary(transpiler)
353 else None
354 ),
355 request_feed_inventory=inventory,
356 )
357
358
360 """Feature-detect the loaded producer, including older supported codegens."""
361
362 try:
363 _load_codegen()
364 except ModuleNotFoundError:
365 return False
366 return callable(
367 getattr(pineforge_codegen, "transpile_with_request_inventory", None)
368 )
369
370
371def codegen_identity() -> CodegenIdentity:
372 """Return version plus a hash that detects dirty/editable codegen installs."""
373
374 _transpiler, _compile_error = _load_codegen()
375 assert pineforge_codegen is not None
376
377 try:
378 version = metadata.version("pineforge-codegen")
379 except metadata.PackageNotFoundError:
380 version = str(getattr(pineforge_codegen, "__version__", "unknown"))
381
382 module_file = Path(pineforge_codegen.__file__ or "").resolve()
383 package_root = module_file.parent
384 digest = hashlib.sha256()
385 files = (
386 sorted(package_root.rglob("*.py")) if package_root.is_dir() else [module_file]
387 )
388 for path in files:
389 if not path.is_file():
390 continue
391 try:
392 relative = path.relative_to(package_root).as_posix()
393 except ValueError:
394 relative = path.name
395 digest.update(relative.encode("utf-8"))
396 digest.update(b"\0")
397 digest.update(path.read_bytes())
398 digest.update(b"\0")
399 return CodegenIdentity(
400 version=version,
401 implementation_sha256=digest.hexdigest(),
402 module_path=str(module_file),
403 )
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
with_failure(self, str|None code, dict[str, Any]|None args, str origin="hpo")
Definition error.py:25
__init__(self, TranspileResult result)
Definition transpile.py:80
bool codegen_supports_request_inventory()
Definition transpile.py:359
CodegenIdentity codegen_identity()
Definition transpile.py:371