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.
File without changes
@@ -0,0 +1,505 @@
1
+ """FlowScript 3-view debugger: a tiny stdlib HTTP server around the compiler + Python VM.
2
+
3
+ python3 -m flowscript debug examples/gps_tracker.fs
4
+
5
+ Views (in the browser): source | bytecode | flowchart, all following the execution.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import threading
11
+ import time
12
+ import webbrowser
13
+ from http.server import HTTPServer, BaseHTTPRequestHandler
14
+
15
+ from ..lexer import FlowError
16
+ from ..parser import parse, parse_expression
17
+ from ..sizes import size_report
18
+ from ..printer import to_source, expr_to_str
19
+ from ..compiler import compile_ast
20
+ from ..bytecode import disassemble, FAULTS
21
+ from ..vm import VM, IDLE, DISPATCHED, RUNNING, DONE, FAULT
22
+ from .. import values as V
23
+
24
+
25
+ def jv(v):
26
+ """A VM value for JSON: an int as a number, anything else as its canonical text
27
+ (floats can be nan/inf, which JSON can't carry)."""
28
+ if isinstance(v, int) and not isinstance(v, bool):
29
+ return v
30
+ try:
31
+ return V.show(v)
32
+ except TypeError:
33
+ return str(v)
34
+
35
+ STATIC = os.path.join(os.path.dirname(__file__), "static")
36
+ LOG_LIMIT = 400
37
+
38
+ DEFAULT_SOURCE = """flow hello
39
+
40
+ extern led(on)
41
+ event button
42
+ timer blink
43
+
44
+ var count = 0
45
+
46
+ on boot
47
+ start blink every 500ms
48
+ end
49
+
50
+ on blink
51
+ count = count + 1
52
+ led(count % 2)
53
+ end
54
+
55
+ on button
56
+ if count > 10 and not count > 100
57
+ stop blink
58
+ end
59
+ end
60
+ """
61
+
62
+
63
+ class Session:
64
+ def __init__(self, path=None):
65
+ self.path = path
66
+ if path and os.path.exists(path):
67
+ with open(path) as f:
68
+ self.source = f.read()
69
+ else:
70
+ self.source = DEFAULT_SOURCE
71
+ self.extern_values = {}
72
+ self.breakpoints = set() # source lines
73
+ self.ast = None
74
+ self.prog = None
75
+ self.dbg = None
76
+ self.vm = None
77
+ self.errors = []
78
+ self.compile(self.source)
79
+
80
+ # -- compilation -------------------------------------------------------------------
81
+ def compile(self, source):
82
+ self.source = source
83
+ self.errors = []
84
+ self.prog = None
85
+ self.vm = None
86
+ try:
87
+ self.ast = parse(source)
88
+ except FlowError as e:
89
+ self.errors = [e.to_dict()]
90
+ return
91
+ try:
92
+ self.prog, self.dbg = compile_ast(self.ast)
93
+ except FlowError as e:
94
+ self.errors = [e.to_dict()]
95
+ return
96
+ # per-statement expression lists, used to clear stale gate values
97
+ self.stmt_exprs = {}
98
+ for nid, sid in self.dbg.stmt_of.items():
99
+ if nid != sid:
100
+ self.stmt_exprs.setdefault(sid, []).append(nid)
101
+ self.reset()
102
+
103
+ def reset(self):
104
+ self.log = []
105
+ self.values = {}
106
+ self.call_marks = []
107
+ self.last_state = None
108
+ self.total_steps = 0
109
+ if not self.prog:
110
+ self.vm = None
111
+ return
112
+ externs = {e[0]: self.make_extern(i, e[0], e[3]) for i, e in enumerate(self.prog.externs)}
113
+ self.vm = VM(self.prog, externs, on_emit=self.on_emit, on_fault=self.on_fault,
114
+ clock=lambda vm: int(time.time() * 1000))
115
+ self.vm.start()
116
+ self.add_log("sys", "VM started" + (" — initial state '%s'" % self.prog.states[0] if self.prog.states else ""))
117
+
118
+ def add_log(self, kind, text):
119
+ self.log.append({"kind": kind, "text": text, "n": self.total_steps})
120
+ if len(self.log) > LOG_LIMIT:
121
+ del self.log[:len(self.log) - LOG_LIMIT]
122
+
123
+ def make_extern(self, idx, name, ret):
124
+ def fn(vm, args):
125
+ shown = ", ".join(V.show(a) for a in args)
126
+ if ret == V.NONE_TYPE:
127
+ self.add_log("call", "%s(%s)" % (name, shown))
128
+ return 0
129
+ r = V.zero(ret)
130
+ text = self.extern_values.get(name)
131
+ if text not in (None, ""):
132
+ try:
133
+ r = V.parse_literal(ret, str(text))
134
+ except ValueError as e:
135
+ self.add_log("fault", "%s: return value %r is not a %s (%s): returning %s"
136
+ % (name, text, V.TYPE_NAMES[ret], e, V.show(r)))
137
+ self.add_log("call", "%s(%s) → %s" % (name, shown, V.show(r)))
138
+ return r.b if isinstance(r, V.Str) and text not in (None, "") else r
139
+ return fn
140
+
141
+ def on_emit(self, vm, name, args):
142
+ self.add_log("emit", "emit %s(%s)" % (name, ", ".join(V.show(a) for a in args)))
143
+
144
+ def on_fault(self, vm, code, pc):
145
+ self.add_log("fault", "FAULT %d: %s at pc %s" % (code, FAULTS.get(code, "?"), pc))
146
+
147
+ def node_by_id(self, nid):
148
+ if nid is None:
149
+ return None
150
+ if not hasattr(self, "_node_index") or self._node_index[0] is not self.ast:
151
+ idx = {}
152
+
153
+ def walk(o):
154
+ if isinstance(o, dict):
155
+ if "id" in o and "type" in o:
156
+ idx[o["id"]] = o
157
+ for v in o.values():
158
+ walk(v)
159
+ elif isinstance(o, list):
160
+ for v in o:
161
+ walk(v)
162
+ walk(self.ast)
163
+ self._node_index = (self.ast, idx)
164
+ return self._node_index[1].get(nid)
165
+
166
+ # -- stepping ----------------------------------------------------------------------
167
+ def stmt_at(self, pc):
168
+ nid = self.dbg.pc_node.get(pc)
169
+ return self.dbg.stmt_of.get(nid, nid)
170
+
171
+ def one(self):
172
+ """One VM step with debugger bookkeeping. Returns the VM step result."""
173
+ vm = self.vm
174
+ was_active = vm.active
175
+ pc = vm.pc
176
+ depth = len(vm.frames)
177
+ if was_active and pc in self.dbg.stmt_starts:
178
+ for nid in self.stmt_exprs.get(self.dbg.stmt_starts[pc], []):
179
+ self.values.pop(nid, None)
180
+ r = vm.step()
181
+ self.total_steps += 1
182
+ if vm.state != self.last_state:
183
+ self.last_state = vm.state
184
+ self.add_log("state", "state → %s" % vm.prog.states[vm.state])
185
+ if r == DISPATCHED:
186
+ self.call_marks = []
187
+ if vm.active:
188
+ rt = self.dbg.routine_at(vm.pc)
189
+ self.add_log("dispatch", "→ %s" % (rt["name"] if rt else "?"))
190
+ elif r == RUNNING and pc in self.dbg.value_marks:
191
+ nid = self.dbg.value_marks[pc]
192
+ if len(vm.frames) > depth: # CALL: value known after return
193
+ self.call_marks.append((nid, depth))
194
+ elif vm.stack:
195
+ self.values[nid] = vm.stack[-1]
196
+ if r == RUNNING and self.call_marks and len(vm.frames) == self.call_marks[-1][1] and len(vm.frames) < depth:
197
+ nid, _ = self.call_marks.pop()
198
+ if vm.stack:
199
+ self.values[nid] = vm.stack[-1]
200
+ if r in (DONE, FAULT):
201
+ self.call_marks = []
202
+ return r
203
+
204
+ def at_breakpoint(self):
205
+ vm = self.vm
206
+ if not vm.active or not self.breakpoints:
207
+ return False
208
+ lines = set()
209
+ if vm.pc in self.dbg.stmt_starts:
210
+ lines.add(self.dbg.pc_line.get(vm.pc))
211
+ if vm.steps == 0: # just dispatched: the handler head counts too
212
+ rt = self.dbg.routine_at(vm.pc)
213
+ node = self.node_by_id(rt["node"]) if rt else None
214
+ if node:
215
+ lines.add(node.get("line"))
216
+ return bool(lines & self.breakpoints)
217
+
218
+ def step(self, mode):
219
+ vm = self.vm
220
+ if vm is None:
221
+ return "nocode"
222
+ limit = 200000
223
+ if mode == "instr":
224
+ return self.one()
225
+ if mode in ("stmt", "over", "out"):
226
+ if not vm.active:
227
+ return self.one()
228
+ start_stmt = self.stmt_at(vm.pc)
229
+ depth = len(vm.frames)
230
+ for _ in range(limit):
231
+ r = self.one()
232
+ if r != RUNNING:
233
+ return r
234
+ d = len(vm.frames)
235
+ if mode == "out":
236
+ if d < depth:
237
+ return r
238
+ continue
239
+ if mode == "over" and d > depth:
240
+ continue
241
+ if mode == "over" and d < depth:
242
+ return r
243
+ if self.stmt_at(vm.pc) != start_stmt or vm.pc in self.dbg.stmt_starts or d != depth:
244
+ return r
245
+ return "limit"
246
+ if mode == "handler":
247
+ if not vm.active:
248
+ r = self.one()
249
+ if r != DISPATCHED:
250
+ return r
251
+ for _ in range(limit):
252
+ r = self.one()
253
+ if r != RUNNING:
254
+ return r
255
+ return "limit"
256
+ if mode == "run":
257
+ first = True
258
+ for _ in range(limit):
259
+ if not first and self.at_breakpoint():
260
+ return "breakpoint"
261
+ first = False
262
+ r = self.one()
263
+ if r == IDLE:
264
+ return r
265
+ return "limit"
266
+ raise ValueError("unknown step mode %r" % mode)
267
+
268
+ # -- snapshot ----------------------------------------------------------------------
269
+ def snapshot(self, status=None):
270
+ vm = self.vm
271
+ if vm is None:
272
+ return {"ready": False, "status": "error", "log": self.log if hasattr(self, "log") else []}
273
+ p = self.prog
274
+ snap = {
275
+ "ready": True,
276
+ "status": status,
277
+ "active": vm.active,
278
+ "pc": vm.pc if vm.active else None,
279
+ "node": self.dbg.pc_node.get(vm.pc) if vm.active else None,
280
+ "line": self.dbg.pc_line.get(vm.pc) if vm.active else None,
281
+ "state": vm.state_name(),
282
+ "pending": None if vm.pending is None else p.states[vm.pending],
283
+ "globals": [{"name": n, "value": jv(v), "type": V.TYPE_NAMES[V.type_of(v)]}
284
+ for n, v in zip(p.global_names, vm.globals)],
285
+ "stack": [jv(v) for v in vm.stack],
286
+ "frames": [],
287
+ "queue": [{"event": p.events[e][0], "args": [jv(x) for x in a]} for e, a in vm.queue],
288
+ "timers": [],
289
+ "values": {str(k): jv(v) for k, v in self.values.items()},
290
+ "strings_in_use": vm.strings_in_use(),
291
+ "steps": vm.steps if vm.active else 0,
292
+ "total_steps": self.total_steps,
293
+ "log": self.log[-200:],
294
+ }
295
+ if vm.active:
296
+ snap["stmt"] = self.stmt_at(vm.pc)
297
+ for i, fr in enumerate(vm.frames):
298
+ # outer frames are paused at their CALL instruction (3 bytes before return pc)
299
+ fpc = vm.frames[i + 1][0] - 3 if i + 1 < len(vm.frames) else vm.pc
300
+ rt = self.dbg.routine_at(fpc)
301
+ base = fr[1]
302
+ local_vals = [{"name": nm, "value": jv(vm.stack[base + j])}
303
+ for j, nm in enumerate(rt["locals"] if rt else [])
304
+ if base + j < len(vm.stack)]
305
+ snap["frames"].append({"routine": rt["name"] if rt else "?", "pc": fpc,
306
+ "node": self.dbg.pc_node.get(fpc), "locals": local_vals,
307
+ "base": base})
308
+ for i, t in enumerate(vm.timers):
309
+ snap["timers"].append({"name": p.events[p.timers[i]][0], "active": t[0],
310
+ "periodic": t[1], "period": t[2], "remaining": t[3],
311
+ "owner": None if t[4] is None else p.states[t[4]]})
312
+ return snap
313
+
314
+ def compile_result(self):
315
+ res = {"source": self.source, "errors": self.errors, "ast": self.ast,
316
+ "warnings": self.dbg.warnings if self.prog else [],
317
+ "path": self.path, "extern_values": self.extern_values,
318
+ "breakpoints": sorted(self.breakpoints)}
319
+ if self.prog:
320
+ p = self.prog
321
+ local_names = {}
322
+ for r in self.dbg.routines:
323
+ for pc in range(r["start"], r["end"]):
324
+ local_names[pc] = r["locals"]
325
+ dis = disassemble(p, local_names)
326
+ for ins in dis:
327
+ if isinstance(ins["arg"], float):
328
+ ins["arg"] = V.fmt_float(ins["arg"])
329
+ ins["node"] = self.dbg.pc_node.get(ins["pc"])
330
+ ins["line"] = self.dbg.pc_line.get(ins["pc"])
331
+ ins["stmt"] = self.dbg.stmt_of.get(ins["node"], ins["node"])
332
+ res.update({
333
+ "disasm": dis,
334
+ "size": len(p.to_bytes()),
335
+ "code_size": len(p.code),
336
+ "events": [{"name": n, "argc": a, "types": [V.TYPE_NAMES[t] for t in ts]}
337
+ for n, a, ts in p.events[3:]],
338
+ "externs": [{"name": n, "argc": a, "types": [V.TYPE_NAMES[t] for t in ts],
339
+ "returns": None if r == V.NONE_TYPE else V.TYPE_NAMES[r]}
340
+ for n, a, ts, r in p.externs],
341
+ "states": list(p.states),
342
+ "timers": [p.events[e][0] for e in p.timers],
343
+ "stmt_of": {str(k): v for k, v in self.dbg.stmt_of.items()},
344
+ "routines": self.dbg.routines,
345
+ "size_report": size_report(p),
346
+ })
347
+ res["snapshot"] = self.snapshot("compiled")
348
+ return res
349
+
350
+
351
+ def make_handler(session, lock):
352
+ class Handler(BaseHTTPRequestHandler):
353
+ def log_message(self, fmt, *args):
354
+ pass
355
+
356
+ def send_json(self, obj, code=200):
357
+ data = json.dumps(obj).encode("utf-8")
358
+ self.send_response(code)
359
+ self.send_header("Content-Type", "application/json")
360
+ self.send_header("Content-Length", str(len(data)))
361
+ self.send_header("Cache-Control", "no-store")
362
+ self.end_headers()
363
+ self.wfile.write(data)
364
+
365
+ def do_GET(self):
366
+ path = self.path.split("?", 1)[0]
367
+ if path == "/":
368
+ path = "/index.html"
369
+ fp = os.path.normpath(os.path.join(STATIC, path.lstrip("/")))
370
+ if not fp.startswith(STATIC) or not os.path.isfile(fp):
371
+ self.send_error(404)
372
+ return
373
+ ctype = {"html": "text/html", "js": "application/javascript", "css": "text/css",
374
+ "svg": "image/svg+xml"}.get(fp.rsplit(".", 1)[-1], "application/octet-stream")
375
+ with open(fp, "rb") as f:
376
+ data = f.read()
377
+ self.send_response(200)
378
+ self.send_header("Content-Type", ctype + "; charset=utf-8")
379
+ self.send_header("Content-Length", str(len(data)))
380
+ self.send_header("Cache-Control", "no-store")
381
+ self.end_headers()
382
+ self.wfile.write(data)
383
+
384
+ def do_POST(self):
385
+ n = int(self.headers.get("Content-Length") or 0)
386
+ try:
387
+ body = json.loads(self.rfile.read(n) or b"{}")
388
+ except ValueError:
389
+ self.send_json({"error": "bad json"}, 400)
390
+ return
391
+ route = self.path.split("?", 1)[0]
392
+ with lock:
393
+ try:
394
+ self.send_json(self.route(route, body))
395
+ except FlowError as e:
396
+ self.send_json({"error": str(e), "detail": e.to_dict()})
397
+ except Exception as e: # keep the debugger alive
398
+ import traceback
399
+ traceback.print_exc()
400
+ self.send_json({"error": "%s: %s" % (type(e).__name__, e)}, 500)
401
+
402
+ def route(self, route, body):
403
+ s = session
404
+ if route == "/api/load":
405
+ return s.compile_result()
406
+ if route == "/api/compile":
407
+ s.compile(body.get("source", ""))
408
+ return s.compile_result()
409
+ if route == "/api/from_model":
410
+ src = to_source(body["ast"])
411
+ s.compile(src)
412
+ return s.compile_result()
413
+ if route == "/api/format":
414
+ return {"source": to_source(parse(body.get("source", "")))}
415
+ if route == "/api/parse_exprs":
416
+ out = []
417
+ for text in body.get("texts", []):
418
+ try:
419
+ e = parse_expression(text)
420
+ out.append({"ok": True, "expr": e, "text": expr_to_str(e)})
421
+ except FlowError as err:
422
+ out.append({"ok": False, "error": err.msg})
423
+ return {"results": out}
424
+ if route == "/api/save":
425
+ path = body.get("path") or s.path
426
+ if not path:
427
+ return {"error": "no file name"}
428
+ with open(path, "w") as f:
429
+ f.write(body.get("source", s.source))
430
+ s.path = path
431
+ return {"ok": True, "path": path}
432
+ if route == "/api/bytecode":
433
+ if not s.prog:
434
+ return {"error": "program does not compile"}
435
+ import base64
436
+ return {"base64": base64.b64encode(s.prog.to_bytes(debug=bool(body.get("debug")))).decode()}
437
+ if s.vm is None and route.startswith("/api/vm/"):
438
+ return {"snapshot": s.snapshot("error")}
439
+ if route == "/api/vm/reset":
440
+ s.reset()
441
+ return {"snapshot": s.snapshot("reset")}
442
+ if route == "/api/vm/step":
443
+ r = s.step(body.get("mode", "instr"))
444
+ return {"snapshot": s.snapshot(r)}
445
+ if route == "/api/vm/post":
446
+ ev = s.prog.event_index(body["event"])
447
+ types = s.prog.events[ev][2] if ev >= 0 else ()
448
+ try:
449
+ args = [V.parse_literal(t, str(a)) for t, a in zip(types, body.get("args", []))]
450
+ except ValueError as e:
451
+ s.add_log("fault", "post %s: %s" % (body["event"], e))
452
+ return {"snapshot": s.snapshot("posted")}
453
+ try:
454
+ ok = s.vm.post(body["event"], *[a.b if isinstance(a, V.Str) else a for a in args])
455
+ except TypeError as e:
456
+ ok = False
457
+ s.add_log("fault", "post %s: %s" % (body["event"], e))
458
+ s.add_log("post", "post %s(%s)%s" % (body["event"], ", ".join(V.show(a) for a in args),
459
+ "" if ok else " — not queued"))
460
+ return {"snapshot": s.snapshot("posted")}
461
+ if route == "/api/vm/tick":
462
+ ms = int(body.get("ms", 0))
463
+ s.vm.tick(ms)
464
+ s.add_log("tick", "tick %d ms" % ms)
465
+ return {"snapshot": s.snapshot("ticked")}
466
+ if route == "/api/vm/extern":
467
+ s.extern_values[body["name"]] = str(body.get("value", ""))
468
+ return {"ok": True}
469
+ if route == "/api/vm/breakpoints":
470
+ s.breakpoints = set(int(x) for x in body.get("lines", []))
471
+ return {"ok": True}
472
+ if route == "/api/vm/set_global":
473
+ idx = s.prog.global_names.index(body["name"])
474
+ t = V.type_of(s.vm.globals[idx])
475
+ try:
476
+ v = V.parse_literal(t, str(body["value"]))
477
+ s.vm.set_global(idx, v.b if isinstance(v, V.Str) else v)
478
+ except (ValueError, V.ValueFault) as e:
479
+ s.add_log("fault", "%s: %r is not a %s value (%s)" % (body["name"], body["value"], V.TYPE_NAMES[t], e))
480
+ return {"snapshot": s.snapshot("edited")}
481
+ raise ValueError("unknown route %s" % route)
482
+
483
+ return Handler
484
+
485
+
486
+ def serve(path=None, port=8642, open_browser=True):
487
+ session = Session(path)
488
+ if session.errors:
489
+ print("warning: %s" % session.errors[0])
490
+ try:
491
+ httpd = HTTPServer(("127.0.0.1", port), make_handler(session, threading.Lock()))
492
+ except OSError as e:
493
+ import errno
494
+ if e.errno == errno.EADDRINUSE:
495
+ raise SystemExit("error: port %d is already in use (another debugger still running?). "
496
+ "Stop it, or pick another port with --port N." % port)
497
+ raise
498
+ url = "http://127.0.0.1:%d/" % port
499
+ print("FlowScript debugger on %s (Ctrl+C to stop)" % url)
500
+ if open_browser:
501
+ threading.Timer(0.4, lambda: webbrowser.open(url)).start()
502
+ try:
503
+ httpd.serve_forever()
504
+ except KeyboardInterrupt:
505
+ print()