pulse-framework 0.1.53__py3-none-any.whl → 0.1.55__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 (41) hide show
  1. pulse/__init__.py +3 -3
  2. pulse/app.py +34 -20
  3. pulse/code_analysis.py +38 -0
  4. pulse/codegen/codegen.py +18 -50
  5. pulse/codegen/templates/route.py +100 -56
  6. pulse/component.py +24 -6
  7. pulse/components/for_.py +17 -2
  8. pulse/cookies.py +38 -2
  9. pulse/env.py +4 -4
  10. pulse/hooks/init.py +174 -14
  11. pulse/hooks/state.py +105 -0
  12. pulse/js/__init__.py +12 -9
  13. pulse/js/obj.py +79 -0
  14. pulse/js/pulse.py +112 -0
  15. pulse/js/react.py +457 -0
  16. pulse/messages.py +13 -13
  17. pulse/proxy.py +18 -5
  18. pulse/render_session.py +282 -266
  19. pulse/renderer.py +36 -73
  20. pulse/serializer.py +5 -2
  21. pulse/transpiler/__init__.py +13 -0
  22. pulse/transpiler/assets.py +66 -0
  23. pulse/transpiler/builtins.py +0 -20
  24. pulse/transpiler/dynamic_import.py +131 -0
  25. pulse/transpiler/emit_context.py +49 -0
  26. pulse/transpiler/errors.py +29 -11
  27. pulse/transpiler/function.py +36 -5
  28. pulse/transpiler/imports.py +33 -27
  29. pulse/transpiler/js_module.py +73 -20
  30. pulse/transpiler/modules/pulse/tags.py +35 -15
  31. pulse/transpiler/nodes.py +121 -36
  32. pulse/transpiler/py_module.py +1 -1
  33. pulse/transpiler/react_component.py +4 -11
  34. pulse/transpiler/transpiler.py +32 -26
  35. pulse/user_session.py +10 -0
  36. pulse_framework-0.1.55.dist-info/METADATA +196 -0
  37. {pulse_framework-0.1.53.dist-info → pulse_framework-0.1.55.dist-info}/RECORD +39 -32
  38. pulse/hooks/states.py +0 -285
  39. pulse_framework-0.1.53.dist-info/METADATA +0 -18
  40. {pulse_framework-0.1.53.dist-info → pulse_framework-0.1.55.dist-info}/WHEEL +0 -0
  41. {pulse_framework-0.1.53.dist-info → pulse_framework-0.1.55.dist-info}/entry_points.txt +0 -0
pulse/renderer.py CHANGED
@@ -65,48 +65,43 @@ class RenderPropTask(NamedTuple):
65
65
 
66
66
 
67
67
  class RenderTree:
68
- root: Node
68
+ element: Node
69
69
  callbacks: Callbacks
70
- operations: list[VDOMOperation]
71
- _normalized: Node | None
70
+ rendered: bool
72
71
 
73
- def __init__(self, root: Node) -> None:
74
- self.root = root
72
+ def __init__(self, element: Node) -> None:
73
+ self.element = element
75
74
  self.callbacks = {}
76
- self.operations = []
77
- self._normalized = None
75
+ self.rendered = False
78
76
 
79
77
  def render(self) -> VDOM:
78
+ """First render. Returns VDOM."""
80
79
  renderer = Renderer()
81
- vdom, normalized = renderer.render_tree(self.root)
82
- self.root = normalized
80
+ vdom, self.element = renderer.render_tree(self.element)
83
81
  self.callbacks = renderer.callbacks
84
- self._normalized = normalized
82
+ self.rendered = True
85
83
  return vdom
86
84
 
87
- def diff(self, new_tree: Node) -> list[VDOMOperation]:
88
- if self._normalized is None:
89
- raise RuntimeError("RenderTree.render must be called before diff")
85
+ def rerender(self, new_element: Node | None = None) -> list[VDOMOperation]:
86
+ """Re-render and return update operations.
90
87
 
88
+ If new_element is provided, reconciles against it (for testing).
89
+ Otherwise, reconciles against the current element (production use).
90
+ """
91
+ if not self.rendered:
92
+ raise RuntimeError("render() must be called before rerender()")
93
+ target = new_element if new_element is not None else self.element
91
94
  renderer = Renderer()
92
- normalized = renderer.reconcile_tree(self._normalized, new_tree, path="")
93
-
95
+ self.element = renderer.reconcile_tree(self.element, target, path="")
94
96
  self.callbacks = renderer.callbacks
