Files
gw-basic-2026/gwbasickernel/kernel.py
T
Eremey Valetov 3e8aaa298b Implement Jupyter kernel for GW-BASIC 2026
Persistent subprocess model: gwbasic reads BASIC from stdin and writes
output to stdout in piped mode (no banner, no prompts, unbuffered).
Sentinel protocol (PRINT "<<<GWDONE>>>") delimits output per cell.

Features: state persistence across cells, error detection and reporting,
tab completion for GW-BASIC keywords, %reset/%timeout/%new magic
commands, Ctrl+C forwarding, automatic process restart.

Includes setup.py for pip install, kernel.json spec, install script,
and 10-test smoke suite (all passing).
2026-03-29 05:51:37 -04:00

325 lines
12 KiB
Python

"""Jupyter kernel for GW-BASIC 2026.
Persistent process model: a long-running gwbasic subprocess reads BASIC
commands from stdin and writes output to stdout. The kernel sends each
notebook cell as one or more lines of BASIC, followed by a sentinel
PRINT that emits a known delimiter. Output is captured until the
delimiter appears, then forwarded to the notebook.
GW-BASIC's piped mode suppresses the banner and "Ok" prompt automatically,
and stdout is unbuffered, making it ideal for subprocess communication.
"""
import os
import re
import select
import shutil
import signal
import subprocess
from ipykernel.kernelbase import Kernel
from . import __version__
_SENTINEL = '<<<GWDONE>>>'
_SENTINEL_CMD = f'PRINT "{_SENTINEL}"\n'
_ERROR_RE = re.compile(
r'(?:Syntax error|Illegal function call|Type mismatch|Out of |'
r'Division by zero|Overflow|RETURN without GOSUB|'
r'NEXT without FOR|Undefined |Subscript out of range|'
r'Redo from start|String too long|'
r'WHILE without WEND|WEND without WHILE|'
r'File not found|File already open|Bad file |'
r'Direct statement in file|Missing operand|'
r'Duplicate Definition|Formula too complex|'
r'Internal error|Unprintable error)',
re.IGNORECASE,
)
GW_KEYWORDS = [
'AUTO', 'BEEP', 'BLOAD', 'BSAVE', 'CALL', 'CHAIN', 'CHDIR',
'CIRCLE', 'CLEAR', 'CLOSE', 'CLS', 'COLOR', 'COM', 'COMMON',
'CONT', 'DATA', 'DATE$', 'DEF', 'DEFDBL', 'DEFINT', 'DEFSNG',
'DEFSTR', 'DELETE', 'DIM', 'DRAW', 'EDIT', 'ELSE', 'END',
'ENVIRON', 'ENVIRON$', 'ERASE', 'ERDEV', 'ERDEV$', 'ERR', 'ERL',
'ERROR', 'FIELD', 'FILES', 'FN', 'FOR', 'GET', 'GOSUB', 'GOTO',
'IF', 'INPUT', 'IOCTL', 'IOCTL$', 'KEY', 'KILL', 'LCOPY',
'LET', 'LINE', 'LIST', 'LLIST', 'LOAD', 'LOCATE', 'LPRINT',
'LSET', 'MERGE', 'MKDIR', 'MOTOR', 'NAME', 'NEW', 'NEXT',
'NOISE', 'ON', 'OPEN', 'OPTION', 'OUT', 'PAINT', 'PALETTE',
'PLAY', 'POKE', 'PRESET', 'PRINT', 'PSET', 'PUT', 'RANDOMIZE',
'READ', 'REM', 'RENUM', 'RESET', 'RESTORE', 'RESUME', 'RETURN',
'RMDIR', 'RSET', 'RUN', 'SAVE', 'SCREEN', 'SHELL', 'SOUND',
'STOP', 'SWAP', 'SYSTEM', 'THEN', 'TIME$', 'TIMER', 'TO',
'TRON', 'TROFF', 'VIEW', 'WAIT', 'WEND', 'WHILE', 'WIDTH',
'WINDOW', 'WRITE',
'ABS', 'ASC', 'ATN', 'CDBL', 'CHR$', 'CINT', 'COS', 'CSNG',
'CSRLIN', 'CVD', 'CVI', 'CVS', 'EOF', 'EXP', 'FIX', 'FRE',
'HEX$', 'INKEY$', 'INP', 'INPUT$', 'INSTR', 'INT', 'LEFT$',
'LEN', 'LOC', 'LOF', 'LOG', 'LPOS', 'MID$', 'MKD$', 'MKI$',
'MKS$', 'OCT$', 'PEEK', 'PEN', 'PMAP', 'POINT', 'POS',
'RIGHT$', 'RND', 'SGN', 'SIN', 'SPACE$', 'SPC', 'SQR',
'STICK', 'STR$', 'STRIG', 'STRING$', 'TAB', 'TAN', 'USR',
'VAL', 'VARPTR', 'VARPTR$',
'AND', 'EQV', 'IMP', 'MOD', 'NOT', 'OR', 'XOR', 'STEP',
]
class GWBasicKernel(Kernel):
implementation = 'gwbasickernel'
implementation_version = __version__
language = 'basic'
language_version = '2026'
language_info = {
'name': 'basic',
'mimetype': 'text/x-basic',
'file_extension': '.bas',
'codemirror_mode': 'vb',
}
banner = 'GW-BASIC 2026 Jupyter Kernel'
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._proc = None
self._timeout = int(os.environ.get('GWBASIC_TIMEOUT', '30'))
def _find_binary(self):
env = os.environ.get('GWBASIC')
if env and os.path.isfile(env) and os.access(env, os.X_OK):
return env
for candidate in ['gwbasic', './build/gwbasic', './gwbasic']:
found = shutil.which(candidate)
if found:
return found
return None
def _start_process(self):
self._kill_process()
binary = self._find_binary()
if not binary:
return False
self._proc = subprocess.Popen(
[binary],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
bufsize=0,
)
return True
def _kill_process(self):
if self._proc is not None:
try:
self._proc.kill()
self._proc.wait(timeout=5)
except (ProcessLookupError, subprocess.TimeoutExpired):
pass
self._proc = None
def _is_alive(self):
return self._proc is not None and self._proc.poll() is None
def _send_interrupt_children(self):
if self._is_alive():
self._proc.send_signal(signal.SIGINT)
else:
super()._send_interrupt_children()
def _read_until_sentinel(self):
"""Read stdout until the sentinel line or timeout.
Returns (output_lines, status) where status is one of
'done', 'timeout', 'died', or 'interrupted'.
"""
lines = []
fd = self._proc.stdout.fileno()
buf = b''
while True:
try:
ready, _, _ = select.select([fd], [], [], self._timeout)
except (KeyboardInterrupt, InterruptedError):
if self._is_alive():
self._proc.send_signal(signal.SIGINT)
return lines, 'interrupted'
if not ready:
return lines, 'timeout'
chunk = os.read(fd, 8192)
if not chunk:
return lines, 'died'
buf += chunk
while b'\n' in buf:
line_bytes, buf = buf.split(b'\n', 1)
line = line_bytes.decode('utf-8', errors='replace').rstrip('\r')
if _SENTINEL in line:
return lines, 'done'
lines.append(line)
def _has_error(self, lines):
for line in lines:
if _ERROR_RE.search(line):
return True
return False
def _handle_magic(self, code):
"""Handle %magic commands. Returns (handled, response)."""
stripped = code.strip()
if stripped == '%reset':
self._kill_process()
return True, 'Session reset.'
if stripped.startswith('%timeout'):
parts = stripped.split()
if len(parts) == 2:
try:
self._timeout = int(parts[1])
return True, f'Timeout set to {self._timeout}s.'
except ValueError:
return True, 'Usage: %timeout <seconds>'
return True, f'Current timeout: {self._timeout}s. Usage: %timeout <seconds>'
if stripped == '%new':
if self._is_alive():
self._proc.stdin.write(b'NEW\n')
self._proc.stdin.write(_SENTINEL_CMD.encode())
self._proc.stdin.flush()
self._read_until_sentinel()
return True, 'Program cleared.'
return False, None
def do_execute(self, code, silent, store_history=True,
user_expressions=None, allow_stdin=False):
# Magic commands
handled, response = self._handle_magic(code)
if handled:
if not silent and response:
self.send_response(self.iopub_socket, 'stream',
{'name': 'stdout', 'text': response + '\n'})
return {
'status': 'ok', 'execution_count': self.execution_count,
'payload': [], 'user_expressions': {},
}
if not code.strip():
return {
'status': 'ok', 'execution_count': self.execution_count,
'payload': [], 'user_expressions': {},
}
# Start process if needed
if not self._is_alive():
if not self._start_process():
if not silent:
self.send_response(self.iopub_socket, 'stream', {
'name': 'stderr',
'text': 'gwbasic binary not found.\n'
'Set GWBASIC env var or install gwbasic on PATH.\n',
})
return {
'status': 'error', 'execution_count': self.execution_count,
'ename': 'FileNotFoundError', 'evalue': 'gwbasic not found',
'traceback': [],
}
# Send each line of the cell
for line in code.split('\n'):
line = line.rstrip()
if line:
self._proc.stdin.write((line + '\n').encode())
# Send sentinel to delimit output
self._proc.stdin.write(_SENTINEL_CMD.encode())
self._proc.stdin.flush()
# Read output
output_lines, status = self._read_until_sentinel()
if status == 'interrupted':
if not silent:
self.send_response(self.iopub_socket, 'stream', {
'name': 'stderr', 'text': 'Execution interrupted.\n',
})
return {'status': 'abort', 'execution_count': self.execution_count}
if status == 'timeout':
self._kill_process()
if not silent:
self.send_response(self.iopub_socket, 'stream', {
'name': 'stderr',
'text': f'Execution timed out after {self._timeout}s. '
f'Use %timeout to increase.\n',
})
return {
'status': 'error', 'execution_count': self.execution_count,
'ename': 'TimeoutError',
'evalue': f'Timed out after {self._timeout}s',
'traceback': [],
}
if status == 'died':
self._proc = None
output = '\n'.join(output_lines)
if not silent and output:
self.send_response(self.iopub_socket, 'stream', {
'name': 'stderr',
'text': f'GW-BASIC process exited.\n{output}\n',
})
return {
'status': 'error', 'execution_count': self.execution_count,
'ename': 'RuntimeError', 'evalue': 'Process exited',
'traceback': [],
}
# Filter empty leading/trailing lines
while output_lines and not output_lines[0].strip():
output_lines.pop(0)
while output_lines and not output_lines[-1].strip():
output_lines.pop()
is_error = self._has_error(output_lines)
output = '\n'.join(output_lines)
if not silent and output:
self.send_response(self.iopub_socket, 'stream', {
'name': 'stderr' if is_error else 'stdout',
'text': output + '\n',
})
if is_error:
return {
'status': 'error', 'execution_count': self.execution_count,
'ename': 'BASICError', 'evalue': output.split('\n')[0],
'traceback': output.split('\n'),
}
return {
'status': 'ok', 'execution_count': self.execution_count,
'payload': [], 'user_expressions': {},
}
def do_complete(self, code, cursor_pos):
text = code[:cursor_pos]
match = re.search(r'([A-Za-z_]\w*\$?)$', text)
if not match:
return {'matches': [], 'cursor_start': cursor_pos,
'cursor_end': cursor_pos, 'status': 'ok'}
token = match.group(1).upper()
start = cursor_pos - len(token)
matches = [kw for kw in GW_KEYWORDS if kw.startswith(token)]
return {
'matches': matches, 'cursor_start': start,
'cursor_end': cursor_pos, 'status': 'ok',
}
def do_is_complete(self, code):
if not code.strip():
return {'status': 'incomplete', 'indent': ''}
return {'status': 'complete'}
def do_shutdown(self, restart):
self._kill_process()
return {'status': 'ok', 'restart': restart}