pulse-framework 0.1.72__py3-none-any.whl → 0.1.74__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.
@@ -5,223 +5,23 @@ This module provides the base State class and reactive property system
5
5
  that enables automatic re-rendering when state changes.
6
6
  """
7
7
 
8
- import inspect
9
- from abc import ABC, ABCMeta, abstractmethod
10
- from collections.abc import Callable, Iterator
8
+ import sys
9
+ from abc import ABC, ABCMeta
10
+ from collections.abc import Iterator
11
11
  from enum import IntEnum
12
- from typing import Any, Generic, Never, TypeVar, override
12
+ from types import SimpleNamespace
13
+ from typing import Any, get_type_hints, override
13
14
 
14
15
  from pulse.helpers import Disposable
15
- from pulse.reactive import (
16
- AsyncEffect,
17
- Computed,
18
- Effect,
19
- Scope,
20
- Signal,
21
- )
16
+ from pulse.reactive import Computed, Effect, Scope, Signal
22
17
  from pulse.reactive_extensions import ReactiveProperty
23
-
24
- T = TypeVar("T")
25
-
26
-
27
- class StateProperty(ReactiveProperty[Any]):
28
- """
29
- Descriptor for reactive properties on State classes.
30
-
31
- StateProperty wraps a Signal and provides automatic reactivity for
32
- class attributes. When a property is read, it subscribes to the underlying
33
- Signal. When written, it updates the Signal and triggers re-renders.
34
-
35
- This class is typically not used directly. Instead, declare typed attributes
36
- on a State subclass, and the StateMeta metaclass will automatically convert
37
- them into StateProperty instances.
38
-
39
- Example:
40
-
41
- ```python
42
- class MyState(ps.State):
43
- count: int = 0 # Automatically becomes a StateProperty
44
- name: str = "default"
45
-
46
- state = MyState()
47
- state.count = 5 # Updates the underlying Signal
48
- print(state.count) # Reads from the Signal, subscribes to changes
49
- ```
50
- """
51
-
52
- pass
53
-
54
-
55
- class InitializableProperty(ABC):
56
- @abstractmethod
57
- def initialize(self, state: "State", name: str) -> Any: ...
58
-
59
-
60
- class ComputedProperty(Generic[T]):
61
- """
62
- Descriptor for computed (derived) properties on State classes.
63
-
64
- ComputedProperty wraps a method that derives its value from other reactive
65
- properties. The computed value is cached and only recalculated when its
66
- dependencies change. Reading a computed property subscribes to it.
67
-
68
- Created automatically when using the @ps.computed decorator on a State method.
69
-
70
- Args:
71
- name: The property name (used for debugging and the private storage key).
72
- fn: The method that computes the value. Must take only `self` as argument.
73
-
74
- Example:
75
-
76
- ```python
77
- class MyState(ps.State):
78
- count: int = 0
79
-
80
- @ps.computed
81
- def doubled(self):
82
- return self.count * 2
83
-
84
- state = MyState()
85
- print(state.doubled) # 0
86
- state.count = 5
87
- print(state.doubled) # 10 (automatically recomputed)
88
- ```
89
- """
90
-
91
- name: str
92
- private_name: str
93
- fn: "Callable[[State], T]"
94
-
95
- def __init__(self, name: str, fn: "Callable[[State], T]"):
96
- self.name = name
97
- self.private_name = f"__computed_{name}"
98
- # The computed_template holds the original method
99
- self.fn = fn
100
-
101
- def get_computed(self, obj: Any) -> Computed[T]:
102
- if not isinstance(obj, State):
103
- raise ValueError(
104
- f"Computed property {self.name} defined on a non-State class"
105
- )
106
- if not hasattr(obj, self.private_name):
107
- # Create the computed on first access for this instance
108
- bound_method = self.fn.__get__(obj, obj.__class__)
109
- new_computed = Computed(
110
- bound_method,
111
- name=f"{obj.__class__.__name__}.{self.name}",
112
- )
113
- setattr(obj, self.private_name, new_computed)
114
- return getattr(obj, self.private_name)
115
-
116
- def __get__(self, obj: Any, objtype: Any = None) -> T:
117
- if obj is None:
118
- return self # pyright: ignore[reportReturnType]
119
-
120
- return self.get_computed(obj).read()
121
-
122
- def __set__(self, obj: Any, value: Any) -> Never:
123
- raise AttributeError(f"Cannot set computed property '{self.name}'")
124
-
125
-
126
- class StateEffect(Generic[T], InitializableProperty):
127
- """
128
- Descriptor for side effects on State classes.
129
-
130
- StateEffect wraps a method that performs side effects when its dependencies
131
- change. The effect is initialized when the State instance is created and
132
- disposed when the State is disposed.
133
-
134
- Created automatically when using the @ps.effect decorator on a State method.
135
- Supports both sync and async methods.
136
-
137
- Args:
138
- fn: The effect function. Must take only `self` as argument.
139
- Can return a cleanup function that runs before the next execution
140
- or when the effect is disposed.
141
- name: Debug name for the effect. Defaults to "ClassName.method_name".
142
- immediate: If True, run synchronously when scheduled (sync effects only).
143
- lazy: If True, don't run on creation; wait for first dependency change.
144
- on_error: Callback for handling errors during effect execution.
145
- deps: Explicit dependencies. If provided, auto-tracking is disabled.
146
- interval: Re-run interval in seconds for polling effects.
147
-
148
- Example:
149
-
150
- ```python
151
- class MyState(ps.State):
152
- count: int = 0
153
-
154
- @ps.effect
155
- def log_count(self):
156
- print(f"Count changed to: {self.count}")
157
-
158
- @ps.effect
159
- async def fetch_data(self):
160
- data = await api.fetch(self.query)
161
- self.data = data
162
-
163
- @ps.effect
164
- def subscribe(self):
165
- unsub = event_bus.subscribe(self.handle_event)
166
- return unsub # Cleanup function
167
- ```
168
- """
169
-
170
- fn: "Callable[[State], T]"
171
- name: str | None
172
- immediate: bool
173
- on_error: "Callable[[Exception], None] | None"
174
- lazy: bool
175
- deps: "list[Signal[Any] | Computed[Any]] | None"
176
- update_deps: bool | None
177
- interval: float | None
178
-
179
- def __init__(
180
- self,
181
- fn: "Callable[[State], T]",
182
- name: str | None = None,
183
- immediate: bool = False,
184
- lazy: bool = False,
185
- on_error: "Callable[[Exception], None] | None" = None,
186
- deps: "list[Signal[Any] | Computed[Any]] | None" = None,
187
- update_deps: bool | None = None,
188
- interval: float | None = None,
189
- ):
190
- self.fn = fn
191
- self.name = name
192
- self.immediate = immediate
193
- self.on_error = on_error
194
- self.lazy = lazy
195
- self.deps = deps
196
- self.update_deps = update_deps
197
- self.interval = interval
198
-
199
- @override
200
- def initialize(self, state: "State", name: str):
201
- bound_method = self.fn.__get__(state, state.__class__)
202
- # Select sync/async effect type based on bound method
203
- if inspect.iscoroutinefunction(bound_method):
204
- effect: Effect = AsyncEffect(
205
- bound_method, # type: ignore[arg-type]
206
- name=self.name or f"{state.__class__.__name__}.{name}",
207
- lazy=self.lazy,
208
- on_error=self.on_error,
209
- deps=self.deps,
210
- update_deps=self.update_deps,
211
- interval=self.interval,
212
- )
213
- else:
214
- effect = Effect(
215
- bound_method, # type: ignore[arg-type]
216
- name=self.name or f"{state.__class__.__name__}.{name}",
217
- immediate=self.immediate,
218
- lazy=self.lazy,
219
- on_error=self.on_error,
220
- deps=self.deps,
221
- update_deps=self.update_deps,
222
- interval=self.interval,
223
- )
224
- setattr(state, name, effect)
18
+ from pulse.state.property import (
19
+ ComputedProperty,
20
+ InitializableProperty,
21
+ StateEffect,
22
+ StateProperty,
23
+ )
24
+ from pulse.state.query_param import QueryParam, QueryParamProperty, extract_query_param
225
25
 
226
26
 
227
27
  class StateMeta(ABCMeta):
@@ -258,18 +58,62 @@ class StateMeta(ABCMeta):
258
58
  namespace: dict[str, Any],
259
59
  **kwargs: Any,
260
60
  ):
261
- annotations = namespace.get("__annotations__", {})
61
+ declared_annotations = dict(namespace.get("__annotations__", {}))
62
+ cls = super().__new__(mcs, name, bases, namespace)
63
+ resolved_annotations: dict[str, Any] = {}
64
+ if declared_annotations:
65
+ module = sys.modules.get(cls.__module__)
66
+ globalns = module.__dict__ if module else {}
67
+ if "QueryParam" not in globalns:
68
+ globalns["QueryParam"] = QueryParam
69
+ localns = dict(cls.__dict__)
70
+ try:
71
+ hints = get_type_hints(
72
+ cls,
73
+ globalns=globalns,
74
+ localns=localns,
75
+ )
76
+ except Exception:
77
+ hints = None
78
+ if hints is not None:
79
+ for key, value in declared_annotations.items():
80
+ resolved_annotations[key] = hints.get(key, value)
81
+ else:
82
+ for key, value in declared_annotations.items():
83
+ try:
84
+ holder = SimpleNamespace(__annotations__={key: value})
85
+ resolved = get_type_hints(
86
+ holder,
87
+ globalns=globalns,
88
+ localns=localns,
89
+ ).get(key, value)
90
+ except Exception:
91
+ resolved = value
92
+ resolved_annotations[key] = resolved
262
93
 
263
94
  # 1) Turn annotated fields into StateProperty descriptors
264
- for attr_name in annotations:
95
+ for attr_name, annotation in resolved_annotations.items():
265
96
  # Do not wrap private/dunder attributes as reactive
266
97
  if attr_name.startswith("_"):
267
98
  continue
268
- default_value = namespace.get(attr_name)
269
- namespace[attr_name] = StateProperty(attr_name, default_value)
99
+ default_value = cls.__dict__.get(attr_name)
100
+ value_type, is_query_param = extract_query_param(annotation)
101
+ if is_query_param:
102
+ cls.__annotations__[attr_name] = value_type
103
+ prop = QueryParamProperty(
104
+ attr_name,
105
+ default_value,
106
+ value_type,
107
+ )
108
+ setattr(cls, attr_name, prop)
109
+ prop.__set_name__(cls, attr_name)
110
+ else:
111
+ prop = StateProperty(attr_name, default_value)
112
+ setattr(cls, attr_name, prop)
113
+ prop.__set_name__(cls, attr_name)
270
114
 
271
115
  # 2) Turn non-annotated plain values into StateProperty descriptors
272
- for attr_name, value in list(namespace.items()):
116
+ for attr_name, value in list(cls.__dict__.items()):
273
117
  # Do not wrap private/dunder attributes as reactive
274
118
  if attr_name.startswith("_"):
275
119
  continue
@@ -285,9 +129,11 @@ class StateMeta(ABCMeta):
285
129
  ):
286
130
  continue
287
131
  # Convert plain class var into a StateProperty
288
- namespace[attr_name] = StateProperty(attr_name, value)
132
+ prop = StateProperty(attr_name, value)
133
+ setattr(cls, attr_name, prop)
134
+ prop.__set_name__(cls, attr_name)
289
135
 
290
- return super().__new__(mcs, name, bases, namespace)
136
+ return cls
291
137
 
292
138
  @override
293
139
  def __call__(cls, *args: Any, **kwargs: Any):
@@ -106,6 +106,11 @@ from pulse.transpiler.nodes import While as While
106
106
  # Emit
107
107
  from pulse.transpiler.nodes import emit as emit
108
108
 
109
+ # Parse helpers
110
+ from pulse.transpiler.parse import ParsedSource as ParsedSource
111
+ from pulse.transpiler.parse import get_ast as get_ast
112
+ from pulse.transpiler.parse import get_source as get_source
113
+
109
114
  # Transpiler
110
115
  from pulse.transpiler.transpiler import Transpiler as Transpiler
111
116
  from pulse.transpiler.transpiler import transpile as transpile
@@ -7,8 +7,8 @@ and JsFunction which wraps transpiled functions with their dependencies.
7
7
  from __future__ import annotations
8
8
 
9
9
  import ast
10
+ import dis
10
11
  import inspect
11
- import textwrap
12
12
  import types as pytypes
13
13
  from collections.abc import Callable
14
14
  from dataclasses import dataclass, field
@@ -25,7 +25,6 @@ from typing import (
25
25
  override,
26
26
  )
27
27
 
28
- from pulse.helpers import getsourcecode
29
28
  from pulse.transpiler.errors import TranspileError
30
29
  from pulse.transpiler.id import next_id, reset_id_counter
31
30
  from pulse.transpiler.imports import Import
@@ -38,6 +37,7 @@ from pulse.transpiler.nodes import (
38
37
  Return,
39
38
  to_js_identifier,
40
39
  )
40
+ from pulse.transpiler.parse import clear_parse_cache, get_ast, get_source
41
41
  from pulse.transpiler.transpiler import Transpiler
42
42
  from pulse.transpiler.vdom import VDOMExpr
43
43
 
@@ -63,6 +63,7 @@ def clear_function_cache() -> None:
63
63
 
64
64
  FUNCTION_CACHE.clear()
65
65
  CONSTANT_REGISTRY.clear()
66
+ clear_parse_cache()
66
67
  clear_import_registry()
67
68
  clear_asset_registry()
68
69
  reset_id_counter()
@@ -137,33 +138,17 @@ def _transpile_function_body(
137
138
  deps: dict[str, Expr],
138
139
  *,
139
140
  jsx: bool = False,
140
- ) -> tuple[Function | Arrow, str]:
141
+ ) -> Function | Arrow:
141
142
  """Shared transpilation logic for JsFunction and JsxFunction.