95
- self._normalized = normalized
96
- self.root = normalized
97
-
98
97
  return renderer.operations
99
98
 
100
99
  def unmount(self) -> None:
101
- if self._normalized is not None:
102
- unmount_element(self._normalized)
103
- self._normalized = None
100
+ if self.rendered:
101
+ unmount_element(self.element)
102
+ self.rendered = False
104
103
  self.callbacks.clear()
105
104
 
106
- @property
107
- def normalized(self) -> Node | None:
108
- return self._normalized
109
-
110
105
 
111
106
  class Renderer:
112
107
  def __init__(self) -> None:
@@ -123,13 +118,11 @@ class Renderer:
123
118
  if isinstance(node, Element):
124
119
  return self.render_node(node, path)
125
120
  if isinstance(node, Value):
126
- json_value = coerce_json(node.value, path)
127
- return json_value, json_value
121
+ return node.value, node.value
128
122
  if isinstance(node, Expr):
129
123
  return node.render(), node
130
- if is_json_primitive(node):
131
- return node, node
132
- raise TypeError(f"Unsupported node type: {type(node).__name__}")
124
+ # Pass through any other value - serializer will validate
125
+ return node, node
133
126
 
134
127
  def render_component(
135
128
  self, component: PulseNode, path: str
@@ -148,7 +141,7 @@ class Renderer:
148
141
  if (key_val := key_value(element)) is not None:
149
142
  vdom_node["key"] = key_val
150
143
 
151
- props = element.props or {}
144
+ props = element.props_dict()
152
145
  props_result = self.diff_props({}, props, path, prev_eval=set())
153
146
  if props_result.delta_set:
154
147
  vdom_node["props"] = props_result.delta_set
@@ -188,9 +181,9 @@ class Renderer:
188
181
  path: str = "",
189
182
  ) -> Node:
190
183
  if isinstance(current, Value):
191
- current = coerce_json(current.value, path)
184
+ current = current.value
192
185
  if isinstance(previous, Value):
193
- previous = coerce_json(previous.value, path)
186
+ previous = previous.value
194
187
  if not same_node(previous, current):
195
188
  unmount_element(previous)
196
189
  new_vdom, normalized = self.render_tree(current, path)
@@ -239,8 +232,8 @@ class Renderer:
239
232
  current: Element,
240
233
  path: str,
241
234
  ) -> Element:
242
- prev_props = previous.props or {}
243
- new_props = current.props or {}
235
+ prev_props = previous.props_dict()
236
+ new_props = current.props_dict()
244
237
  prev_eval = eval_keys_for_props(prev_props)
245
238
  props_result = self.diff_props(prev_props, new_props, path, prev_eval)
246
239
 
@@ -389,14 +382,14 @@ class Renderer:
389
382
  continue
390
383
 
391
384
  if isinstance(value, Value):
392
- json_value = coerce_json(value.value, prop_path)
385
+ unwrapped = value.value
393
386
  if normalized is None:
394
387
  normalized = current.copy()
395
- normalized[key] = json_value
388
+ normalized[key] = unwrapped
396
389
  if isinstance(old_value, (Element, PulseNode)):
397
390
  unmount_element(old_value)
398
- if key not in previous or not values_equal(json_value, old_value):
399
- updated[key] = cast(VDOMPropValue, json_value)
391
+ if key not in previous or not values_equal(unwrapped, old_value):
392
+ updated[key] = cast(VDOMPropValue, unwrapped)
400
393
  continue
401
394
 
402
395
  if isinstance(value, Expr):
@@ -422,16 +415,11 @@ class Renderer:
422
415
  updated[key] = CALLBACK_PLACEHOLDER
423
416
  continue
424
417
 
425
- json_value = coerce_json(value, prop_path)
426
418
  if isinstance(old_value, (Element, PulseNode)):
427
419
  unmount_element(old_value)
428
- if normalized is not None:
429
- normalized[key] = json_value
430
- elif json_value is not value:
431
- normalized = current.copy()
432
- normalized[key] = json_value
433
- if key not in previous or not values_equal(json_value, old_value):
434
- updated[key] = cast(VDOMPropValue, json_value)
420
+ # No normalization needed - value passes through unchanged
421
+ if key not in previous or not values_equal(value, old_value):
422
+ updated[key] = cast(VDOMPropValue, value)
435
423
 
