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 +1 -0
- bxengine/__init__.py +3 -0
- bxengine/__main__.py +4 -0
- bxengine/constants.py +2 -0
- bxengine/exceptions.py +20 -0
- bxengine/module.py +72 -0
- bxengine/parsing/__init__.py +0 -0
- bxengine/parsing/nodes.py +40 -0
- bxengine/parsing/parser.py +204 -0
- bxengine/runtime/__init__.py +4 -0
- bxengine/runtime/context.py +15 -0
- bxengine/runtime/executor.py +315 -0
- bxengine/runtime/extensions/BxeExtension.py +72 -0
- bxengine/runtime/extensions/__init__.py +15 -0
- bxengine/runtime/extensions/builtin.py +586 -0
- bxengine/runtime/extensions/discord_stub.py +25 -0
- bxengine/spans.py +136 -0
- bxengine/tokenizer/__init__.py +0 -0
- bxengine/tokenizer/stringreader.py +163 -0
- bxengine/tokenizer/tokenize.py +188 -0
- bxengine/tokenizer/tokens.py +45 -0
- bxengine-0.1.0.dist-info/METADATA +46 -0
- bxengine-0.1.0.dist-info/RECORD +26 -0
- bxengine-0.1.0.dist-info/WHEEL +5 -0
- bxengine-0.1.0.dist-info/entry_points.txt +2 -0
- bxengine-0.1.0.dist-info/top_level.txt +1 -0
bxengine/__about__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
bxengine/__init__.py
ADDED
bxengine/__main__.py
ADDED
bxengine/constants.py
ADDED
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,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))
|