142
143
 
143
- Returns the transpiled Function/Arrow node and the source code.
144
+ Returns the transpiled Function/Arrow node.
144
145
  """
145
146
  # Get and parse source
146
- src = getsourcecode(fn)
147
- src = textwrap.dedent(src)
148
- try:
149
- source_start_line = inspect.getsourcelines(fn)[1]
150
- except (OSError, TypeError):
151
- source_start_line = None
152
- module = ast.parse(src)
153
-
154
- # Find the function definition
155
- fndefs = [
156
- n for n in module.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
157
- ]
158
- if not fndefs:
159
- raise TranspileError("No function definition found in source")
160
- fndef = fndefs[-1]
161
-
162
- # Get filename for error messages and source file resolution
163
- try:
164
- filename = inspect.getfile(fn)
165
- except (TypeError, OSError):
166
- filename = None
147
+ parsed = get_source(fn)
148
+ src = parsed.source
149
+ fndef = get_ast(fn)
150
+ filename = parsed.filename
151
+ source_start_line = parsed.source_start_line
167
152
 
168
153
  # Transpile with source context for errors
169
154
  try:
@@ -181,7 +166,7 @@ def _transpile_function_body(
181
166
  ) from None
182
167
  raise
183
168
 
184
- return result, src
169
+ return result
185
170
 
186
171
 
187
172
  @dataclass(slots=True, init=False)
@@ -238,7 +223,7 @@ class JsFunction(Expr, Generic[*Args, R]):
238
223
  if self._transpiled is not None:
239
224
  return self._transpiled
240
225
 
241
- result, _ = _transpile_function_body(self.fn, self.deps)
226
+ result = _transpile_function_body(self.fn, self.deps)
242
227
 
243
228
  # Convert Arrow to Function if needed, and set the name
244
229
  if isinstance(result, Function):
@@ -326,7 +311,7 @@ class JsxFunction(Expr, Generic[P, R]):
326
311
  if self._transpiled is not None:
327
312
  return self._transpiled
328
313
 
329
- result, _ = _transpile_function_body(self.fn, self.deps, jsx=True)
314
+ result = _transpile_function_body(self.fn, self.deps, jsx=True)
330
315
 
331
316
  # JSX transpilation always returns Function (never Arrow)
332
317
  assert isinstance(result, Function), (
@@ -376,7 +361,6 @@ def analyze_code_object(
376
361
  - effective_globals: dict mapping names to their values (includes closure vars)
377
362
  - all_names: set of all names referenced in the code (including nested functions)
378
363
  """