436
424
  for key in removed_keys:
437
425
  old_value = previous.get(key)
@@ -488,31 +476,6 @@ def registry_ref(expr: Expr) -> RegistryRef | None:
488
476
  return None
489
477
 
490
478
 
491
- def is_json_primitive(value: Any) -> bool:
492
- return value is None or isinstance(value, (str, int, float, bool))
493
-
494
-
495
- def coerce_json(value: Any, path: str) -> Any:
496
- """Convert Python value to JSON-compatible structure.
497
-
498
- Performs runtime conversions:
499
- - tuple → list
500
- - validates dict keys are strings
501
- """
502
- if is_json_primitive(value):
503
- return value
504
- if isinstance(value, (list, tuple)):
505
- return [coerce_json(v, path) for v in value]
506
- if isinstance(value, dict):
507
- out: dict[str, Any] = {}
508
- for k, v in value.items():
509
- if not isinstance(k, str):
510
- raise TypeError(f"Non-string prop key at {path}: {k!r}")
511
- out[k] = coerce_json(v, path)
512
- return out
513
- raise TypeError(f"Unsupported JSON value at {path}: {type(value).__name__}")
514
-
515
-
516
479
  def prop_requires_eval(value: PropValue) -> bool:
517
480
  if isinstance(value, Value):
518
481
  return False
@@ -611,7 +574,7 @@ def unmount_element(element: Node) -> None:
611
574
  return
612
575
 
613
576
  if isinstance(element, Element):
614
- props = element.props or {}
577
+ props = element.props_dict()
615
578
  for value in props.values():
616
579
  if isinstance(value, (Element, PulseNode)):
617
580
  unmount_element(value)
pulse/serializer.py CHANGED
@@ -86,8 +86,11 @@ def serialize(data: Any) -> Serialized:
86
86
  if isinstance(value, dict):
87
87
  result_dict: dict[str, PlainJSON] = {}
88
88
  for key, entry in value.items():
89
- key_str = str(key) # pyright: ignore[reportUnknownArgumentType]
90
- result_dict[key_str] = process(entry)
89
+ if not isinstance(key, str):
90
+ raise TypeError(
91
+ f"Dict keys must be strings, got {type(key).__name__}: {key!r}" # pyright: ignore[reportUnknownArgumentType]
92
+ )
93
+ result_dict[key] = process(entry)
91
94
  return result_dict
92
95
 
93
96
  if isinstance(value, (list, tuple)):
@@ -3,10 +3,23 @@
3
3
  # Ensure built-in Python modules (e.g., math) are registered on import.
4
4
  from pulse.transpiler import modules as _modules # noqa: F401
5
5
 
6
+ # Asset registry (unified for Import and DynamicImport)
7
+ from pulse.transpiler.assets import LocalAsset as LocalAsset
8
+ from pulse.transpiler.assets import clear_asset_registry as clear_asset_registry
9
+ from pulse.transpiler.assets import get_registered_assets as get_registered_assets
10
+ from pulse.transpiler.assets import register_local_asset as register_local_asset
11
+
6
12
  # Builtins
7
13
  from pulse.transpiler.builtins import BUILTINS as BUILTINS
8
14
  from pulse.transpiler.builtins import emit_method as emit_method
9
15
 
16
+ # Dynamic import primitive
17
+ from pulse.transpiler.dynamic_import import DynamicImport as DynamicImport
18
+ from pulse.transpiler.dynamic_import import import_ as import_
19
+
20
+ # Emit context
21
+ from pulse.transpiler.emit_context import EmitContext as EmitContext
22
+
10
23
  # Errors
11
24
  from pulse.transpiler.errors import TranspileError as TranspileError
12
25
 
