gwbasickernel 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.
- gwbasickernel/README.md +80 -0
- gwbasickernel/__init__.py +3 -0
- gwbasickernel/__main__.py +4 -0
- gwbasickernel/basic_lexer.py +79 -0
- gwbasickernel/install.py +60 -0
- gwbasickernel/kernel.json +8 -0
- gwbasickernel/kernel.py +575 -0
- gwbasickernel/test_kernel.py +140 -0
- gwbasickernel-0.1.0.dist-info/METADATA +101 -0
- gwbasickernel-0.1.0.dist-info/RECORD +14 -0
- gwbasickernel-0.1.0.dist-info/WHEEL +5 -0
- gwbasickernel-0.1.0.dist-info/entry_points.txt +5 -0
- gwbasickernel-0.1.0.dist-info/licenses/LICENSE +21 -0
- gwbasickernel-0.1.0.dist-info/top_level.txt +1 -0
gwbasickernel/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# GW-BASIC Jupyter kernel
|
|
2
|
+
|
|
3
|
+
Run GW-BASIC programs in JupyterLab or Notebook. Variables and program lines
|
|
4
|
+
persist between cells. The kernel converts the interpreter's Sixel output to
|
|
5
|
+
inline PNG images, so the notebook browser does not need Sixel support.
|
|
6
|
+
|
|
7
|
+
Requires Python 3.10 or later and the native
|
|
8
|
+
[GW-BASIC 2026 interpreter](https://github.com/evvaletov/gw-basic-2026)
|
|
9
|
+
on Linux or macOS. The Python package does not include that interpreter.
|
|
10
|
+
The DOS executables cannot be used as the notebook backend.
|
|
11
|
+
|
|
12
|
+
## Install the interpreter
|
|
13
|
+
|
|
14
|
+
With Homebrew on Linux or macOS:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
brew tap evvaletov/gwbasic https://github.com/evvaletov/gw-basic-2026.git
|
|
18
|
+
brew install evvaletov/gwbasic/gwbasic
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The Homebrew formula builds from a pinned source revision. It includes the
|
|
22
|
+
interpreter and native compiler, with PulseAudio disabled.
|
|
23
|
+
|
|
24
|
+
Alternatively, build from source with a C11 compiler and CMake:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
git clone https://github.com/evvaletov/gw-basic-2026.git
|
|
28
|
+
cd gw-basic-2026
|
|
29
|
+
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
|
|
30
|
+
cmake --build build --parallel 2
|
|
31
|
+
export GWBASIC="$PWD/build/gwbasic"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Install the kernel
|
|
35
|
+
|
|
36
|
+
Create a Python environment for the kernel and JupyterLab:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
python3 -m venv .venv
|
|
40
|
+
. .venv/bin/activate
|
|
41
|
+
python -m pip install gwbasickernel jupyterlab
|
|
42
|
+
gwbasickernel-install --sys-prefix
|
|
43
|
+
jupyter lab
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Choose **GW-BASIC 2026** when creating a notebook. The kernel looks for the
|
|
47
|
+
interpreter in `GWBASIC`, then on `PATH`. To save its location in the kernelspec,
|
|
48
|
+
use this command instead of the installer command above:
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
gwbasickernel-install --sys-prefix --gwbasic /absolute/path/to/gwbasic
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
If Jupyter runs in another Python environment, use `--user` to register the
|
|
55
|
+
kernel for your account. Keep the kernel's Python environment in place:
|
|
56
|
+
the kernelspec records its Python executable.
|
|
57
|
+
|
|
58
|
+
## Try it
|
|
59
|
+
|
|
60
|
+
```basic
|
|
61
|
+
10 FOR N=1 TO 8
|
|
62
|
+
20 PRINT N;N*N
|
|
63
|
+
30 NEXT N
|
|
64
|
+
RUN
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
For graphics, clear the previous program first:
|
|
68
|
+
|
|
69
|
+
```basic
|
|
70
|
+
NEW
|
|
71
|
+
10 SCREEN 1
|
|
72
|
+
20 CIRCLE (160,100),80,2
|
|
73
|
+
RUN
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
See the [executed graphics notebook](https://github.com/evvaletov/gw-basic-2026/blob/main/examples/graphics.ipynb)
|
|
77
|
+
and [demo guide](https://github.com/evvaletov/gw-basic-2026/blob/main/docs/demos.md).
|
|
78
|
+
|
|
79
|
+
`%new` clears the BASIC program; `%reset` starts a fresh interpreter process.
|
|
80
|
+
`%timeout 60` changes the output wait timeout from its default of 30 seconds.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Pygments lexer for GW-BASIC."""
|
|
2
|
+
|
|
3
|
+
from pygments.lexer import RegexLexer
|
|
4
|
+
from pygments.token import (
|
|
5
|
+
Comment, Keyword, Name, Number, Operator, Punctuation, String, Text,
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GWBasicLexer(RegexLexer):
|
|
10
|
+
name = 'GW-BASIC'
|
|
11
|
+
aliases = ['gwbasic', 'basic']
|
|
12
|
+
filenames = ['*.bas', '*.BAS']
|
|
13
|
+
mimetypes = ['text/x-basic']
|
|
14
|
+
|
|
15
|
+
tokens = {
|
|
16
|
+
'root': [
|
|
17
|
+
# REM comments (to end of line)
|
|
18
|
+
(r"(?i)\bREM\b.*$", Comment.Single),
|
|
19
|
+
# Single-quote comments
|
|
20
|
+
(r"'.*$", Comment.Single),
|
|
21
|
+
|
|
22
|
+
# String literals
|
|
23
|
+
(r'"[^"]*"', String.Double),
|
|
24
|
+
|
|
25
|
+
# Line numbers at start of line
|
|
26
|
+
(r'^\s*\d+', Name.Label),
|
|
27
|
+
|
|
28
|
+
# Hex and octal literals
|
|
29
|
+
(r'&[Hh][0-9A-Fa-f]+', Number.Hex),
|
|
30
|
+
(r'&[Oo]?[0-7]+', Number.Oct),
|
|
31
|
+
|
|
32
|
+
# Floating point
|
|
33
|
+
(r'\d+\.\d*([eEdD][+-]?\d+)?[!#]?', Number.Float),
|
|
34
|
+
(r'\d+[eEdD][+-]?\d+[!#]?', Number.Float),
|
|
35
|
+
|
|
36
|
+
# Integer
|
|
37
|
+
(r'\d+[%!#]?', Number.Integer),
|
|
38
|
+
|
|
39
|
+
# Statements / keywords
|
|
40
|
+
(r'(?i)\b(AUTO|BEEP|BLOAD|BSAVE|CALL|CALLS|CHAIN|CHDIR|CIRCLE'
|
|
41
|
+
r'|CLEAR|CLOSE|CLS|COLOR|COM|COMMON|CONT|DATA|DEF\s+SEG'
|
|
42
|
+
r'|DEF\s+FN|DEFDBL|DEFINT|DEFSNG|DEFSTR|DELETE|DIM|DRAW'
|
|
43
|
+
r'|EDIT|ELSE|END|ENVIRON|ERASE|ERROR|FIELD|FILES|FOR|GET'
|
|
44
|
+
r'|GOSUB|GOTO|IF|INPUT|KEY|KILL|LCOPY|LET|LINE\s+INPUT'
|
|
45
|
+
r'|LINE|LIST|LLIST|LOAD|LOCATE|LPRINT|LSET|MERGE|MKDIR'
|
|
46
|
+
r'|MOTOR|NAME|NEW|NEXT|ON|OPEN|OPTION\s+BASE|OUT|PAINT'
|
|
47
|
+
r'|PALETTE|PLAY|POKE|PRESET|PRINT|PSET|PUT|RANDOMIZE'
|
|
48
|
+
r'|READ|REM|RENUM|RESET|RESTORE|RESUME|RETURN|RMDIR|RSET'
|
|
49
|
+
r'|RUN|SAVE|SCREEN|SHELL|SOUND|STOP|SWAP|SYSTEM|THEN|TIMER'
|
|
50
|
+
r'|TO|TRON|TROFF|USING|VIEW|WAIT|WEND|WHILE|WIDTH|WINDOW'
|
|
51
|
+
r'|WRITE)\b', Keyword),
|
|
52
|
+
|
|
53
|
+
# Built-in functions
|
|
54
|
+
(r'(?i)\b(ABS|ASC|ATN|CDBL|CHR\$|CINT|COS|CSNG|CSRLIN'
|
|
55
|
+
r'|CVD|CVI|CVS|DATE\$|EOF|ENVIRON\$|ERDEV\$?|ERL|ERR'
|
|
56
|
+
r'|EXP|FIX|FN|FRE|HEX\$|INKEY\$|INP|INPUT\$|INSTR|INT'
|
|
57
|
+
r'|IOCTL\$|LEFT\$|LEN|LOC|LOF|LOG|LPOS|MID\$|MKD\$'
|
|
58
|
+
r'|MKI\$|MKS\$|OCT\$|PEEK|PEN|PMAP|POINT|POS|RIGHT\$'
|
|
59
|
+
r'|RND|SGN|SIN|SPACE\$|SPC|SQR|STICK|STR\$|STRIG'
|
|
60
|
+
r'|STRING\$|TAB|TAN|TIME\$|TIMER|USR|VAL|VARPTR\$?)\b',
|
|
61
|
+
Name.Builtin),
|
|
62
|
+
|
|
63
|
+
# Logical / arithmetic operators as keywords
|
|
64
|
+
(r'(?i)\b(AND|EQV|IMP|MOD|NOT|OR|XOR|STEP)\b', Operator.Word),
|
|
65
|
+
|
|
66
|
+
# Operators
|
|
67
|
+
(r'[+\-*/\\^<>=]', Operator),
|
|
68
|
+
(r'<>|<=|>=', Operator),
|
|
69
|
+
|
|
70
|
+
# Punctuation
|
|
71
|
+
(r'[(),;:#]', Punctuation),
|
|
72
|
+
|
|
73
|
+
# Identifiers (variable names)
|
|
74
|
+
(r'[A-Za-z][A-Za-z0-9]*[%!#$]?', Name),
|
|
75
|
+
|
|
76
|
+
# Whitespace
|
|
77
|
+
(r'\s+', Text),
|
|
78
|
+
],
|
|
79
|
+
}
|
gwbasickernel/install.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Install the GW-BASIC kernel into Jupyter."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def install_kernel(user=True, prefix=None, gwbasic=None):
|
|
12
|
+
from jupyter_client.kernelspec import KernelSpecManager
|
|
13
|
+
|
|
14
|
+
spec = json.loads((Path(__file__).parent / 'kernel.json').read_text())
|
|
15
|
+
spec['argv'][0] = sys.executable
|
|
16
|
+
if gwbasic is not None:
|
|
17
|
+
binary = shutil.which(str(gwbasic))
|
|
18
|
+
if binary is None:
|
|
19
|
+
raise ValueError(f'GW-BASIC executable not found: {gwbasic}')
|
|
20
|
+
spec['env'] = {'GWBASIC': str(Path(binary).absolute())}
|
|
21
|
+
|
|
22
|
+
with tempfile.TemporaryDirectory() as td:
|
|
23
|
+
dest = Path(td) / 'gwbasic'
|
|
24
|
+
dest.mkdir()
|
|
25
|
+
(dest / 'kernel.json').write_text(json.dumps(spec, indent=2) + '\n')
|
|
26
|
+
|
|
27
|
+
ksm = KernelSpecManager()
|
|
28
|
+
ksm.install_kernel_spec(
|
|
29
|
+
str(dest), kernel_name='gwbasic', user=user, prefix=prefix,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
print('GW-BASIC kernel installed successfully.')
|
|
33
|
+
print('Run "jupyter kernelspec list" to verify.')
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main():
|
|
37
|
+
parser = argparse.ArgumentParser(description='Install the GW-BASIC Jupyter kernel')
|
|
38
|
+
location = parser.add_mutually_exclusive_group()
|
|
39
|
+
location.add_argument('--user', action='store_true',
|
|
40
|
+
help='Install for the current user (default)')
|
|
41
|
+
location.add_argument('--sys-prefix', action='store_true',
|
|
42
|
+
help='Install into sys.prefix (for virtualenvs)')
|
|
43
|
+
location.add_argument('--prefix', type=str, default=None,
|
|
44
|
+
help='Install into a specific prefix')
|
|
45
|
+
parser.add_argument('--gwbasic', help='Interpreter executable to save in the kernelspec')
|
|
46
|
+
args = parser.parse_args()
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
if args.sys_prefix:
|
|
50
|
+
install_kernel(user=False, prefix=sys.prefix, gwbasic=args.gwbasic)
|
|
51
|
+
elif args.prefix:
|
|
52
|
+
install_kernel(user=False, prefix=args.prefix, gwbasic=args.gwbasic)
|
|
53
|
+
else:
|
|
54
|
+
install_kernel(gwbasic=args.gwbasic)
|
|
55
|
+
except ValueError as exc:
|
|
56
|
+
parser.error(str(exc))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
if __name__ == '__main__':
|
|
60
|
+
main()
|
gwbasickernel/kernel.py
ADDED
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
"""Jupyter kernel for GW-BASIC 2026.
|
|
2
|
+
|
|
3
|
+
Persistent process model: a long-running gwbasic subprocess reads BASIC
|
|
4
|
+
commands from stdin and writes output to stdout. The kernel sends each
|
|
5
|
+
notebook cell as one or more lines of BASIC, followed by a sentinel
|
|
6
|
+
PRINT that emits a known delimiter. Output is captured until the
|
|
7
|
+
delimiter appears, then forwarded to the notebook.
|
|
8
|
+
|
|
9
|
+
GW-BASIC's piped mode suppresses the banner and "Ok" prompt automatically,
|
|
10
|
+
and stdout is unbuffered, making it ideal for subprocess communication.
|
|
11
|
+
|
|
12
|
+
Features beyond basic execution:
|
|
13
|
+
- Inline Sixel graphics: ESC P ... ESC \\ sequences are extracted from
|
|
14
|
+
the output stream, decoded into PNG images, and displayed inline.
|
|
15
|
+
- INPUT statement support: when gwbasic prints "? " (the INPUT prompt),
|
|
16
|
+
the kernel requests input from the notebook front-end via the Jupyter
|
|
17
|
+
stdin protocol.
|
|
18
|
+
- Pygments lexer: GW-BASIC syntax highlighting in notebooks.
|
|
19
|
+
- Tab completion for all GW-BASIC keywords.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import base64
|
|
23
|
+
import io
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import select
|
|
27
|
+
import shutil
|
|
28
|
+
import signal
|
|
29
|
+
import struct
|
|
30
|
+
import subprocess
|
|
31
|
+
import zlib
|
|
32
|
+
|
|
33
|
+
from ipykernel.kernelbase import Kernel
|
|
34
|
+
|
|
35
|
+
from . import __version__
|
|
36
|
+
|
|
37
|
+
_SENTINEL = '\x01GWDONE\x01'
|
|
38
|
+
_SENTINEL_CMD = 'PRINT CHR$(1)+"GWDONE"+CHR$(1)\n'
|
|
39
|
+
|
|
40
|
+
_ERROR_RE = re.compile(
|
|
41
|
+
r'(?:Syntax error|Illegal function call|Type mismatch|Out of |'
|
|
42
|
+
r'Division by zero|Overflow|RETURN without GOSUB|'
|
|
43
|
+
r'NEXT without FOR|Undefined |Subscript out of range|'
|
|
44
|
+
r'Redo from start|String too long|'
|
|
45
|
+
r'WHILE without WEND|WEND without WHILE|'
|
|
46
|
+
r'File not found|File already open|Bad file |'
|
|
47
|
+
r'Direct statement in file|Missing operand|'
|
|
48
|
+
r'Duplicate Definition|Formula too complex|'
|
|
49
|
+
r'Internal error|Unprintable error)',
|
|
50
|
+
re.IGNORECASE,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# DCS (ESC P) starts Sixel, ST (ESC \) ends it — both as bytes
|
|
54
|
+
_DCS = b'\x1bP'
|
|
55
|
+
_ST = b'\x1b\\'
|
|
56
|
+
|
|
57
|
+
GW_KEYWORDS = [
|
|
58
|
+
'AUTO', 'BEEP', 'BLOAD', 'BSAVE', 'CALL', 'CHAIN', 'CHDIR',
|
|
59
|
+
'CIRCLE', 'CLEAR', 'CLOSE', 'CLS', 'COLOR', 'COM', 'COMMON',
|
|
60
|
+
'CONT', 'DATA', 'DATE$', 'DEF', 'DEFDBL', 'DEFINT', 'DEFSNG',
|
|
61
|
+
'DEFSTR', 'DELETE', 'DIM', 'DRAW', 'EDIT', 'ELSE', 'END',
|
|
62
|
+
'ENVIRON', 'ENVIRON$', 'ERASE', 'ERDEV', 'ERDEV$', 'ERR', 'ERL',
|
|
63
|
+
'ERROR', 'FIELD', 'FILES', 'FN', 'FOR', 'GET', 'GOSUB', 'GOTO',
|
|
64
|
+
'IF', 'INPUT', 'IOCTL', 'IOCTL$', 'KEY', 'KILL', 'LCOPY',
|
|
65
|
+
'LET', 'LINE', 'LIST', 'LLIST', 'LOAD', 'LOCATE', 'LPRINT',
|
|
66
|
+
'LSET', 'MERGE', 'MKDIR', 'MOTOR', 'NAME', 'NEW', 'NEXT',
|
|
67
|
+
'NOISE', 'ON', 'OPEN', 'OPTION', 'OUT', 'PAINT', 'PALETTE',
|
|
68
|
+
'PLAY', 'POKE', 'PRESET', 'PRINT', 'PSET', 'PUT', 'RANDOMIZE',
|
|
69
|
+
'READ', 'REM', 'RENUM', 'RESET', 'RESTORE', 'RESUME', 'RETURN',
|
|
70
|
+
'RMDIR', 'RSET', 'RUN', 'SAVE', 'SCREEN', 'SHELL', 'SOUND',
|
|
71
|
+
'STOP', 'SWAP', 'SYSTEM', 'THEN', 'TIME$', 'TIMER', 'TO',
|
|
72
|
+
'TRON', 'TROFF', 'VIEW', 'WAIT', 'WEND', 'WHILE', 'WIDTH',
|
|
73
|
+
'WINDOW', 'WRITE',
|
|
74
|
+
'ABS', 'ASC', 'ATN', 'CDBL', 'CHR$', 'CINT', 'COS', 'CSNG',
|
|
75
|
+
'CSRLIN', 'CVD', 'CVI', 'CVS', 'EOF', 'EXP', 'FIX', 'FRE',
|
|
76
|
+
'HEX$', 'INKEY$', 'INP', 'INPUT$', 'INSTR', 'INT', 'LEFT$',
|
|
77
|
+
'LEN', 'LOC', 'LOF', 'LOG', 'LPOS', 'MID$', 'MKD$', 'MKI$',
|
|
78
|
+
'MKS$', 'OCT$', 'PEEK', 'PEN', 'PMAP', 'POINT', 'POS',
|
|
79
|
+
'RIGHT$', 'RND', 'SGN', 'SIN', 'SPACE$', 'SPC', 'SQR',
|
|
80
|
+
'STICK', 'STR$', 'STRIG', 'STRING$', 'TAB', 'TAN', 'USR',
|
|
81
|
+
'VAL', 'VARPTR', 'VARPTR$',
|
|
82
|
+
'AND', 'EQV', 'IMP', 'MOD', 'NOT', 'OR', 'XOR', 'STEP',
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---------- Sixel decoder (pure Python, no PIL dependency) ----------
|
|
87
|
+
|
|
88
|
+
def _parse_sixel_palette(data):
|
|
89
|
+
"""Parse #n;2;r;g;b palette definitions from Sixel data."""
|
|
90
|
+
palette = {}
|
|
91
|
+
for m in re.finditer(rb'#(\d+);2;(\d+);(\d+);(\d+)', data):
|
|
92
|
+
idx = int(m.group(1))
|
|
93
|
+
r = int(m.group(2)) * 255 // 100
|
|
94
|
+
g = int(m.group(3)) * 255 // 100
|
|
95
|
+
b = int(m.group(4)) * 255 // 100
|
|
96
|
+
palette[idx] = (r, g, b)
|
|
97
|
+
return palette
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _decode_sixel(data):
|
|
101
|
+
"""Decode Sixel data into an RGBA image. Returns (width, height, rgba_bytes)."""
|
|
102
|
+
# Strip DCS/ST wrappers if present
|
|
103
|
+
if data.startswith(_DCS):
|
|
104
|
+
data = data[len(_DCS):]
|
|
105
|
+
if data.endswith(_ST):
|
|
106
|
+
data = data[:-len(_ST)]
|
|
107
|
+
|
|
108
|
+
# Skip mode char (usually 'q')
|
|
109
|
+
if data and data[0:1] == b'q':
|
|
110
|
+
data = data[1:]
|
|
111
|
+
|
|
112
|
+
palette = _parse_sixel_palette(data)
|
|
113
|
+
if not palette:
|
|
114
|
+
palette = {0: (0, 0, 0)}
|
|
115
|
+
|
|
116
|
+
# First pass: determine image dimensions
|
|
117
|
+
x = 0
|
|
118
|
+
y = 0
|
|
119
|
+
max_x = 0
|
|
120
|
+
max_y = 0
|
|
121
|
+
color = 0
|
|
122
|
+
i = 0
|
|
123
|
+
while i < len(data):
|
|
124
|
+
ch = data[i]
|
|
125
|
+
if ch == ord('#'):
|
|
126
|
+
# Color selector or palette def — skip palette defs
|
|
127
|
+
j = i + 1
|
|
128
|
+
num = b''
|
|
129
|
+
while j < len(data) and (data[j] in b'0123456789'):
|
|
130
|
+
num += bytes([data[j]])
|
|
131
|
+
j += 1
|
|
132
|
+
if j < len(data) and data[j] == ord(';'):
|
|
133
|
+
# Palette definition — skip to end
|
|
134
|
+
while j < len(data) and data[j] not in b'#$-\x1b' and not (63 <= data[j] <= 126):
|
|
135
|
+
j += 1
|
|
136
|
+
i = j
|
|
137
|
+
continue
|
|
138
|
+
color = int(num) if num else 0
|
|
139
|
+
i = j
|
|
140
|
+
elif ch == ord('$'):
|
|
141
|
+
x = 0
|
|
142
|
+
i += 1
|
|
143
|
+
elif ch == ord('-'):
|
|
144
|
+
x = 0
|
|
145
|
+
y += 6
|
|
146
|
+
i += 1
|
|
147
|
+
elif ch == ord('!'):
|
|
148
|
+
# RLE: !count<char>
|
|
149
|
+
j = i + 1
|
|
150
|
+
num = b''
|
|
151
|
+
while j < len(data) and data[j] in b'0123456789':
|
|
152
|
+
num += bytes([data[j]])
|
|
153
|
+
j += 1
|
|
154
|
+
count = int(num) if num else 1
|
|
155
|
+
if j < len(data) and 63 <= data[j] <= 126:
|
|
156
|
+
x += count
|
|
157
|
+
j += 1
|
|
158
|
+
max_x = max(max_x, x)
|
|
159
|
+
max_y = max(max_y, y + 6)
|
|
160
|
+
i = j
|
|
161
|
+
elif 63 <= ch <= 126:
|
|
162
|
+
x += 1
|
|
163
|
+
max_x = max(max_x, x)
|
|
164
|
+
max_y = max(max_y, y + 6)
|
|
165
|
+
i += 1
|
|
166
|
+
else:
|
|
167
|
+
i += 1
|
|
168
|
+
|
|
169
|
+
if max_x == 0 or max_y == 0:
|
|
170
|
+
return 0, 0, b''
|
|
171
|
+
|
|
172
|
+
width, height = max_x, max_y
|
|
173
|
+
# RGBA buffer (transparent background)
|
|
174
|
+
pixels = bytearray(width * height * 4)
|
|
175
|
+
|
|
176
|
+
# Second pass: render pixels
|
|
177
|
+
x = 0
|
|
178
|
+
y = 0
|
|
179
|
+
color = 0
|
|
180
|
+
i = 0
|
|
181
|
+
while i < len(data):
|
|
182
|
+
ch = data[i]
|
|
183
|
+
if ch == ord('#'):
|
|
184
|
+
j = i + 1
|
|
185
|
+
num = b''
|
|
186
|
+
while j < len(data) and data[j] in b'0123456789':
|
|
187
|
+
num += bytes([data[j]])
|
|
188
|
+
j += 1
|
|
189
|
+
if j < len(data) and data[j] == ord(';'):
|
|
190
|
+
while j < len(data) and data[j] not in b'#$-\x1b' and not (63 <= data[j] <= 126):
|
|
191
|
+
j += 1
|
|
192
|
+
i = j
|
|
193
|
+
continue
|
|
194
|
+
color = int(num) if num else 0
|
|
195
|
+
i = j
|
|
196
|
+
elif ch == ord('$'):
|
|
197
|
+
x = 0
|
|
198
|
+
i += 1
|
|
199
|
+
elif ch == ord('-'):
|
|
200
|
+
x = 0
|
|
201
|
+
y += 6
|
|
202
|
+
i += 1
|
|
203
|
+
elif ch == ord('!'):
|
|
204
|
+
j = i + 1
|
|
205
|
+
num = b''
|
|
206
|
+
while j < len(data) and data[j] in b'0123456789':
|
|
207
|
+
num += bytes([data[j]])
|
|
208
|
+
j += 1
|
|
209
|
+
count = int(num) if num else 1
|
|
210
|
+
if j < len(data) and 63 <= data[j] <= 126:
|
|
211
|
+
sixel = data[j] - 63
|
|
212
|
+
j += 1
|
|
213
|
+
r, g, b = palette.get(color, (0, 0, 0))
|
|
214
|
+
for dx in range(count):
|
|
215
|
+
cx = x + dx
|
|
216
|
+
if cx >= width:
|
|
217
|
+
break
|
|
218
|
+
for bit in range(6):
|
|
219
|
+
if sixel & (1 << bit):
|
|
220
|
+
cy = y + bit
|
|
221
|
+
if cy < height:
|
|
222
|
+
off = (cy * width + cx) * 4
|
|
223
|
+
pixels[off] = r
|
|
224
|
+
pixels[off + 1] = g
|
|
225
|
+
pixels[off + 2] = b
|
|
226
|
+
pixels[off + 3] = 255
|
|
227
|
+
x += count
|
|
228
|
+
i = j
|
|
229
|
+
elif 63 <= ch <= 126:
|
|
230
|
+
sixel = ch - 63
|
|
231
|
+
r, g, b = palette.get(color, (0, 0, 0))
|
|
232
|
+
if x < width:
|
|
233
|
+
for bit in range(6):
|
|
234
|
+
if sixel & (1 << bit):
|
|
235
|
+
cy = y + bit
|
|
236
|
+
if cy < height:
|
|
237
|
+
off = (cy * width + x) * 4
|
|
238
|
+
pixels[off] = r
|
|
239
|
+
pixels[off + 1] = g
|
|
240
|
+
pixels[off + 2] = b
|
|
241
|
+
pixels[off + 3] = 255
|
|
242
|
+
x += 1
|
|
243
|
+
i += 1
|
|
244
|
+
else:
|
|
245
|
+
i += 1
|
|
246
|
+
|
|
247
|
+
return width, height, bytes(pixels)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _rgba_to_png(width, height, rgba):
|
|
251
|
+
"""Encode raw RGBA pixels as PNG (pure Python, no dependencies)."""
|
|
252
|
+
def _chunk(tag, data):
|
|
253
|
+
c = tag + data
|
|
254
|
+
return struct.pack('>I', len(data)) + c + struct.pack('>I', zlib.crc32(c) & 0xFFFFFFFF)
|
|
255
|
+
|
|
256
|
+
ihdr = struct.pack('>IIBBBBB', width, height, 8, 6, 0, 0, 0)
|
|
257
|
+
raw = b''
|
|
258
|
+
stride = width * 4
|
|
259
|
+
for y in range(height):
|
|
260
|
+
raw += b'\x00' + rgba[y * stride:(y + 1) * stride]
|
|
261
|
+
idat = zlib.compress(raw)
|
|
262
|
+
|
|
263
|
+
out = b'\x89PNG\r\n\x1a\n'
|
|
264
|
+
out += _chunk(b'IHDR', ihdr)
|
|
265
|
+
out += _chunk(b'IDAT', idat)
|
|
266
|
+
out += _chunk(b'IEND', b'')
|
|
267
|
+
return out
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# ---------- Kernel class ----------
|
|
271
|
+
|
|
272
|
+
class GWBasicKernel(Kernel):
|
|
273
|
+
implementation = 'gwbasickernel'
|
|
274
|
+
implementation_version = __version__
|
|
275
|
+
language = 'basic'
|
|
276
|
+
language_version = '2026'
|
|
277
|
+
language_info = {
|
|
278
|
+
'name': 'basic',
|
|
279
|
+
'mimetype': 'text/x-basic',
|
|
280
|
+
'file_extension': '.bas',
|
|
281
|
+
'codemirror_mode': 'vb',
|
|
282
|
+
'pygments_lexer': 'gwbasickernel.basic_lexer.GWBasicLexer',
|
|
283
|
+
}
|
|
284
|
+
banner = 'GW-BASIC 2026 Jupyter Kernel'
|
|
285
|
+
|
|
286
|
+
def __init__(self, **kwargs):
|
|
287
|
+
super().__init__(**kwargs)
|
|
288
|
+
self._proc = None
|
|
289
|
+
self._timeout = int(os.environ.get('GWBASIC_TIMEOUT', '30'))
|
|
290
|
+
|
|
291
|
+
def _find_binary(self):
|
|
292
|
+
env = os.environ.get('GWBASIC')
|
|
293
|
+
if env and os.path.isfile(env) and os.access(env, os.X_OK):
|
|
294
|
+
return env
|
|
295
|
+
for candidate in ['gwbasic', './build/gwbasic', './gwbasic']:
|
|
296
|
+
found = shutil.which(candidate)
|
|
297
|
+
if found:
|
|
298
|
+
return found
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
def _start_process(self):
|
|
302
|
+
self._kill_process()
|
|
303
|
+
binary = self._find_binary()
|
|
304
|
+
if not binary:
|
|
305
|
+
return False
|
|
306
|
+
self._proc = subprocess.Popen(
|
|
307
|
+
[binary],
|
|
308
|
+
stdin=subprocess.PIPE,
|
|
309
|
+
stdout=subprocess.PIPE,
|
|
310
|
+
stderr=subprocess.STDOUT,
|
|
311
|
+
bufsize=0,
|
|
312
|
+
)
|
|
313
|
+
return True
|
|
314
|
+
|
|
315
|
+
def _kill_process(self):
|
|
316
|
+
if self._proc is not None:
|
|
317
|
+
try:
|
|
318
|
+
self._proc.kill()
|
|
319
|
+
self._proc.wait(timeout=5)
|
|
320
|
+
except (ProcessLookupError, subprocess.TimeoutExpired):
|
|
321
|
+
pass
|
|
322
|
+
self._proc = None
|
|
323
|
+
|
|
324
|
+
def _is_alive(self):
|
|
325
|
+
return self._proc is not None and self._proc.poll() is None
|
|
326
|
+
|
|
327
|
+
def _send_interrupt_children(self):
|
|
328
|
+
if self._is_alive():
|
|
329
|
+
self._proc.send_signal(signal.SIGINT)
|
|
330
|
+
else:
|
|
331
|
+
super()._send_interrupt_children()
|
|
332
|
+
|
|
333
|
+
def _read_until_sentinel(self, allow_stdin=False):
|
|
334
|
+
"""Read raw bytes from stdout until sentinel or timeout.
|
|
335
|
+
|
|
336
|
+
When allow_stdin is True and the output ends with "? " (INPUT prompt),
|
|
337
|
+
the kernel requests input from the notebook front-end and writes the
|
|
338
|
+
response to the subprocess's stdin.
|
|
339
|
+
|
|
340
|
+
Returns (raw_bytes, status).
|
|
341
|
+
"""
|
|
342
|
+
fd = self._proc.stdout.fileno()
|
|
343
|
+
buf = b''
|
|
344
|
+
sentinel_bytes = _SENTINEL.encode()
|
|
345
|
+
|
|
346
|
+
while True:
|
|
347
|
+
try:
|
|
348
|
+
ready, _, _ = select.select([fd], [], [], self._timeout)
|
|
349
|
+
except (KeyboardInterrupt, InterruptedError):
|
|
350
|
+
if self._is_alive():
|
|
351
|
+
self._proc.send_signal(signal.SIGINT)
|
|
352
|
+
return buf, 'interrupted'
|
|
353
|
+
|
|
354
|
+
if not ready:
|
|
355
|
+
# Check for INPUT prompt before declaring timeout
|
|
356
|
+
if allow_stdin and buf.endswith(b'? '):
|
|
357
|
+
reply = self.raw_input('? ')
|
|
358
|
+
self._proc.stdin.write((reply + '\n').encode())
|
|
359
|
+
self._proc.stdin.flush()
|
|
360
|
+
continue
|
|
361
|
+
return buf, 'timeout'
|
|
362
|
+
|
|
363
|
+
chunk = os.read(fd, 8192)
|
|
364
|
+
if not chunk:
|
|
365
|
+
return buf, 'died'
|
|
366
|
+
|
|
367
|
+
buf += chunk
|
|
368
|
+
|
|
369
|
+
# Check for sentinel in the buffer
|
|
370
|
+
if sentinel_bytes in buf:
|
|
371
|
+
idx = buf.index(sentinel_bytes)
|
|
372
|
+
# Keep everything before the sentinel, stripping the
|
|
373
|
+
# PRINT output that contains it (newline + sentinel + newline)
|
|
374
|
+
before = buf[:idx]
|
|
375
|
+
# Remove trailing newline left by PRINT before sentinel
|
|
376
|
+
if before.endswith(b'\n'):
|
|
377
|
+
before = before[:-1]
|
|
378
|
+
return before, 'done'
|
|
379
|
+
|
|
380
|
+
# Check for INPUT prompt: "? " at end of buffer (no newline after)
|
|
381
|
+
if allow_stdin and buf.endswith(b'? '):
|
|
382
|
+
last_nl = buf.rfind(b'\n')
|
|
383
|
+
tail = buf[last_nl + 1:] if last_nl >= 0 else buf
|
|
384
|
+
if b'\n' not in tail[:-2]: # no embedded newline before "? "
|
|
385
|
+
prompt_line = tail.decode('utf-8', errors='replace')
|
|
386
|
+
reply = self.raw_input(prompt_line)
|
|
387
|
+
self._proc.stdin.write((reply + '\n').encode())
|
|
388
|
+
self._proc.stdin.flush()
|
|
389
|
+
|
|
390
|
+
def _split_sixel(self, raw):
|
|
391
|
+
"""Split raw output into (text_parts, sixel_blobs) interleaved."""
|
|
392
|
+
parts = []
|
|
393
|
+
pos = 0
|
|
394
|
+
while True:
|
|
395
|
+
dcs_idx = raw.find(_DCS, pos)
|
|
396
|
+
if dcs_idx == -1:
|
|
397
|
+
parts.append(('text', raw[pos:]))
|
|
398
|
+
break
|
|
399
|
+
if dcs_idx > pos:
|
|
400
|
+
parts.append(('text', raw[pos:dcs_idx]))
|
|
401
|
+
st_idx = raw.find(_ST, dcs_idx)
|
|
402
|
+
if st_idx == -1:
|
|
403
|
+
parts.append(('text', raw[pos:]))
|
|
404
|
+
break
|
|
405
|
+
end = st_idx + len(_ST)
|
|
406
|
+
parts.append(('sixel', raw[dcs_idx:end]))
|
|
407
|
+
pos = end
|
|
408
|
+
return parts
|
|
409
|
+
|
|
410
|
+
def _has_error(self, text):
|
|
411
|
+
return bool(_ERROR_RE.search(text))
|
|
412
|
+
|
|
413
|
+
def _handle_magic(self, code):
|
|
414
|
+
stripped = code.strip()
|
|
415
|
+
if stripped == '%reset':
|
|
416
|
+
self._kill_process()
|
|
417
|
+
return True, 'Session reset.'
|
|
418
|
+
if stripped.startswith('%timeout'):
|
|
419
|
+
parts = stripped.split()
|
|
420
|
+
if len(parts) == 2:
|
|
421
|
+
try:
|
|
422
|
+
self._timeout = int(parts[1])
|
|
423
|
+
return True, f'Timeout set to {self._timeout}s.'
|
|
424
|
+
except ValueError:
|
|
425
|
+
return True, 'Usage: %timeout <seconds>'
|
|
426
|
+
return True, f'Current timeout: {self._timeout}s. Usage: %timeout <seconds>'
|
|
427
|
+
if stripped == '%new':
|
|
428
|
+
if self._is_alive():
|
|
429
|
+
self._proc.stdin.write(b'NEW\n')
|
|
430
|
+
self._proc.stdin.write(_SENTINEL_CMD.encode())
|
|
431
|
+
self._proc.stdin.flush()
|
|
432
|
+
self._read_until_sentinel()
|
|
433
|
+
return True, 'Program cleared.'
|
|
434
|
+
return False, None
|
|
435
|
+
|
|
436
|
+
def do_execute(self, code, silent, store_history=True,
|
|
437
|
+
user_expressions=None, allow_stdin=False):
|
|
438
|
+
handled, response = self._handle_magic(code)
|
|
439
|
+
if handled:
|
|
440
|
+
if not silent and response:
|
|
441
|
+
self.send_response(self.iopub_socket, 'stream',
|
|
442
|
+
{'name': 'stdout', 'text': response + '\n'})
|
|
443
|
+
return {
|
|
444
|
+
'status': 'ok', 'execution_count': self.execution_count,
|
|
445
|
+
'payload': [], 'user_expressions': {},
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if not code.strip():
|
|
449
|
+
return {
|
|
450
|
+
'status': 'ok', 'execution_count': self.execution_count,
|
|
451
|
+
'payload': [], 'user_expressions': {},
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if not self._is_alive():
|
|
455
|
+
if not self._start_process():
|
|
456
|
+
if not silent:
|
|
457
|
+
self.send_response(self.iopub_socket, 'stream', {
|
|
458
|
+
'name': 'stderr',
|
|
459
|
+
'text': 'gwbasic binary not found.\n'
|
|
460
|
+
'Set GWBASIC env var or install gwbasic on PATH.\n',
|
|
461
|
+
})
|
|
462
|
+
return {
|
|
463
|
+
'status': 'error', 'execution_count': self.execution_count,
|
|
464
|
+
'ename': 'FileNotFoundError', 'evalue': 'gwbasic not found',
|
|
465
|
+
'traceback': [],
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
for line in code.split('\n'):
|
|
469
|
+
line = line.rstrip()
|
|
470
|
+
if line:
|
|
471
|
+
self._proc.stdin.write((line + '\n').encode())
|
|
472
|
+
|
|
473
|
+
self._proc.stdin.write(_SENTINEL_CMD.encode())
|
|
474
|
+
self._proc.stdin.flush()
|
|
475
|
+
|
|
476
|
+
raw, status = self._read_until_sentinel(allow_stdin=allow_stdin)
|
|
477
|
+
|
|
478
|
+
if status == 'interrupted':
|
|
479
|
+
if not silent:
|
|
480
|
+
self.send_response(self.iopub_socket, 'stream', {
|
|
481
|
+
'name': 'stderr', 'text': 'Execution interrupted.\n',
|
|
482
|
+
})
|
|
483
|
+
return {'status': 'abort', 'execution_count': self.execution_count}
|
|
484
|
+
|
|
485
|
+
if status == 'timeout':
|
|
486
|
+
self._kill_process()
|
|
487
|
+
if not silent:
|
|
488
|
+
self.send_response(self.iopub_socket, 'stream', {
|
|
489
|
+
'name': 'stderr',
|
|
490
|
+
'text': f'Execution timed out after {self._timeout}s. '
|
|
491
|
+
f'Use %timeout to increase.\n',
|
|
492
|
+
})
|
|
493
|
+
return {
|
|
494
|
+
'status': 'error', 'execution_count': self.execution_count,
|
|
495
|
+
'ename': 'TimeoutError',
|
|
496
|
+
'evalue': f'Timed out after {self._timeout}s',
|
|
497
|
+
'traceback': [],
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if status == 'died':
|
|
501
|
+
self._proc = None
|
|
502
|
+
text = raw.decode('utf-8', errors='replace').strip()
|
|
503
|
+
if not silent and text:
|
|
504
|
+
self.send_response(self.iopub_socket, 'stream', {
|
|
505
|
+
'name': 'stderr',
|
|
506
|
+
'text': f'GW-BASIC process exited.\n{text}\n',
|
|
507
|
+
})
|
|
508
|
+
return {
|
|
509
|
+
'status': 'error', 'execution_count': self.execution_count,
|
|
510
|
+
'ename': 'RuntimeError', 'evalue': 'Process exited',
|
|
511
|
+
'traceback': [],
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
# Split output into text and Sixel graphics
|
|
515
|
+
parts = self._split_sixel(raw)
|
|
516
|
+
|
|
517
|
+
is_error = False
|
|
518
|
+
for kind, data in parts:
|
|
519
|
+
if kind == 'text':
|
|
520
|
+
text = data.decode('utf-8', errors='replace').strip()
|
|
521
|
+
if not text:
|
|
522
|
+
continue
|
|
523
|
+
if self._has_error(text):
|
|
524
|
+
is_error = True
|
|
525
|
+
if not silent:
|
|
526
|
+
self.send_response(self.iopub_socket, 'stream', {
|
|
527
|
+
'name': 'stderr' if is_error else 'stdout',
|
|
528
|
+
'text': text + '\n',
|
|
529
|
+
})
|
|
530
|
+
elif kind == 'sixel' and not silent:
|
|
531
|
+
w, h, rgba = _decode_sixel(data)
|
|
532
|
+
if w > 0 and h > 0:
|
|
533
|
+
png = _rgba_to_png(w, h, rgba)
|
|
534
|
+
b64 = base64.b64encode(png).decode('ascii')
|
|
535
|
+
self.send_response(self.iopub_socket, 'display_data', {
|
|
536
|
+
'data': {'image/png': b64},
|
|
537
|
+
'metadata': {'image/png': {'width': w, 'height': h}},
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
if is_error:
|
|
541
|
+
err_text = raw.decode('utf-8', errors='replace').strip()
|
|
542
|
+
return {
|
|
543
|
+
'status': 'error', 'execution_count': self.execution_count,
|
|
544
|
+
'ename': 'BASICError', 'evalue': err_text.split('\n')[0],
|
|
545
|
+
'traceback': err_text.split('\n'),
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return {
|
|
549
|
+
'status': 'ok', 'execution_count': self.execution_count,
|
|
550
|
+
'payload': [], 'user_expressions': {},
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
def do_complete(self, code, cursor_pos):
|
|
554
|
+
text = code[:cursor_pos]
|
|
555
|
+
match = re.search(r'([A-Za-z_]\w*\$?)$', text)
|
|
556
|
+
if not match:
|
|
557
|
+
return {'matches': [], 'cursor_start': cursor_pos,
|
|
558
|
+
'cursor_end': cursor_pos, 'status': 'ok'}
|
|
559
|
+
|
|
560
|
+
token = match.group(1).upper()
|
|
561
|
+
start = cursor_pos - len(token)
|
|
562
|
+
matches = [kw for kw in GW_KEYWORDS if kw.startswith(token)]
|
|
563
|
+
return {
|
|
564
|
+
'matches': matches, 'cursor_start': start,
|
|
565
|
+
'cursor_end': cursor_pos, 'status': 'ok',
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
def do_is_complete(self, code):
|
|
569
|
+
if not code.strip():
|
|
570
|
+
return {'status': 'incomplete', 'indent': ''}
|
|
571
|
+
return {'status': 'complete'}
|
|
572
|
+
|
|
573
|
+
def do_shutdown(self, restart):
|
|
574
|
+
self._kill_process()
|
|
575
|
+
return {'status': 'ok', 'restart': restart}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Smoke test for the GW-BASIC Jupyter kernel.
|
|
2
|
+
|
|
3
|
+
Spawns the kernel class directly and exercises the do_execute path
|
|
4
|
+
via the subprocess protocol. No running Jupyter instance required.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
# Ensure gwbasic binary is found
|
|
11
|
+
os.environ['GWBASIC'] = os.path.join(os.path.dirname(__file__), '..', 'build', 'gwbasic')
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_kernel():
|
|
15
|
+
from .kernel import GWBasicKernel, _decode_sixel, _rgba_to_png
|
|
16
|
+
|
|
17
|
+
# Minimal mock — we only need the subprocess communication, not ZMQ
|
|
18
|
+
class MockKernel(GWBasicKernel):
|
|
19
|
+
execution_count = 1
|
|
20
|
+
iopub_socket = None
|
|
21
|
+
_captured = []
|
|
22
|
+
|
|
23
|
+
def __init__(self):
|
|
24
|
+
self._proc = None
|
|
25
|
+
self._timeout = 10
|
|
26
|
+
|
|
27
|
+
def send_response(self, socket, msg_type, content):
|
|
28
|
+
self._captured.append((msg_type, content))
|
|
29
|
+
|
|
30
|
+
k = MockKernel()
|
|
31
|
+
|
|
32
|
+
def run(code):
|
|
33
|
+
k._captured = []
|
|
34
|
+
result = k.do_execute(code, silent=False)
|
|
35
|
+
text = ''
|
|
36
|
+
has_image = False
|
|
37
|
+
for msg_type, content in k._captured:
|
|
38
|
+
if msg_type == 'stream':
|
|
39
|
+
text += content.get('text', '')
|
|
40
|
+
elif msg_type == 'display_data':
|
|
41
|
+
has_image = True
|
|
42
|
+
return result, text.strip(), has_image
|
|
43
|
+
|
|
44
|
+
# Test 1: Simple PRINT
|
|
45
|
+
r, out, _ = run('PRINT "Hello Jupyter"')
|
|
46
|
+
assert r['status'] == 'ok', f'Expected ok, got {r}'
|
|
47
|
+
assert 'Hello Jupyter' in out, f'Expected Hello Jupyter, got: {out}'
|
|
48
|
+
print(f' PASS PRINT: {out}')
|
|
49
|
+
|
|
50
|
+
# Test 2: Arithmetic
|
|
51
|
+
r, out, _ = run('PRINT 6*7')
|
|
52
|
+
assert r['status'] == 'ok'
|
|
53
|
+
assert '42' in out, f'Expected 42, got: {out}'
|
|
54
|
+
print(f' PASS Arithmetic: {out}')
|
|
55
|
+
|
|
56
|
+
# Test 3: State persistence across cells
|
|
57
|
+
r, _, _ = run('X = 42')
|
|
58
|
+
assert r['status'] == 'ok'
|
|
59
|
+
r, out, _ = run('PRINT X')
|
|
60
|
+
assert '42' in out, f'Expected 42, got: {out}'
|
|
61
|
+
print(f' PASS State persistence: {out}')
|
|
62
|
+
|
|
63
|
+
# Test 4: Error handling
|
|
64
|
+
r, out, _ = run('PRINT 1/0')
|
|
65
|
+
assert r['status'] == 'error', f'Expected error, got {r}'
|
|
66
|
+
assert 'Division by zero' in out, f'Expected Division by zero, got: {out}'
|
|
67
|
+
print(f' PASS Error: {out}')
|
|
68
|
+
|
|
69
|
+
# Test 5: Recovery after error
|
|
70
|
+
r, out, _ = run('PRINT "OK after error"')
|
|
71
|
+
assert r['status'] == 'ok'
|
|
72
|
+
assert 'OK after error' in out
|
|
73
|
+
print(f' PASS Recovery: {out}')
|
|
74
|
+
|
|
75
|
+
# Test 6: Program RUN
|
|
76
|
+
r, _, _ = run('10 PRINT "Alpha"\n20 PRINT "Beta"')
|
|
77
|
+
assert r['status'] == 'ok'
|
|
78
|
+
r, out, _ = run('RUN')
|
|
79
|
+
assert 'Alpha' in out and 'Beta' in out, f'Expected Alpha+Beta, got: {out}'
|
|
80
|
+
print(f' PASS RUN: {repr(out)}')
|
|
81
|
+
|
|
82
|
+
# Test 7: Magic %reset
|
|
83
|
+
r, out, _ = run('%reset')
|
|
84
|
+
assert r['status'] == 'ok'
|
|
85
|
+
assert 'reset' in out.lower()
|
|
86
|
+
print(f' PASS %reset: {out}')
|
|
87
|
+
|
|
88
|
+
# Test 8: String functions
|
|
89
|
+
r, out, _ = run('PRINT LEFT$("HELLO", 3)')
|
|
90
|
+
assert 'HEL' in out, f'Expected HEL, got: {out}'
|
|
91
|
+
print(f' PASS String functions: {out}')
|
|
92
|
+
|
|
93
|
+
# Test 9: Multi-statement line
|
|
94
|
+
r, out, _ = run('A=10:B=20:PRINT A+B')
|
|
95
|
+
assert '30' in out, f'Expected 30, got: {out}'
|
|
96
|
+
print(f' PASS Multi-statement: {out}')
|
|
97
|
+
|
|
98
|
+
# Test 10: FRE returns real value
|
|
99
|
+
r, out, _ = run('PRINT FRE(0)')
|
|
100
|
+
assert r['status'] == 'ok'
|
|
101
|
+
print(f' PASS FRE: {out}')
|
|
102
|
+
|
|
103
|
+
# Test 11: Sixel decoder (unit test)
|
|
104
|
+
# Minimal Sixel: single red pixel at position (0,0)
|
|
105
|
+
# '@' = ASCII 64 = sixel value 1 = bit 0 set = row 0 pixel
|
|
106
|
+
sixel_data = b'\x1bPq#1;2;100;0;0#1@\x1b\\'
|
|
107
|
+
w, h, rgba = _decode_sixel(sixel_data)
|
|
108
|
+
assert w == 1 and h == 6, f'Expected 1x6, got {w}x{h}'
|
|
109
|
+
# Pixel (0,0) should be red (255,0,0,255)
|
|
110
|
+
assert rgba[0] == 255 and rgba[1] == 0 and rgba[2] == 0 and rgba[3] == 255, \
|
|
111
|
+
f'Expected red pixel, got {list(rgba[:4])}'
|
|
112
|
+
print(f' PASS Sixel decode: {w}x{h} pixel')
|
|
113
|
+
|
|
114
|
+
# Test 12: PNG encoder (unit test)
|
|
115
|
+
png = _rgba_to_png(1, 1, bytes([255, 0, 0, 255]))
|
|
116
|
+
assert png[:8] == b'\x89PNG\r\n\x1a\n', 'Invalid PNG header'
|
|
117
|
+
print(f' PASS PNG encode: {len(png)} bytes')
|
|
118
|
+
|
|
119
|
+
# Test 13: Sixel graphics via SCREEN (integration)
|
|
120
|
+
r, out, has_img = run('SCREEN 1\nPSET (10,10), 1\nSCREEN 0')
|
|
121
|
+
# The Sixel output should be detected and rendered as an image
|
|
122
|
+
assert r['status'] == 'ok', f'Expected ok, got {r}'
|
|
123
|
+
if has_img:
|
|
124
|
+
print(' PASS Sixel graphics: inline image rendered')
|
|
125
|
+
else:
|
|
126
|
+
print(' PASS Sixel graphics: no image (Sixel may not be emitted in piped mode)')
|
|
127
|
+
|
|
128
|
+
# Test 14: Pygments lexer loads
|
|
129
|
+
from .basic_lexer import GWBasicLexer
|
|
130
|
+
lexer = GWBasicLexer()
|
|
131
|
+
tokens = list(lexer.get_tokens('10 PRINT "Hello"'))
|
|
132
|
+
assert len(tokens) > 0, 'Lexer produced no tokens'
|
|
133
|
+
print(f' PASS Pygments lexer: {len(tokens)} tokens')
|
|
134
|
+
|
|
135
|
+
k.do_shutdown(False)
|
|
136
|
+
print(f'\n14 tests passed.')
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if __name__ == '__main__':
|
|
140
|
+
test_kernel()
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gwbasickernel
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Jupyter kernel for GW-BASIC 2026 with inline graphics
|
|
5
|
+
Author: Eremey Valetov
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/evvaletov/gw-basic-2026
|
|
8
|
+
Project-URL: Documentation, https://evvaletov.github.io/gw-basic-2026/
|
|
9
|
+
Project-URL: Issues, https://github.com/evvaletov/gw-basic-2026/issues
|
|
10
|
+
Classifier: Framework :: Jupyter
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
13
|
+
Classifier: Operating System :: MacOS
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: ipykernel>=6
|
|
18
|
+
Requires-Dist: jupyter_client>=8
|
|
19
|
+
Requires-Dist: pygments>=2
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# GW-BASIC Jupyter kernel
|
|
23
|
+
|
|
24
|
+
Run GW-BASIC programs in JupyterLab or Notebook. Variables and program lines
|
|
25
|
+
persist between cells. The kernel converts the interpreter's Sixel output to
|
|
26
|
+
inline PNG images, so the notebook browser does not need Sixel support.
|
|
27
|
+
|
|
28
|
+
Requires Python 3.10 or later and the native
|
|
29
|
+
[GW-BASIC 2026 interpreter](https://github.com/evvaletov/gw-basic-2026)
|
|
30
|
+
on Linux or macOS. The Python package does not include that interpreter.
|
|
31
|
+
The DOS executables cannot be used as the notebook backend.
|
|
32
|
+
|
|
33
|
+
## Install the interpreter
|
|
34
|
+
|
|
35
|
+
With Homebrew on Linux or macOS:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
brew tap evvaletov/gwbasic https://github.com/evvaletov/gw-basic-2026.git
|
|
39
|
+
brew install evvaletov/gwbasic/gwbasic
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The Homebrew formula builds from a pinned source revision. It includes the
|
|
43
|
+
interpreter and native compiler, with PulseAudio disabled.
|
|
44
|
+
|
|
45
|
+
Alternatively, build from source with a C11 compiler and CMake:
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
git clone https://github.com/evvaletov/gw-basic-2026.git
|
|
49
|
+
cd gw-basic-2026
|
|
50
|
+
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
|
|
51
|
+
cmake --build build --parallel 2
|
|
52
|
+
export GWBASIC="$PWD/build/gwbasic"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Install the kernel
|
|
56
|
+
|
|
57
|
+
Create a Python environment for the kernel and JupyterLab:
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
python3 -m venv .venv
|
|
61
|
+
. .venv/bin/activate
|
|
62
|
+
python -m pip install gwbasickernel jupyterlab
|
|
63
|
+
gwbasickernel-install --sys-prefix
|
|
64
|
+
jupyter lab
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Choose **GW-BASIC 2026** when creating a notebook. The kernel looks for the
|
|
68
|
+
interpreter in `GWBASIC`, then on `PATH`. To save its location in the kernelspec,
|
|
69
|
+
use this command instead of the installer command above:
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
gwbasickernel-install --sys-prefix --gwbasic /absolute/path/to/gwbasic
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
If Jupyter runs in another Python environment, use `--user` to register the
|
|
76
|
+
kernel for your account. Keep the kernel's Python environment in place:
|
|
77
|
+
the kernelspec records its Python executable.
|
|
78
|
+
|
|
79
|
+
## Try it
|
|
80
|
+
|
|
81
|
+
```basic
|
|
82
|
+
10 FOR N=1 TO 8
|
|
83
|
+
20 PRINT N;N*N
|
|
84
|
+
30 NEXT N
|
|
85
|
+
RUN
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
For graphics, clear the previous program first:
|
|
89
|
+
|
|
90
|
+
```basic
|
|
91
|
+
NEW
|
|
92
|
+
10 SCREEN 1
|
|
93
|
+
20 CIRCLE (160,100),80,2
|
|
94
|
+
RUN
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
See the [executed graphics notebook](https://github.com/evvaletov/gw-basic-2026/blob/main/examples/graphics.ipynb)
|
|
98
|
+
and [demo guide](https://github.com/evvaletov/gw-basic-2026/blob/main/docs/demos.md).
|
|
99
|
+
|
|
100
|
+
`%new` clears the BASIC program; `%reset` starts a fresh interpreter process.
|
|
101
|
+
`%timeout 60` changes the output wait timeout from its default of 30 seconds.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
gwbasickernel/README.md,sha256=UxKKuCooAfd_9mC9ZIvUGLOtNtKXaWN_diTv3YwWS9Q,2352
|
|
2
|
+
gwbasickernel/__init__.py,sha256=V0pJTh8MU9tJ7d0ZKK0VD3P36ZwG2IUduwhRXYGBScw,63
|
|
3
|
+
gwbasickernel/__main__.py,sha256=ZRyXUypbCWf-wDm7fuZlFKzRbFxwHNB6F0Nuj4_9Ykc,135
|
|
4
|
+
gwbasickernel/basic_lexer.py,sha256=ealwhWBtOMap0N0FrbddSyeRE1Dac-iyKcNv44DeHCc,2886
|
|
5
|
+
gwbasickernel/install.py,sha256=KO9lclfYmoK4NCtc6s9aLB3IhtcwCIQLxXGiyIr2-o0,2111
|
|
6
|
+
gwbasickernel/kernel.json,sha256=zTKt3s2oGWK0SEyj75O20pWTwDUzQMhs7gpIG51afvk,176
|
|
7
|
+
gwbasickernel/kernel.py,sha256=xI7KCg_8l6XqwX_FNPp-TL9uocw4jUPTgRleNAqTxXA,20644
|
|
8
|
+
gwbasickernel/test_kernel.py,sha256=5cmIHPkavIbN9VIPKB74EpnJM84uLWzev1SeoxZnK6c,4778
|
|
9
|
+
gwbasickernel-0.1.0.dist-info/licenses/LICENSE,sha256=-mIPUvK6wSxeUkO82LHW_GbhCgP23AFZPoTJyS8AMY0,1071
|
|
10
|
+
gwbasickernel-0.1.0.dist-info/METADATA,sha256=RTI5PmUHtG1Xj_goD8PXrIzLlAumyF8loyaWD89Gvc8,3090
|
|
11
|
+
gwbasickernel-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
12
|
+
gwbasickernel-0.1.0.dist-info/entry_points.txt,sha256=u52KcGl979bG_JYZgBMIio8Y5NhVai_KB3HNY6vA7iE,137
|
|
13
|
+
gwbasickernel-0.1.0.dist-info/top_level.txt,sha256=Zew3LBNrkia0PskUaC9Hvz1LeCp_Acoypcr21v9MoTs,14
|
|
14
|
+
gwbasickernel-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Eremey Valetov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
gwbasickernel
|