379
- import dis
380
364
 
381
365
  code = fn.__code__
382
366
 
@@ -443,14 +427,54 @@ def analyze_deps(fn: Callable[..., Any]) -> dict[str, Expr]:
443
427
  """
444
428
  # Analyze code object and resolve globals + closure vars
445
429
  effective_globals, all_names = analyze_code_object(fn)
430
+ code_names = set(all_names)
431
+ default_names: set[str] = set()
432
+ default_name_values: dict[str, Any] = {}
433
+
434
+ # Include names referenced only in default expressions (not in bytecode)
435
+ try:
436
+ args = get_ast(fn).args
437
+ pos_defaults = list(args.defaults)
438
+ py_defaults = fn.__defaults__ or ()
439
+ num_args = len(args.args)
440
+ num_defaults = len(pos_defaults)
441
+ for i, _arg in enumerate(args.args):
442
+ default_idx = i - (num_args - num_defaults)
443
+ if default_idx < 0 or default_idx >= len(pos_defaults):
444
+ continue
445
+ default_node = pos_defaults[default_idx]
446
+ if isinstance(default_node, ast.Name) and default_idx < len(py_defaults):
447
+ default_name_values[default_node.id] = py_defaults[default_idx]
448
+ for node in ast.walk(default_node):
449
+ if isinstance(node, ast.Name):
450
+ default_names.add(node.id)
451
+
452
+ py_kwdefaults = fn.__kwdefaults__ or {}
453
+ for i, kwarg in enumerate(args.kwonlyargs):
454
+ default_node = args.kw_defaults[i]
455
+ if default_node is None:
456
+ continue
457
+ if isinstance(default_node, ast.Name) and kwarg.arg in py_kwdefaults:
458
+ default_name_values[default_node.id] = py_kwdefaults[kwarg.arg]
459
+ for node in ast.walk(default_node):
460
+ if isinstance(node, ast.Name):
461
+ default_names.add(node.id)
462
+ except (OSError, TypeError, SyntaxError, TranspileError):
463
+ pass
464
+
465
+ all_names.update(default_names)
466
+ default_only_names = default_names - code_names
446
467
 
447
468
  # Build dependencies dictionary - all values are Expr
448
469
  deps: dict[str, Expr] = {}
449
470
 
471
+ missing = object()
450
472
  for name in all_names:
451
- value = effective_globals.get(name)
452
-
453
- if value is None:
473
+ if name in default_only_names and name in default_name_values:
474
+ value = default_name_values[name]
475
+ else:
476
+ value = effective_globals.get(name, missing)
477
+ if value is missing:
454
478
  # Not in globals - could be a builtin or unresolved
455
479
  # For now, skip - builtins will be handled by the transpiler
456
480
  # TODO: Add builtin support
pulse/transpiler/nodes.py CHANGED
@@ -1150,6 +1150,15 @@ class Unary(Expr):
1150
1150
  out.append(" ")
1151
1151
  else:
1152
1152
  out.append(self.op)
1153
+ if (
1154
+ self.op in {"+", "-"}
1155
+ and isinstance(self.operand, Unary)
1156
+ and self.operand.op == self.op
1157
+ ):
1158
+ out.append("(")
1159
+ self.operand.emit(out)
1160
+ out.append(")")
1161
+ return
1153
1162
  _emit_paren(self.operand, self.op, "unary", out)
1154
1163
 
1155
1164
  @override
@@ -1508,7 +1517,7 @@ class If(Stmt):
1508
1517
 
1509
1518
  @dataclass(slots=True)
1510
1519
  class ForOf(Stmt):
1511
- """JS for-of loop: for (const x of iter) { ... }
1520
+ """JS for-of loop: for (x of iter) { ... }
1512
1521
 
1513
1522
  target can be a single name or array pattern for destructuring: [a, b]
1514
1523
  """