@@ -0,0 +1,66 @@
1
+ """Unified asset registry for local files that need copying.
2
+
3
+ Used by both Import (static imports) and DynamicImport (inline dynamic imports)
4
+ to track local files that should be copied to the assets folder during codegen.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import posixpath
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ from pulse.transpiler.emit_context import EmitContext
14
+ from pulse.transpiler.id import next_id
15
+
16
+
17
+ @dataclass(slots=True)
18
+ class LocalAsset:
19
+ """A local file registered for copying to assets."""
20
+
21
+ source_path: Path
22
+ id: str
23
+
24
+ @property
25
+ def asset_filename(self) -> str:
26
+ """Filename in assets folder: stem_id.ext"""
27
+ return f"{self.source_path.stem}_{self.id}{self.source_path.suffix}"
28
+
29
+ def import_path(self) -> str:
30
+ """Get import path for this asset.
31
+
32
+ If EmitContext is set, returns path relative to route file.
33
+ Otherwise returns the absolute source path (useful for tests/debugging).
34
+ """
35
+ ctx = EmitContext.get()
36
+ if ctx is None:
37
+ return str(self.source_path)
38
+ # Compute relative path from route file directory to asset
39
+ # route_file_path is like "routes/users/index.tsx"
40
+ # asset is in "assets/{asset_filename}"
41
+ route_dir = posixpath.dirname(ctx.route_file_path)
42
+ asset_path = f"assets/{self.asset_filename}"
43
+ return posixpath.relpath(asset_path, route_dir)
44
+
45
+
46
+ # Registry keyed by resolved source_path (dedupes same file)
47
+ _ASSET_REGISTRY: dict[Path, LocalAsset] = {}
48
+
49
+
50
+ def register_local_asset(source_path: Path) -> LocalAsset:
51
+ """Register a local file for copying. Returns existing if already registered."""
52
+ if source_path in _ASSET_REGISTRY:
53
+ return _ASSET_REGISTRY[source_path]
54
+ asset = LocalAsset(source_path, next_id())
55
+ _ASSET_REGISTRY[source_path] = asset
56
+ return asset
57
+
58
+
59
+ def get_registered_assets() -> list[LocalAsset]:
60
+ """Get all registered local assets."""
61
+ return list(_ASSET_REGISTRY.values())
62
+
63
+
64
+ def clear_asset_registry() -> None:
65
+ """Clear asset registry (for tests)."""
66
+ _ASSET_REGISTRY.clear()
@@ -21,7 +21,6 @@ from pulse.transpiler.nodes import (
21
21
  Literal,
22
22
  Member,
23
23
  New,
24
- Object,
25
24
  Spread,
26
25
  Subscript,
27
26
  Template,
@@ -173,25 +172,6 @@ def emit_dict(*args: Any, ctx: Transpiler) -> Expr:
173
172
  raise TranspileError("dict() expects at most one argument")
174
173
 
175
174
 
176
- @transformer("obj")
177
- def obj(*args: Any, ctx: Transpiler, **kwargs: Any) -> Expr:
178
- """obj(key=value, ...) -> { key: value, ... }
179
-
180
- Creates a plain JavaScript object literal.
181
- Use this instead of dict() when you need a plain object (e.g., for React props).
182
-
183
- Example:
184
- style=obj(display="block", color="red")
185
- -> style={{ display: "block", color: "red" }}
186
- """
187
- if args:
188
- raise TranspileError("obj() only accepts keyword arguments")
189
- props: list[tuple[str, Expr]] = []
190
- for key, value in kwargs.items():
191
- props.append((key, ctx.emit_expr(value)))
192
- return Object(props)
193
-
194
-
195
175
  @transformer("filter")
196
176
  def emit_filter(*args: Any, ctx: Transpiler) -> Expr:
197
177
  """filter(func, iterable) -> iterable.filter(func)"""
@@ -0,0 +1,131 @@
1
+ """Dynamic import primitive for code-splitting.
2
+
3
+ Provides `import_` for inline dynamic imports in @javascript functions:
4
+
5
+ @javascript
6
+ def load_chart():
7
+ return import_("./Chart").then(lambda m: m.default)
8
+
9
+ For lazy-loaded React components, use Import(lazy=True) with React.lazy:
10
+
11
+ from pulse.js.react import React, lazy
12
+
13
+ # Low-level: Import(lazy=True) creates a factory, wrap with React.lazy
14
+ factory = Import("Chart", "./Chart", kind="default", lazy=True)
15
+ LazyChart = Jsx(React.lazy(factory))
16
+
17
+ # High-level: lazy() helper combines both
18
+ LazyChart = lazy("./Chart")
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import ast
24
+ from dataclasses import dataclass
25
+ from typing import TYPE_CHECKING, override
26
+
27
+ from pulse.transpiler.assets import LocalAsset, register_local_asset
28
+ from pulse.transpiler.errors import TranspileError
29
+ from pulse.transpiler.imports import is_local_path, resolve_local_path
30
+ from pulse.transpiler.nodes import Expr, Member
31
+ from pulse.transpiler.vdom import VDOMNode
32
+
33
+ if TYPE_CHECKING:
34
+ from pulse.transpiler.transpiler import Transpiler
35
+
36
+
37
+ @dataclass(slots=True)
38
+ class DynamicImport(Expr):
39
+ """Represents a dynamic import() expression.
40
+
41
+ Emits as: import("src")
42
+
43
+ Supports method chaining for .then():
44
+ import_("./foo").then(lambda m: m.bar)
45
+ -> import("./foo").then(m => m.bar)
46
+ """
47
+
48
+ src: str
49
+ asset: LocalAsset | None = None
50
+
51
+ @override
52
+ def emit(self, out: list[str]) -> None:
53
+ if self.asset:
54
+ out.append(f'import("{self.asset.import_path()}")')
55
+ else:
56
+ out.append(f'import("{self.src}")')
57
+
58
+ @override
59
+ def render(self) -> VDOMNode:
60
+ raise TypeError("DynamicImport cannot be rendered to VDOM")
61
+
62
+ @override
63
+ def transpile_getattr(self, attr: str, ctx: Transpiler) -> Expr:
64
+ """Allow .then() and other method chaining."""
65
+ return Member(self, attr)
66
+
67
+
68
+ class DynamicImportFn(Expr):
69
+ """Sentinel expr that intercepts import_() calls.
70
+
71
+ When used in a @javascript function:
72
+ import_("./module")
73
+
74
+ Transpiles to:
75
+ import("./module")
76
+
77
+ For local paths, resolves the file and registers it for asset copying.
78
+ """
79
+
80
+ @override
81
+ def emit(self, out: list[str]) -> None:
82
+ raise TypeError(
83
+ "import_ cannot be emitted directly - call it with a source path"
84
+ )
85
+
86
+ @override
87
+ def render(self) -> VDOMNode:
88
+ raise TypeError("import_ cannot be rendered to VDOM")
89
+
90
+ @override
91
+ def transpile_call(
92
+ self,
93
+ args: list[ast.expr],
94
+ keywords: list[ast.keyword],
95
+ ctx: Transpiler,
96
+ ) -> Expr:
97
+ """Handle import_("source") calls."""
98
+ if keywords:
99
+ raise TranspileError("import_() does not accept keyword arguments")
100
+ if len(args) != 1:
101
+ raise TranspileError("import_() takes exactly 1 argument")
102
+
103
+ # Extract string literal from AST
104
+ src_node = args[0]
105
+ if not isinstance(src_node, ast.Constant) or not isinstance(
106
+ src_node.value, str
107
+ ):
108
+ raise TranspileError("import_() argument must be a string literal")
109
+
110
+ src = src_node.value
111
+ asset: LocalAsset | None = None
112
+
113
+ # Resolve local paths and register asset
114
+ if is_local_path(src):
115
+ if ctx.source_file is None:
116
+ raise TranspileError(
117
+ "Cannot resolve relative import_() path: source file unknown"
118
+ )
119
+ source_path = resolve_local_path(src, ctx.source_file)
120
+ if source_path:
121
+ asset = register_local_asset(source_path)
122
+ else:
123
+ raise TranspileError(
124
+ f"import_({src!r}) references a local path that does not exist"
125
+ )
126
+
127
+ return DynamicImport(src, asset)
128
+
129
+
130
+ # Singleton for use in deps
131
+ import_ = DynamicImportFn()
@@ -0,0 +1,49 @@
1
+ """Emit context for code generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextvars import ContextVar, Token
6
+ from dataclasses import dataclass, field
7
+ from types import TracebackType
8
+ from typing import Literal
9
+
10
+
11
+ @dataclass
12
+ class EmitContext:
13
+ """Context for emit operations during route code generation.
14
+
15
+ Stores information about the current route file being generated,
16
+ allowing emit methods to compute correct relative paths.
17
+
18
+ Usage:
19
+ with EmitContext(route_file_path="routes/users/index.tsx"):
20
+ js_code = emit(fn.transpile())
21
+ """
22
+
23
+ route_file_path: str
24
+ """Path to route file from pulse folder root, e.g. 'routes/users/index.tsx'"""
25
+
26
+ _token: Token[EmitContext | None] | None = field(default=None, repr=False)
27
+
28
+ @classmethod
29
+ def get(cls) -> EmitContext | None:
30
+ """Get current emit context, or None if not set."""
31
+ return _EMIT_CONTEXT.get()
32
+
33
+ def __enter__(self) -> EmitContext:
34
+ self._token = _EMIT_CONTEXT.set(self)
35
+ return self
36
+
37
+ def __exit__(
38
+ self,
39
+ exc_type: type[BaseException] | None = None,
40
+ exc_val: BaseException | None = None,
41
+ exc_tb: TracebackType | None = None,
42
+ ) -> Literal[False]:
43
+ if self._token is not None:
44
+ _EMIT_CONTEXT.reset(self._token)
45
+ self._token = None
46
+ return False
47
+
48
+
49
+ _EMIT_CONTEXT: ContextVar[EmitContext | None] = ContextVar("emit_context", default=None)
@@ -13,6 +13,7 @@ class TranspileError(Exception):
13
13
  source: str | None
14
14
  filename: str | None
15
15
  func_name: str | None
16
+ source_start_line: int | None
16
17
 
17
18
  def __init__(
18
19
  self,
@@ -22,12 +23,14 @@ class TranspileError(Exception):
22
23
  source: str | None = None,
23
24
  filename: str | None = None,
24
25
  func_name: str | None = None,
26
+ source_start_line: int | None = None,
25
27
  ) -> None:
26
28
  self.message = message
27
29
  self.node = node
28
30
  self.source = source
29
31
  self.filename = filename
30
32
  self.func_name = func_name
33
+ self.source_start_line = source_start_line
31
34
  super().__init__(self._format_message())
32
35
 
33
36
  def _format_message(self) -> str:
@@ -38,25 +41,38 @@ class TranspileError(Exception):
38
41
  loc_parts: list[str] = []
39
42
  if self.func_name:
40
43
  loc_parts.append(f"in {self.func_name}")
44
+ display_lineno = self.node.lineno
45
+ if self.source_start_line is not None:
46
+ display_lineno = self.source_start_line + self.node.lineno - 1
41
47
  if self.filename:
42
- loc_parts.append(f"at {self.filename}:{self.node.lineno}")
48
+ loc_parts.append(f"at {self.filename}:{display_lineno}")
43
49
  else:
44
- loc_parts.append(f"at line {self.node.lineno}")
45
- if hasattr(self.node, "col_offset"):
46
- loc_parts[-1] += f":{self.node.col_offset}"
47
-
48
- if loc_parts:
49
- parts.append(" ".join(loc_parts))
50
+ loc_parts.append(f"at line {display_lineno}")
50
51
 
51
- # Show the source line if available
52
+ display_line = None
53
+ display_col = None
52
54
  if self.source:
53
55
  lines = self.source.splitlines()
54
56
  if 0 < self.node.lineno <= len(lines):
55
57
  source_line = lines[self.node.lineno - 1]
56
- parts.append(f"\n {source_line}")
57
- # Add caret pointing to column
58
+ display_line = source_line.expandtabs(4)
58
59
  if hasattr(self.node, "col_offset"):
59
- parts.append(" " + " " * self.node.col_offset + "^")
60
+ prefix = source_line[: self.node.col_offset]
61
+ display_col = len(prefix.expandtabs(4))
62
+
63
+ if hasattr(self.node, "col_offset"):
64
+ col = display_col if display_col is not None else self.node.col_offset
65
+ loc_parts[-1] += f":{col}"
66
+
67
+ if loc_parts:
68
+ parts.append(" ".join(loc_parts))
69
+
70
+ # Show the source line if available
71
+ if display_line is not None:
72
+ parts.append(f"\n {display_line}")
73
+ # Add caret pointing to column
74
+ if display_col is not None:
75
+ parts.append(" " + " " * display_col + "^")
60
76
 
61
77
  return "\n".join(parts) if len(parts) > 1 else parts[0]
62
78
 
@@ -67,6 +83,7 @@ class TranspileError(Exception):
67
83
  source: str | None = None,
68
84
  filename: str | None = None,
69
85
  func_name: str | None = None,
86
+ source_start_line: int | None = None,
70
87
  ) -> TranspileError:
71
88
  """Return a new TranspileError with additional context."""
72
89
  return TranspileError(
@@ -75,4 +92,5 @@ class TranspileError(Exception):
75
92
  source=source or self.source,
76
93
  filename=filename or self.filename,
77
94
  func_name=func_name or self.func_name,
95
+ source_start_line=source_start_line or self.source_start_line,
78
96
  )