flowscript-vm 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.
flowscript/__init__.py ADDED
@@ -0,0 +1,35 @@
1
+ """FlowScript: an event-driven scripting language whose programs are flowcharts.
2
+
3
+ Compile a script, run it (from source or bytecode), and draw it:
4
+
5
+ import flowscript
6
+
7
+ program = flowscript.compile(source) # flowscript.CompileError on errors
8
+ machine = flowscript.Machine(program, externs={"log": print})
9
+ machine.start()
10
+ machine.send("tick")
11
+ svg = flowscript.to_svg(program, state=machine.state)
12
+
13
+ See https://github.com/RubenNorgaard/flowscript for the language (SPEC.md) and the C VM.
14
+ """
15
+
16
+ from .api import compile, load, Machine, CompileError, Diagnostic, Fault
17
+ from .svg import to_svg
18
+ from .bytecode import Program, disassemble, disassemble_text
19
+ from .lexer import FlowError
20
+ from .parser import parse, parse_expression
21
+ from .printer import to_source, expr_to_str
22
+ from .compiler import compile_source, compile_ast
23
+ from .mermaid import to_mermaid
24
+ from .sizes import size_report, format_report
25
+ from .vm import VM
26
+
27
+ __all__ = [
28
+ # the host API
29
+ "compile", "load", "Machine", "to_svg", "Program", "CompileError", "Diagnostic", "Fault",
30
+ # tools
31
+ "to_source", "to_mermaid", "size_report", "format_report", "disassemble", "disassemble_text",
32
+ # lower level
33
+ "FlowError", "parse", "parse_expression", "expr_to_str", "compile_source", "compile_ast", "VM",
34
+ ]
35
+ __version__ = "0.1.0"
flowscript/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ import sys
2
+ from .cli import main
3
+
4
+ sys.exit(main())
flowscript/api.py ADDED
@@ -0,0 +1,286 @@
1
+ """The host-facing API: compile, load, run.
2
+
3
+ import flowscript
4
+
5
+ program = flowscript.compile(source) # raises flowscript.CompileError
6
+ program.warnings # [Diagnostic(line, col, message)]
7
+ data = program.to_bytes() # the .fsb image (also: program.save(path))
8
+
9
+ program = flowscript.load(data) # bytes, a path or a binary file
10
+
11
+ machine = flowscript.Machine(program, externs={"param": params.get, ...}, clock=time.time)
12
+ machine.start() # boot + enter the initial state
13
+ machine.send("tick") # post an event and run until idle
14
+ machine.state, machine["aggression"] # current state, a global
15
+
16
+ Values crossing the boundary are plain Python: int, float, str and timezone-aware
17
+ ``datetime`` (FlowScript ``time``). Hosts never see the VM's internal representations.
18
+ """
19
+
20
+ import datetime as _dt
21
+ import os
22
+
23
+ from .bytecode import Program, FAULTS
24
+ from .compiler import compile_ast
25
+ from .lexer import FlowError
26
+ from .parser import parse
27
+ from . import values as V
28
+ from .vm import VM
29
+
30
+ __all__ = ["compile", "load", "Machine", "CompileError", "Diagnostic", "Fault"]
31
+
32
+ _EPOCH = _dt.datetime(1970, 1, 1, tzinfo=_dt.timezone.utc)
33
+
34
+
35
+ class CompileError(FlowError):
36
+ """The source has an error. ``line`` and ``col`` (1-based, 0 = unknown) locate it."""
37
+
38
+
39
+ class Diagnostic:
40
+ """A compiler warning: the program compiles, but probably not as intended."""
41
+ __slots__ = ("line", "col", "message")
42
+
43
+ def __init__(self, line, col, message):
44
+ self.line, self.col, self.message = line, col, message
45
+
46
+ def __repr__(self):
47
+ return "Diagnostic(line=%d, %r)" % (self.line, self.message)
48
+
49
+ def __str__(self):
50
+ return "line %d: %s" % (self.line, self.message)
51
+
52
+
53
+ class Fault:
54
+ """A run-time fault: the handler it happened in was aborted, the machine carries on."""
55
+ __slots__ = ("code", "name", "pc", "line", "handler")
56
+
57
+ def __init__(self, code, pc, line, handler):
58
+ self.code, self.pc, self.line, self.handler = code, pc, line, handler
59
+ self.name = FAULTS.get(code, "fault %d" % code)
60
+
61
+ def __repr__(self):
62
+ where = ("line %d" % self.line) if self.line else ("pc %d" % self.pc)
63
+ return "Fault(%d %s, %s%s)" % (self.code, self.name, where,
64
+ ", in %s" % self.handler if self.handler else "")
65
+
66
+ __str__ = __repr__
67
+
68
+
69
+ # ------------------------------------------------------------------------------ compile / load
70
+ def compile(source, *, filename=None):
71
+ """Compile FlowScript source to a ``Program``.
72
+
73
+ The program keeps what hosts need besides the bytecode: ``source``, ``ast`` (for
74
+ ``flowscript.to_svg``), ``warnings`` (a list of ``Diagnostic``) and debug information
75
+ (source lines of faults, global names). Raises ``CompileError``.
76
+ """
77
+ try:
78
+ ast = parse(source)
79
+ prog, dbg = compile_ast(ast)
80
+ except FlowError as e:
81
+ err = CompileError(e.msg, e.line, e.col)
82
+ err.filename = filename
83
+ raise err from None
84
+ prog.source = source
85
+ prog.ast = ast
86
+ prog.debug_info = dbg
87
+ prog.warnings = [Diagnostic(w["line"], w.get("col", 0), w["msg"]) for w in dbg.warnings]
88
+ return prog
89
+
90
+
91
+ def load(data):
92
+ """A ``Program`` from bytecode: bytes/bytearray/memoryview, a path, or a binary file object.
93
+
94
+ Raises ``ValueError`` if the data is not a valid FlowScript image.
95
+ """
96
+ if isinstance(data, (bytes, bytearray, memoryview)):
97
+ raw = bytes(data)
98
+ elif isinstance(data, (str, os.PathLike)):
99
+ with open(data, "rb") as f:
100
+ raw = f.read()
101
+ elif hasattr(data, "read"):
102
+ raw = data.read()
103
+ else:
104
+ raise TypeError("load() takes bytes, a path or a binary file, not %s" % type(data).__name__)
105
+ return Program.from_bytes(raw)
106
+
107
+
108
+ # ------------------------------------------------------------------------------ values
109
+ def _to_py(v):
110
+ if isinstance(v, V.Str):
111
+ return v.text
112
+ if isinstance(v, V.Time):
113
+ return _EPOCH + _dt.timedelta(milliseconds=v.ms)
114
+ return v
115
+
116
+
117
+ def _to_vm(v):
118
+ if isinstance(v, _dt.datetime):
119
+ if v.tzinfo is None:
120
+ raise ValueError("FlowScript times are UTC: pass a timezone-aware datetime")
121
+ delta = v - _EPOCH
122
+ return V.Time((delta.days * 86400 + delta.seconds) * 1000 + delta.microseconds // 1000)
123
+ if isinstance(v, bool):
124
+ return int(v)
125
+ return v
126
+
127
+
128
+ # ------------------------------------------------------------------------------ machine
129
+ class Machine:
130
+ """A running FlowScript program.
131
+
132
+ program: a ``Program`` (``compile``/``load``) or bytecode bytes.
133
+ externs: {name: callable(*args)} for the program's ``extern`` declarations. Arguments and
134
+ return values are int, float, str or timezone-aware datetime; an int is accepted
135
+ where a float is declared. Every extern must be provided (``ValueError`` otherwise).
136
+ clock: callable() -> seconds since 1970 (as ``time.time``) or a datetime; feeds ``now()``.
137
+ Default: ``time.time``.
138
+ on_event: callable(name, args) for every ``raise_event`` (args as Python values), before
139
+ the event is queued for the script itself.
140
+ on_fault: callable(Fault). Faults are also kept in ``faults`` (the last 100).
141
+ on_timer: None: timers run inside the machine, driven by ``advance(ms)``. Or
142
+ callable(name, start, ms, periodic): the host runs the timers (start=False stops
143
+ one) and calls ``timer_expired(name)`` when one fires.
144
+ limits: VM limits (stack_size, queue_size, budget, str_slots, str_len, ...), defaulting
145
+ to the C VM's defaults so a script that runs here also runs there.
146
+ """
147
+
148
+ def __init__(self, program, *, externs=None, clock=None, on_event=None, on_fault=None,
149
+ on_timer=None, **limits):
150
+ if isinstance(program, (bytes, bytearray, memoryview)):
151
+ program = load(program)
152
+ self.program = program
153
+ externs = dict(externs or {})
154
+ missing = [name for name, *_ in program.externs if name not in externs]
155
+ if missing:
156
+ raise ValueError("no host function for extern(s): %s" % ", ".join(missing))
157
+ unknown = sorted(set(externs) - {e[0] for e in program.externs})
158
+ if unknown:
159
+ raise ValueError("the program declares no extern(s) %s" % ", ".join(unknown))
160
+ self.faults = []
161
+ self._on_fault = on_fault
162
+ self._on_event = on_event
163
+ self._on_timer = on_timer
164
+ if clock is None:
165
+ import time
166
+ clock = time.time
167
+ self._clock = clock
168
+ names = list(program.global_names) if getattr(program, "global_names", None) else []
169
+ self._globals = {n: i for i, n in enumerate(names) if n}
170
+ self.vm = VM(program, {name: self._wrap(fn) for name, fn in externs.items()},
171
+ on_emit=self._emit, on_fault=self._fault,
172
+ on_timer=self._timer if on_timer else None, clock=self._now, **limits)
173
+
174
+ # -- glue ---------------------------------------------------------------------------
175
+ @staticmethod
176
+ def _wrap(fn):
177
+ return lambda vm, args: _to_vm(fn(*[_to_py(a) for a in args]))
178
+
179
+ def _now(self, vm):
180
+ t = self._clock()
181
+ if isinstance(t, _dt.datetime):
182
+ return _to_vm(t).ms
183
+ return int(round(t * 1000))
184
+
185
+ def _emit(self, vm, name, args):
186
+ if self._on_event:
187
+ self._on_event(name, [_to_py(a) for a in args])
188
+
189
+ def _timer(self, vm, timer, start, ms, periodic):
190
+ self._on_timer(self.program.events[self.program.timers[timer]][0], bool(start), ms, bool(periodic))
191
+
192
+ def _fault(self, vm, code, pc):
193
+ line = 0
194
+ lines = getattr(self.program, "lines", None) or []
195
+ for p, ln, _node in lines:
196
+ if p > pc:
197
+ break
198
+ line = ln
199
+ handler = None
200
+ dbg = getattr(self.program, "debug_info", None)
201
+ if dbg is not None and pc >= 0:
202
+ r = dbg.routine_at(pc)
203
+ handler = r["name"] if r else None
204
+ f = Fault(code, pc, line if pc >= 0 else 0, handler)
205
+ self.faults.append(f)
206
+ del self.faults[:-100]
207
+ if self._on_fault:
208
+ self._on_fault(f)
209
+
210
+ # -- running ------------------------------------------------------------------------
211
+ def start(self):
212
+ """Run ``on boot`` and enter the initial state. Returns the machine."""
213
+ self.vm.start()
214
+ self.vm.run()
215
+ return self
216
+
217
+ def post(self, event, *args):
218
+ """Queue an event (not run yet). False if the queue or string memory was full."""
219
+ return self.vm.post(event, *[_to_vm(a) for a in args])
220
+
221
+ def run(self, max_steps=1000000):
222
+ """Run queued work until idle; returns the number of steps."""
223
+ return self.vm.run(max_steps)
224
+
225
+ def send(self, event, *args):
226
+ """Post an event and run until idle. False if it could not be queued."""
227
+ ok = self.post(event, *args)
228
+ self.run()
229
+ return ok
230
+
231
+ def advance(self, ms):
232
+ """Built-in timers: let ``ms`` milliseconds pass.
233
+
234
+ Time moves from one timer expiry to the next and the handlers run in between, so
235
+ every expiry fires in order (``every 1s`` fires three times in ``advance(3000)``)
236
+ and timers that handlers start or stop take effect at the right moment.
237
+ """
238
+ left = int(ms)
239
+ while left > 0:
240
+ due = [t[3] for t in self.vm.timers if t[0]]
241
+ step = min([left] + [max(1, d) for d in due])
242
+ self.vm.tick(step)
243
+ self.vm.run()
244
+ left -= step
245
+
246
+ def timer_expired(self, name):
247
+ """Host-run timers: the timer ``name`` fired. Runs its handler; False if it was stopped."""
248
+ r = self.vm.timer_expired(self.vm.timer_index(name))
249
+ self.vm.run()
250
+ return r
251
+
252
+ def reset(self):
253
+ """Back to the initial values (globals, no state, empty queue). Call ``start()`` again."""
254
+ self.vm.reset()
255
+
256
+ # -- state ----------------------------------------------------------------------------
257
+ @property
258
+ def state(self):
259
+ """Name of the current state, or None (before ``start()``, or a program without states)."""
260
+ return self.vm.state_name()
261
+
262
+ @property
263
+ def idle(self):
264
+ return not self.vm.has_work() and not self.vm.active
265
+
266
+ @property
267
+ def globals(self):
268
+ """{name: value} of the program's variables (state variables as ``state.name``)."""
269
+ return {n: _to_py(self.vm.globals[i]) for n, i in self._globals.items()}
270
+
271
+ def __getitem__(self, name):
272
+ return _to_py(self.vm.globals[self._global_index(name)])
273
+
274
+ def __setitem__(self, name, value):
275
+ self.vm.set_global(self._global_index(name), _to_vm(value))
276
+
277
+ def _global_index(self, name):
278
+ if name not in self._globals:
279
+ if not self._globals:
280
+ raise KeyError("%r: this program has no global names (compile it, or load "
281
+ "bytecode compiled with debug information)" % name)
282
+ raise KeyError("no global variable %r" % name)
283
+ return self._globals[name]
284
+
285
+ def __repr__(self):
286
+ return "<Machine %s state=%s>" % (self.program.name or "program", self.state)