bxengine 0.1.0__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.
bxengine/__about__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
bxengine/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .__about__ import __version__
2
+
3
+ __all__ = ["__version__"]
bxengine/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from bxengine.module import _module_main
2
+
3
+ if __name__ == "__main__":
4
+ _module_main()
bxengine/constants.py ADDED
@@ -0,0 +1,2 @@
1
+
2
+ ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
bxengine/exceptions.py ADDED
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from bxengine.spans import SpanData
4
+
5
+
6
+ class BxeRuntimeException(Exception):
7
+ def __init__(self, message: str = "", span: SpanData | None = None):
8
+ super().__init__(message)
9
+ self.span = span
10
+
11
+ class BxeSyntaxException(Exception):
12
+ pass
13
+
14
+ class BxeUnclosedStringException(BxeSyntaxException):
15
+ def __init__(self, position: int = -1):
16
+ super().__init__("Unclosed string")
17
+ self.position = position
18
+
19
+ class BxeRuntimeSyntaxException(BxeRuntimeException):
20
+ pass
bxengine/module.py ADDED
@@ -0,0 +1,72 @@
1
+ import argparse
2
+ import sys
3
+
4
+ from bxengine.runtime.extensions.BxeExtension import GlobalVariableBppExtension
5
+ from bxengine.runtime.extensions.discord_stub import DiscordStubExtension
6
+ from bxengine.tokenizer.tokenize import Tokenizer, TokenizationResult
7
+ from bxengine.parsing.parser import Parser, ParsingResult
8
+ from bxengine.runtime.executor import Executor, ExecutorResult
9
+ from bxengine.runtime.extensions.builtin import BuiltinExtension
10
+
11
+
12
+ DEFAULT_TEST = """
13
+ [GLOBAL DEFINE descentNumber 100]
14
+ [GLOBAL DEFINE descentAttempts 0]
15
+ [GLOBAL DEFINE descentHighscore 15]
16
+
17
+ [DEFINE number [GLOBAL VAR descentNumber]]
18
+ [DEFINE attempts [GLOBAL VAR descentAttempts]]
19
+ [DEFINE nextNumber [RANDINT 0 [MATH [VAR number] + 1]]]
20
+ [IF [COMPARE [VAR nextNumber] != 0] [CONCAT "The number has gone from **" [VAR number] "** to **" [VAR nextNumber] "**. The current number of tries in this run is **" [MATH [VAR attempts] + 1] "**!
21
+ " [IF [COMPARE [GLOBAL VAR descentHighscore] < [MATH [VAR attempts] + 1]] [CONCAT "You've successfully beaten the highscore of **" [GLOBAL VAR descentHighscore] "**, but you're still going! Good luck!"] [CONCAT "The highscore to beat is **" [GLOBAL VAR descentHighscore] "**."]]] [CONCAT "Uh oh, looks like this run has finally come to an end! You had **" [MATH [VAR attempts] + 1] "** attempts.
22
+ " [IF [COMPARE [GLOBAL VAR descentHighscore] < [MATH [VAR atoh
23
+ yetempts] + 1]] [CONCAT "But I'm pleased to say you beat the highscore of **" [GLOBAL VAR descentHighscore] "** with your new score of **" [MATH [VAR attempts] + 1] "**! Well done!"] [CONCAT "Unfortunately, you failed to beat the highscore of **" [GLOBAL VAR descentHighscore] "**. I'm sure next attempt will be more promising, though."]]]]
24
+ [IF [COMPARE [VAR nextNumber] != 0] [CONCAT [GLOBAL DEFINE descentAttempts [MATH [VAR attempts] + 1]] [GLOBAL DEFINE descentNumber [VAR nextNumber]]] [CONCAT [IF [COMPARE [GLOBAL VAR descentHighscore] < [MATH [VAR attempts] + 1]] [GLOBAL DEFINE descentHighscore [MATH [VAR attempts] + 1]]] [GLOBAL DEFINE descentAttempts 0] [GLOBAL DEFINE descentNumber 100]]
25
+ """
26
+
27
+
28
+ def run_code(code: str, program_args: list[str] | None = None) -> None:
29
+ tokenizer_res = Tokenizer.tokenize(code)
30
+ if isinstance(tokenizer_res, TokenizationResult.Error):
31
+ print(tokenizer_res.message, "\n\n", tokenizer_res.range.debug_info(), sep="")
32
+ return
33
+
34
+ parser_res = Parser.parse(code, tokenizer_res.tokens)
35
+ if isinstance(parser_res, ParsingResult.Error):
36
+ print(parser_res.message, "\n\n", parser_res.range.debug_info(), sep="")
37
+ return
38
+
39
+ executor = Executor(
40
+ extensions=[BuiltinExtension()],
41
+ stateful_extensions=[GlobalVariableBppExtension, DiscordStubExtension],
42
+ program_args=program_args or [],
43
+ )
44
+ result = executor.execute(parser_res.nodes)
45
+
46
+ if isinstance(result, ExecutorResult.Error):
47
+ exception = result.exception
48
+ print(type(exception).__name__ + ":", exception, file=sys.stderr)
49
+ span = getattr(exception, "span", None)
50
+ if span is not None:
51
+ print("", file=sys.stderr)
52
+ print(span.debug_info(), file=sys.stderr)
53
+ sys.exit(1)
54
+ else:
55
+ print(result.output.strip(), end="\n")
56
+
57
+
58
+ def _module_main():
59
+ parser = argparse.ArgumentParser(prog="bxengine", description="B++ runtime engine")
60
+ parser.add_argument("file", nargs="?", help="Path to a .bx script file")
61
+ parser.add_argument("-e", "--eval", metavar="CODE", help="Execute a string of B++ code")
62
+ parser.add_argument("args", nargs="*", help="Arguments passed to the script (accessible via ARGS)")
63
+
64
+ parsed = parser.parse_args()
65
+
66
+ if parsed.eval:
67
+ run_code(parsed.eval, parsed.args)
68
+ elif parsed.file:
69
+ with open(parsed.file) as f:
70
+ run_code(f.read(), parsed.args)
71
+ else:
72
+ run_code(DEFAULT_TEST, parsed.args)
File without changes
@@ -0,0 +1,40 @@
1
+ from dataclasses import dataclass
2
+ from typing import List
3
+ from bxengine.spans import SpanData
4
+
5
+ class Node:
6
+ pass
7
+
8
+ class Nodes:
9
+ @dataclass(frozen=True)
10
+ class Error(Node):
11
+ message: str
12
+ range: SpanData
13
+
14
+ """
15
+ This is only used internally, package users do not need to handle _Complete.
16
+ """
17
+ @dataclass(frozen=True)
18
+ class _Complete(Node):
19
+ range: SpanData
20
+
21
+ @dataclass(frozen=True)
22
+ class OuterText(Node):
23
+ value: str
24
+ range: SpanData
25
+
26
+ @dataclass(frozen=True)
27
+ class Function(Node):
28
+ name: str
29
+ arguments: List[Node]
30
+ range: SpanData
31
+
32
+ @dataclass(frozen=True)
33
+ class Number(Node):
34
+ value: str
35
+ range: SpanData
36
+
37
+ @dataclass(frozen=True)
38
+ class StringNode(Node):
39
+ value: str
40
+ range: SpanData
@@ -0,0 +1,204 @@
1
+ from bxengine.parsing.nodes import Node, Nodes
2
+ from typing import List, Tuple, Sequence
3
+ from bxengine.tokenizer.tokens import Token, Tokens
4
+ from bxengine.spans import SpanData
5
+ from dataclasses import dataclass
6
+ import functools
7
+
8
+
9
+ class ParsingResult:
10
+ @dataclass(frozen=True)
11
+ class Error:
12
+ message: str
13
+ range: SpanData
14
+
15
+ @dataclass(frozen=True)
16
+ class Success:
17
+ nodes: List[Node]
18
+
19
+ def pretty(self) -> str:
20
+ string = f"{len(self.nodes)} nodes:"
21
+ for node in self.nodes:
22
+ string += "\n"
23
+ string += str(node)
24
+
25
+ return string
26
+
27
+
28
+ class Parser:
29
+ _identity_cache: dict[int, tuple[str, Sequence[Token], ParsingResult.Success | ParsingResult.Error]] = {}
30
+ _identity_cache_limit: int = 512
31
+
32
+ def __init__(self):
33
+ self.nesting_level: int = 0
34
+ self.nesting_token_indexes: List[int] = []
35
+ self.contents: str = ""
36
+ self.token_list: Sequence[Token] = ()
37
+ self.is_function_declaration: bool = False
38
+ self.index: int = 0
39
+
40
+ @staticmethod
41
+ def parse(contents: str, token_list: Sequence[Token]) -> ParsingResult.Success | ParsingResult.Error:
42
+ # Fast path for repeated runs with the same token list object (common when
43
+ # tokenization cache hits across multiple executions in one process).
44
+ cache_key = id(token_list)
45
+ cached = Parser._identity_cache.get(cache_key)
46
+ if cached is not None:
47
+ cached_contents, cached_tokens, cached_result = cached
48
+ if cached_contents == contents and cached_tokens is token_list:
49
+ return cached_result
50
+
51
+ result = Parser._parse_cache(contents, tuple(token_list))
52
+
53
+ if len(Parser._identity_cache) >= Parser._identity_cache_limit:
54
+ Parser._identity_cache.clear()
55
+ Parser._identity_cache[cache_key] = (contents, token_list, result)
56
+ return result
57
+
58
+ @staticmethod
59
+ @functools.lru_cache(maxsize=128, typed=False)
60
+ def _parse_cache(contents: str, token_list: Tuple[Token]) -> ParsingResult.Success | ParsingResult.Error:
61
+ parser = Parser()
62
+ parser.contents = contents
63
+ parser.token_list = token_list
64
+ parser.nesting_level = 0
65
+ parser.nesting_token_indexes = []
66
+ parser.index = 0
67
+ parser.is_function_declaration = False
68
+
69
+ return parser._parse_loop()
70
+
71
+ def _parse_loop(self) -> ParsingResult.Success | ParsingResult.Error:
72
+ node_list = []
73
+ while True:
74
+ node = self._parse_once()
75
+
76
+ if isinstance(node, Nodes.Error):
77
+ return ParsingResult.Error(node.message, node.range)
78
+ elif isinstance(node, Nodes._Complete):
79
+ return ParsingResult.Success(node_list)
80
+
81
+ node_list.append(node)
82
+
83
+ def _parse_once(self) -> Node:
84
+ return self._parse_inner()
85
+
86
+ def _parse_inner(self) -> Node:
87
+ token = self.token_list[self.index]
88
+
89
+ if isinstance(token, Tokens.EndOfFile):
90
+ if self.nesting_level > 0:
91
+ bracket_index = self.nesting_token_indexes[-1]
92
+ return Nodes.Error("Unclosed bracket.", self.token_list[bracket_index].range)
93
+ else:
94
+ return Nodes._Complete(token.range)
95
+
96
+ if isinstance(token, Tokens.Error):
97
+ self.index += 1
98
+ return Nodes.Error(token.message, token.range)
99
+
100
+ if self.nesting_level == 0:
101
+ match token:
102
+ case Tokens.OuterString():
103
+ self.index += 1
104
+ return Nodes.OuterText(token.value, token.range)
105
+ case Tokens.OpenBracket():
106
+ return self._parse_function()
107
+ case _:
108
+ self.index += 1
109
+ text = self.contents[token.range.cursor_start:token.range.cursor_end]
110
+ return Nodes.Error(
111
+ f"Unexpected token '{text}' at top level. Only text and function calls are allowed.",
112
+ token.range
113
+ )
114
+ else:
115
+ self.index += 1
116
+ return Nodes.Error("Parser bug: `_parse_inner` called while (somehow??) nested. Please report this!",
117
+ token.range)
118
+
119
+ def _parse_function(self) -> Node:
120
+ open_bracket = self.token_list[self.index] # this is always an OpenBracket
121
+ self.index += 1 # consume
122
+
123
+ self.nesting_level += 1
124
+ self.nesting_token_indexes.append(self.index - 1)
125
+
126
+ if self.index >= len(self.token_list):
127
+ self.nesting_level -= 1
128
+ self.nesting_token_indexes.pop()
129
+ # Equivalent to empty brackets `[]` due to auto-close.
130
+ return Nodes.Error("Function call cannot be empty `[]`.", self.create_span(open_bracket, open_bracket))
131
+
132
+ name_token = self.token_list[self.index]
133
+
134
+ # BPPCOMPAT: Close on EndOfFile just like a CloseBracket
135
+ if isinstance(name_token, (Tokens.CloseBracket, Tokens.EndOfFile)):
136
+ if isinstance(name_token, Tokens.CloseBracket):
137
+ self.index += 1
138
+ self.nesting_level -= 1
139
+ self.nesting_token_indexes.pop()
140
+ return Nodes.Error("Function call cannot be empty `[]`.", self.create_span(open_bracket, name_token))
141
+
142
+ if not isinstance(name_token, Tokens.UnquotedString):
143
+ self.nesting_level -= 1
144
+ self.nesting_token_indexes.pop()
145
+ return Nodes.Error("Function name must be an unquoted string.", name_token.range)
146
+
147
+ self.index += 1 # consume
148
+
149
+ function_name = name_token.value
150
+ arguments = []
151
+
152
+ while True:
153
+ if self.index >= len(self.token_list):
154
+ self.nesting_level -= 1
155
+ self.nesting_token_indexes.pop()
156
+ last_token = self.token_list[-1] if self.token_list else open_bracket
157
+ return Nodes.Function(function_name, arguments, self.create_span(open_bracket, last_token))
158
+
159
+ current = self.token_list[self.index]
160
+
161
+ # BPPCOMPAT: Close on EndOfFile just like a CloseBracket
162
+ if isinstance(current, (Tokens.CloseBracket, Tokens.EndOfFile)):
163
+ if isinstance(current, Tokens.CloseBracket):
164
+ self.index += 1
165
+ self.nesting_level -= 1
166
+ self.nesting_token_indexes.pop()
167
+ return Nodes.Function(function_name, arguments, self.create_span(open_bracket, current))
168
+
169
+ argument = self._parse_expression()
170
+ if isinstance(argument, Nodes.Error):
171
+ return argument
172
+
173
+ arguments.append(argument)
174
+
175
+ def _parse_expression(self) -> Node:
176
+ token = self.token_list[self.index]
177
+
178
+ if isinstance(token, Tokens.Error):
179
+ self.index += 1
180
+ return Nodes.Error(token.message, token.range)
181
+
182
+ match token:
183
+ case Tokens.OpenBracket():
184
+ return self._parse_function()
185
+ case Tokens.Number():
186
+ self.index += 1
187
+ return Nodes.Number(token.value, token.range)
188
+ case Tokens.QuotedString():
189
+ self.index += 1
190
+ return Nodes.StringNode(token.value, token.range)
191
+ case Tokens.UnquotedString():
192
+ self.index += 1
193
+ return Nodes.StringNode(token.value, token.range)
194
+ case Tokens.EndOfFile():
195
+ bracket_index = self.nesting_token_indexes[-1]
196
+ return Nodes.Error("Parser bug: `_parse_expression` called and hit end of file (should be impossible...!)", self.token_list[bracket_index].range)
197
+ case _:
198
+ self.index += 1
199
+ return Nodes.Error("Unexpected token while parsing arguments.", token.range)
200
+
201
+ def create_span(self, start: Token, end: Token) -> SpanData:
202
+ start_int = start.range.cursor_start
203
+ end_int = end.range.cursor_end
204
+ return SpanData(start_int, end_int, self.contents)
@@ -0,0 +1,4 @@
1
+ from bxengine.runtime.context import RuntimeContext
2
+ from bxengine.runtime.executor import Executor, ExecutorResult, FunctionEntry
3
+
4
+ __all__ = ["RuntimeContext", "Executor", "ExecutorResult", "FunctionEntry"]
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from bxengine.runtime.executor import Executor, FunctionEntry
8
+
9
+
10
+ @dataclass
11
+ class RuntimeContext:
12
+ local_variables: dict[str, Any] = field(default_factory=dict)
13
+ program_args: list[Any] = field(default_factory=list)
14
+ executor: Executor | None = None
15
+ functions: dict[str, FunctionEntry] = field(default_factory=dict)
@@ -0,0 +1,315 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import types
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Callable, Optional, get_origin, get_args, Union
7
+
8
+ from bxengine.exceptions import BxeRuntimeException
9
+ from bxengine.parsing.nodes import Node, Nodes
10
+ from bxengine.runtime.context import RuntimeContext
11
+ from bxengine.runtime.extensions.BxeExtension import (
12
+ BxeExtensionBase,
13
+ BxeStatefulExtension,
14
+ )
15
+ from bxengine.spans import SpanData
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class FunctionEntry:
20
+ func: Callable
21
+ is_node_transformer: bool
22
+ extension_instance: BxeExtensionBase | None
23
+ parameter_annotations: tuple[Any, ...]
24
+ coercion_annotations: tuple[Any | None, ...]
25
+ context_parameter_index: int | None
26
+ node_transformer_accepts_context: bool
27
+
28
+
29
+ class ExecutorResult:
30
+ @dataclass(frozen=True)
31
+ class Success:
32
+ output: str
33
+ stateful_extensions: list[BxeStatefulExtension] = field(default_factory=list)
34
+
35
+ @dataclass(frozen=True)
36
+ class Error:
37
+ exception: Exception
38
+
39
+
40
+ def _safe_cut(s: Any, num: int = 15) -> str:
41
+ return str(s)[:num] + ("..." if len(str(s)) > num else "")
42
+
43
+
44
+ def _is_number(v: Any) -> bool:
45
+ try:
46
+ float(v)
47
+ return True
48
+ except (ValueError, TypeError):
49
+ return False
50
+
51
+
52
+ def _is_whole(v: Any) -> bool:
53
+ try:
54
+ i = int(v)
55
+ f = float(v)
56
+ return f - i == 0
57
+ except (ValueError, TypeError):
58
+ return False
59
+
60
+
61
+ def _is_optional(target_type):
62
+ origin = get_origin(target_type)
63
+ if origin is Union or origin is types.UnionType:
64
+ return type(None) in get_args(target_type)
65
+ return False
66
+
67
+ def _extract_non_optional(typ):
68
+ if _is_optional(typ):
69
+ args = [arg for arg in get_args(typ) if arg is not type(None)]
70
+ return args[0] if len(args) == 1 else Union[tuple(args)]
71
+ return typ
72
+
73
+ def _scan_extension(ext: BxeExtensionBase) -> list[tuple[str, FunctionEntry]]:
74
+ entries: list[tuple[str, FunctionEntry]] = []
75
+ for attr_name in dir(ext):
76
+ if attr_name.startswith("_"):
77
+ continue
78
+ attr = getattr(ext, attr_name, None)
79
+ if attr is None:
80
+ continue
81
+ if callable(attr) and getattr(attr, "_is_bpp_function", False):
82
+ name = getattr(attr, "_bpp_function_name", attr_name).upper()
83
+ is_node_transformer = getattr(attr, "_node_transformer", False)
84
+ sig = inspect.signature(attr)
85
+ parameters = list(sig.parameters.values())
86
+ parameter_annotations = tuple(p.annotation for p in parameters)
87
+ coercion_annotations = tuple(
88
+ (
89
+ None
90
+ if (ann is inspect.Parameter.empty or ann is Any or ann is object)
91
+ else ann
92
+ )
93
+ for ann in parameter_annotations
94
+ )
95
+ context_parameter_index = next(
96
+ (i for i, p in enumerate(parameters) if p.name == "context"),
97
+ None,
98
+ )
99
+ entries.append(
100
+ (
101
+ name,
102
+ FunctionEntry(
103
+ func=attr,
104
+ is_node_transformer=is_node_transformer,
105
+ extension_instance=ext,
106
+ parameter_annotations=parameter_annotations,
107
+ coercion_annotations=coercion_annotations,
108
+ context_parameter_index=context_parameter_index,
109
+ node_transformer_accepts_context=(
110
+ is_node_transformer and context_parameter_index is not None
111
+ ),
112
+ ),
113
+ )
114
+ )
115
+ return entries
116
+
117
+
118
+ class Executor:
119
+ def __init__(
120
+ self,
121
+ extensions: list[BxeExtensionBase] | None = None,
122
+ stateful_extensions: list[type[BxeStatefulExtension]] | None = None,
123
+ program_args: list[Any] | None = None,
124
+ ):
125
+ self._program_args = program_args or []
126
+
127
+ self._stateless_functions: dict[str, FunctionEntry] = {}
128
+ for ext in extensions or []:
129
+ for name, entry in _scan_extension(ext):
130
+ self._stateless_functions[name] = entry
131
+
132
+ self._stateful_classes: list[type[BxeStatefulExtension]] = list(
133
+ stateful_extensions or []
134
+ )
135
+
136
+ def execute(self, nodes: list[Node]) -> ExecutorResult.Success | ExecutorResult.Error:
137
+ functions = dict(self._stateless_functions)
138
+ stateful_instances: list[BxeStatefulExtension] = []
139
+
140
+ for cls in self._stateful_classes:
141
+ instance = cls()
142
+ stateful_instances.append(instance)
143
+ for name, entry in _scan_extension(instance):
144
+ functions[name] = entry
145
+
146
+ context = RuntimeContext(
147
+ local_variables={},
148
+ program_args=self._program_args,
149
+ executor=self,
150
+ functions=functions,
151
+ )
152
+
153
+ for inst in stateful_instances:
154
+ inst.post_parse_hook(nodes)
155
+
156
+ output_parts: list[str] = []
157
+ try:
158
+ for node in nodes:
159
+ if isinstance(node, Nodes.OuterText):
160
+ output_parts.append(node.value)
161
+ elif isinstance(node, Nodes.Function):
162
+ result = self._evaluate_function(node, context)
163
+ output_parts.append(self._format_result(result))
164
+ except Exception as e:
165
+ return ExecutorResult.Error(exception=e)
166
+
167
+ return ExecutorResult.Success(
168
+ output="".join(output_parts),
169
+ stateful_extensions=stateful_instances,
170
+ )
171
+
172
+ def _evaluate_function(self, node: Nodes.Function, context: RuntimeContext) -> Any:
173
+ try:
174
+ func_name = node.name.upper()
175
+
176
+ entry = context.functions.get(func_name)
177
+
178
+ if entry is None:
179
+ raise NameError(f"Function {node.name} does not exist")
180
+
181
+ if entry.is_node_transformer:
182
+ return self._call_node_transformer(entry, node, context)
183
+
184
+ evaluated_args: list[Any] = []
185
+ for arg in node.arguments:
186
+ evaluated_args.append(self._evaluate_node(arg, context))
187
+
188
+ coerced_args = self._coerce_args(entry, evaluated_args)
189
+ final_args, kwargs = self._inject_context(entry, coerced_args, context)
190
+
191
+ return entry.func(*final_args, **kwargs)
192
+ except Exception as e:
193
+ self._attach_span_if_missing(e, node.range)
194
+ raise
195
+
196
+ def _evaluate_node(self, node: Node, context: RuntimeContext) -> Any:
197
+ if isinstance(node, Nodes.Function):
198
+ return self._evaluate_function(node, context)
199
+ elif isinstance(node, Nodes.Number):
200
+ return self._parse_number(node.value)
201
+ elif isinstance(node, Nodes.StringNode):
202
+ return node.value
203
+ elif isinstance(node, Nodes.OuterText):
204
+ return node.value
205
+ else:
206
+ raise BxeRuntimeException(f"Unexpected node type: {type(node).__name__}")
207
+
208
+ def _call_node_transformer(
209
+ self, entry: FunctionEntry, node: Nodes.Function, context: RuntimeContext
210
+ ) -> Any:
211
+ try:
212
+ if entry.node_transformer_accepts_context:
213
+ result = entry.func(node.arguments, node.range, context=context)
214
+ else:
215
+ result = entry.func(node.arguments, node.range)
216
+ except Exception as e:
217
+ self._attach_span_if_missing(e, node.range)
218
+ raise
219
+
220
+ if isinstance(result, Node):
221
+ return self._evaluate_node(result, context)
222
+ return result
223
+
224
+ @staticmethod
225
+ def _attach_span_if_missing(exception: Exception, span: SpanData) -> None:
226
+ existing = getattr(exception, "span", None)
227
+ if existing is None:
228
+ try:
229
+ setattr(exception, "span", span)
230
+ except Exception:
231
+ pass
232
+
233
+ def _coerce_args(self, entry: FunctionEntry, args: list[Any]) -> list[Any]:
234
+ coercion_annotations = entry.coercion_annotations
235
+ if not coercion_annotations:
236
+ return args
237
+
238
+ coerced: list[Any] = []
239
+ for i, arg in enumerate(args):
240
+ if i < len(coercion_annotations) and coercion_annotations[i] is not None:
241
+ coerced.append(self._coerce_value(arg, coercion_annotations[i]))
242
+ else:
243
+ coerced.append(arg)
244
+ return coerced
245
+
246
+ @staticmethod
247
+ def _coerce_value(value: Any, target_type: type) -> Any:
248
+ #print(value, target_type)
249
+ if value is None:
250
+ #print("none!")
251
+ return value
252
+ if _is_optional(target_type):
253
+ if value == "":
254
+ #print("emptystr -> none!")
255
+ return None
256
+ target_type = _extract_non_optional(target_type)
257
+ if target_type is float and isinstance(value, int):
258
+ #print("int -> float!")
259
+ return float(value)
260
+ if target_type is int and isinstance(value, float):
261
+ #print("float -> int!")
262
+ return int(value)
263
+ if target_type == str | list or target_type == Union[str, list]:
264
+ if isinstance(value, list):
265
+ #print("str | list -> list!")
266
+ return value
267
+ #print("str | list -> str!")
268
+ return str(value)
269
+ if target_type is str:
270
+ #print("str!")
271
+ return str(value)
272
+ if target_type in (int, float) and isinstance(value, str):
273
+ try:
274
+ return target_type(value)
275
+ except (ValueError, TypeError):
276
+ return value
277
+
278
+ return value
279
+
280
+ @staticmethod
281
+ def _inject_context(
282
+ entry: FunctionEntry, args: list[Any], context: RuntimeContext
283
+ ) -> tuple[list[Any], dict[str, Any]]:
284
+ context_idx = entry.context_parameter_index
285
+ if context_idx is not None:
286
+ if len(args) <= context_idx:
287
+ return args, {"context": context}
288
+ return args, {}
289
+ return args, {}
290
+
291
+ @staticmethod
292
+ def _parse_number(value: str) -> str:
293
+ # BPPCOMPAT:
294
+ # The old engine keeps bare numeric-looking literals as raw strings
295
+ # unless a function explicitly converts them.
296
+ return value
297
+
298
+ @staticmethod
299
+ def _format_result(value: Any) -> str:
300
+ if value is None:
301
+ return ""
302
+ if isinstance(value, list):
303
+ return Executor._express_array(value)
304
+ return str(value)
305
+
306
+ @staticmethod
307
+ def _express_array(l: list) -> str:
308
+ str_form = " ".join(['"' + str(a) + '"' for a in l])
309
+ return f"[ARRAY {str_form}]"
310
+
311
+ def evaluate_node(self, node: Node, context: RuntimeContext) -> Any:
312
+ return self._evaluate_node(node, context)
313
+
314
+ if __name__ == "__main__":
315
+ print(_extract_non_optional(int | str | None))