@@ -1519,7 +1528,7 @@ class ForOf(Stmt):
1519
1528
 
1520
1529
  @override
1521
1530
  def emit(self, out: list[str]) -> None:
1522
- out.append("for (const ")
1531
+ out.append("for (")
1523
1532
  out.append(self.target)
1524
1533
  out.append(" of ")
1525
1534
  self.iter.emit(out)
@@ -1574,17 +1583,32 @@ class Assign(Stmt):
1574
1583
  op: None for =, or "+", "-", etc. for augmented assignment
1575
1584
  """
1576
1585
 
1577
- target: str
1586
+ target: str | Identifier | Member | Subscript
1578
1587
  value: Expr
1579
1588
  declare: Lit["let", "const"] | None = None
1580
1589
  op: str | None = None # For augmented: +=, -=, etc.
1581
1590
 
1591
+ @staticmethod
1592
+ def _validate_target(target: object) -> None:
1593
+ if not isinstance(target, (str, Identifier, Member, Subscript)):
1594
+ raise TypeError(
1595
+ "Assign target must be str, Identifier, Member, or Subscript; "
1596
+ + f"got {type(target).__name__}: {target!r}"
1597
+ )
1598
+
1599
+ def __post_init__(self) -> None:
1600
+ self._validate_target(self.target)
1601
+
1582
1602
  @override
1583
1603
  def emit(self, out: list[str]) -> None:
1604
+ self._validate_target(self.target)
1584
1605
  if self.declare:
1585
1606
  out.append(self.declare)
1586
1607
  out.append(" ")
1587
- out.append(self.target)
1608
+ if isinstance(self.target, str):
1609
+ out.append(self.target)
1610
+ else:
1611
+ _emit_primary(self.target, out)
1588
1612
  if self.op:
1589
1613
  out.append(" ")
1590
1614
  out.append(self.op)
@@ -1595,6 +1619,21 @@ class Assign(Stmt):
1595
1619
  out.append(";")
1596
1620
 
1597
1621
 
1622
+ @dataclass(slots=True)
1623
+ class LetDecl(Stmt):
1624
+ """JS let declaration: let a, b;"""
1625
+
1626
+ names: Sequence[str]
1627
+
1628
+ @override
1629
+ def emit(self, out: list[str]) -> None:
1630
+ if not self.names:
1631
+ return
1632
+ out.append("let ")
1633
+ out.append(", ".join(self.names))
1634
+ out.append(";")
1635
+
1636
+
1598
1637
  @dataclass(slots=True)
1599
1638
  class ExprStmt(Stmt):
1600
1639
  """JS expression statement: expr;"""
@@ -0,0 +1,70 @@
1
+ """Cached parsing helpers for transpiler source inspection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import inspect
7
+ import textwrap
8
+ from collections.abc import Callable
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+
12
+ from pulse.helpers import getsourcecode
13
+ from pulse.transpiler.errors import TranspileError
14
+
15
+
16
+ @dataclass(slots=True)
17
+ class ParsedSource:
18
+ source: str
19
+ filename: str | None
20
+ source_start_line: int | None
21
+
22
+
23
+ _SOURCE_CACHE: dict[Callable[..., Any], ParsedSource] = {}
24
+ _AST_CACHE: dict[Callable[..., Any], ast.FunctionDef | ast.AsyncFunctionDef] = {}
25
+
26
+
27
+ def clear_parse_cache() -> None:
28
+ _SOURCE_CACHE.clear()
29
+ _AST_CACHE.clear()
30
+
31
+
32
+ def get_source(fn: Callable[..., Any]) -> ParsedSource:
33
+ cached = _SOURCE_CACHE.get(fn)
34
+ if cached is not None:
35
+ return cached
36
+
37
+ src = getsourcecode(fn)
38
+ src = textwrap.dedent(src)
39
+ try:
40
+ source_start_line = inspect.getsourcelines(fn)[1]
41
+ except (OSError, TypeError):
42
+ source_start_line = None
43
+ try:
44
+ filename = inspect.getfile(fn)
45
+ except (TypeError, OSError):
46
+ filename = None
47
+
48
+ parsed = ParsedSource(
49
+ source=src,
50
+ filename=filename,
51
+ source_start_line=source_start_line,
52
+ )
53
+ _SOURCE_CACHE[fn] = parsed
54
+ return parsed
55
+
56
+
57
+ def get_ast(fn: Callable[..., Any]) -> ast.FunctionDef | ast.AsyncFunctionDef:
58
+ cached = _AST_CACHE.get(fn)
59
+ if cached is not None:
60
+ return cached
61
+
62
+ module = ast.parse(get_source(fn).source)
63
+ fndefs = [
64
+ n for n in module.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
65
+ ]
66
+ if not fndefs:
67
+ raise TranspileError("No function definition found in source")
68
+ fndef = fndefs[-1]
69
+ _AST_CACHE[fn] = fndef
70
+ return fndef