Add a new dialect unpythonic.dialects.befunge that compiles Befunge-93
source into a thin Python shim that invokes a runtime interpreter. Lands
as the second pedagogic example of mcpyrate's transform_source hook,
complementing unpythonic.dialects.bf.
bf already covers the basic territory: an exotic 1-D source language
gets rewritten into legible, structured Python. Loops in brainfuck are
lexically nested, the language is essentially linear, and bf_compile
output reads as Python the way a textbook bf-to-Python translation would.
Befunge-93 deliberately doesn't fit that mould:
- 2-D playfield with the IP moving in four directions. There is no
syntactic loop structure for
transform_sourceto lower to awhile. - Self-modifying code via
p(put) andg(get). At "compile time" no static analysis can tell what a cell means: the same cell may be entered going east as a digit and going north as a string-mode quote, and may be overwritten mid-run. ?random direction, string mode ("),#skip-next, and the toroidal grid all conspire so that control flow is fundamentally IP-driven, not lexical.
So compile cannot produce Python that mirrors the program. The honest
compilation strategy is: emit a one-line shim that hands the playfield
text to a runtime interpreter shipped in this module.
That makes Befunge a complementary example, not a redundant one.
Where bf demonstrates transform_source as a transpiler — exotic
syntax in, structured Python out — Befunge demonstrates transform_source
as a reader for non-Python-flavored, non-line-oriented source: the
playfield is data, the interpreter does the work, and the dialect's job
is to wrap the file body in a single function call. The contrast is
the teaching value.
The module docstring states this contrast explicitly. We do not pretend
the compiled output is informative the way bf's is.
unpythonic/dialects/befunge.py # Playfield, run, Befunge dialect class
unpythonic/dialects/tests/test_befunge.py # runtests() per existing convention
Same shape as bf, mirroring the existing dialect-examples convention
(one module per dialect, tests in dialects/tests/).
from unpythonic.dialects.befunge import dialects, Befunge # dialect activation
from unpythonic.dialects.befunge import run # programmatic entry point
from unpythonic.dialects.befunge import Playfield # exposed for testability
from unpythonic.dialects.befunge import UnknownOpcodeError # raised on unknown opcode__all__ = ["Befunge", "UnknownOpcodeError", "Playfield", "run"].
run(src, *, seed=None) is the only public entry point with kwargs,
and seed is the single irreducible kwarg — see "I/O capture" below for
why stdin and stdout are not kwargs.
Before the Befunge work lands, do a tiny standalone commit on the bf
dialect:
bf_compile→compileinunpythonic/dialects/bf.py.- Update
__all__, the dialect class'stransform_source, the module docstring, and the test imports inunpythonic/dialects/tests/test_bf.py.
Rationale:
unpythonic.dialects.bf.compile(src)reads better thanbf_compile; thebf_prefix duplicates the module name.mcpyrate.compiler.compilesets the precedent — fleet consistency.bf.pydoesn't itself callbuiltins.compileanywhere, so shadowing inside the module is harmless. The module docstring will recommend qualified access viafrom unpythonic.dialects import bffollowed bybf.compile(src), matching the project-widefrom … import …import style, rather thanfrom … import compile(which would shadow the builtin in the importer's namespace).bf_compileis only public in 2.2.0-dev (not yet released), so the rename costs nothing in compatibility terms.
After the rename: bf exports compile and befunge exports run.
Symmetric in style: the public function in each dialect module is named
for what that dialect's pedagogic entry point actually does — bf
compiles to Python; befunge runs an interpreter.
Befunge overrides transform_source and leaves transform_ast at its
default (returns NotImplemented). Same as bf — the whole point of
this and the bf example is to demonstrate transform_source.
Canonical behavior, not a relaxed superset:
- 80×25 grid, fixed.
- Byte-valued cells (0–255), wrapping on
p(put masksvalue & 0xFF). - Stack of unbounded Python
ints. The 93 spec is fuzzy on stack width; Pythonintis the natural idiom and matches how integers behave elsewhere inunpythonic. Cells stay byte-valued — that part is non-negotiable. - IP wraparound is toroidal. Off the right edge → column 0 of the same row, etc. Canonical.
- Stack underflow on
popreturns 0. Canonical.
Both g (read) and p (write) raise IndexError if the requested
(x, y) falls outside the 80×25 grid.
Rationale:
- Strict-93 says the grid is 80×25; there is no "outside" to read zeros from. The "OOB read returns 0" convention is from Befunge-98, which we are explicitly not implementing.
- Symmetric policy is easier to document and reason about: same boundary, same reaction.
- Surfaces bugs in computed-coordinate arithmetic (off-by-one in stack juggling) instead of silently returning 0.
- The stack-underflow=0 precedent doesn't push toward returning 0 here: stack underflow is in the 93 spec; OOB grid access is unspecified.
The IP itself never goes OOB — the toroidal wraparound is a separate
concern and applies only to IP movement, not to g/p.
run(src, *, seed=None). Internally:
import random
rng = random.Random(seed)
# `?` does: dx, dy = rng.choice([(1,0), (-1,0), (0,1), (0,-1)])random.Random(seed) as an instance — does not touch the global RNG,
so a Befunge program can't perturb the user's process-wide random state.
seed=None gives normal nondeterminism (OS entropy via Random()'s
default behavior).
seed is a kwarg because there is no stdlib mechanism to "redirect
random.Random() instances at a distance" — the seed has to be
injected at the call site.
In Befunge the four I/O commands have distinct integer-vs-character flavors:
.pops and prints an integer (followed by a space, per spec).,pops and prints a character (chr(value & 0xFF)).&reads a whitespace-delimited integer from stdin and pushes it.~reads one character from stdin and pushes itsord.
EOF on & and ~ pushes 0. Matches bf's EOF=0 convention.
The module docstring calls out that . and , swap roles relative to
bf — readers comparing the two examples could otherwise be tripped up.
run does not take stdin or stdout kwargs. Tests capture I/O
through stdlib mechanisms (or, for stdin, the unpythonic gap-filler —
see next subsection):
- stdout:
redirect_stdout(io.StringIO())(fromcontextlib) around the call. - stdin:
redirect_stdin(io.StringIO("..."))(fromunpythonic) around the call.
Rationale: seed is irreducible (no stdlib equivalent), but
stdin/stdout redirection is a solved problem at the stream level.
Adding kwargs solely for I/O capture would expand the API surface
without giving the caller anything they can't already do with a
context manager. bf already follows this approach; befunge matches.
contextlib ships redirect_stdout (3.4) and redirect_stderr (3.5),
but not redirect_stdin. This is the textbook "stdlib almost gets it
right, then punts" pattern that unpythonic's gap-filling charter targets.
tests/test_bf.py already has a local _redirect_stdin helper for
this; the Befunge tests are about to need the same, and an obvious
recurring use case elsewhere puts this past the bar for promotion.
Add it to unpythonic.misc, alongside the other small stdlib
gap-fillers (maybe_open, UnionFilter, si_prefix, timer,
safeissubclass):
Subclass contextlib._RedirectStream directly. Yes, the underscore
makes it private API — but _RedirectStream was extracted in 3.5
specifically so that redirect_stdout and redirect_stderr could
share machinery, and its shape has been stable across every release
since. unpythonic's floor is 3.10, so we're well downstream of any
shake-out. We're explicitly the third sibling; using the same
machinery is the most honest expression of that.
from contextlib import _RedirectStream
class redirect_stdin(_RedirectStream):
"""Context manager that redirects ``sys.stdin`` to *target*.
The third sibling: `contextlib` ships `redirect_stdout` (3.4) and
`redirect_stderr` (3.5), but punted on `redirect_stdin`. This
fills the gap, sharing `contextlib._RedirectStream` machinery so
behavior matches its stdlib siblings exactly — including the
per-instance stack that supports nested re-entry on the same
instance.
Like its stdlib siblings, this redirects the global ``sys.stdin``
and is **not** safe under concurrent use from multiple threads —
parallel redirects from different threads will stomp on each other.
For tests (the primary use case), single-threaded use is the norm.
"""
_stream = "stdin"Thread safety note: matches stdlib's choice deliberately. A truly
thread-aware variant would need sys.stdin replaced by a proxy that
dispatches per-thread (similar in spirit to unpythonic.dynassign.dyn
but at the file-like-object level), and that's a different abstraction
worth its own design discussion — not a refinement of this gap-filler.
Same commit:
- Add to
unpythonic/misc.py; update__all__. - Re-export from top-level
unpythonic/__init__.py(already happens viafrom .misc import *). - Add
redirect_stdinunit tests inunpythonic/tests/test_misc.py(basic redirect, exception path restoressys.stdin, nested redirects on the same instance unwind correctly). - Add a documentation entry under the Other section of
doc/features.md, alongsidemaybe_openandenviron_override— fellow stdlib gap-fillers in the same file/stream/process-state category. Both the navigation TOC link near the top of the file and the per-feature subsection later in the file. - Replace the local
_redirect_stdinhelper inunpythonic/dialects/tests/test_bf.pywith the public function. - CHANGELOG entry under 2.2.0 "Added".
Befunge-93 has no comment syntax; # is a real command (skip-next-cell).
Trying to recognize Python comments inside the body would necessarily
involve guessing — any rule like "lines starting with # are Python
comments" is a heuristic that picks a convention rather than a clean
parse. We don't do this.
The unambiguous answer is: commentary goes above the dialect-import
line. split_at_dialectimport preserves the prologue verbatim as
Python text, so anything before the dialect-import is plain Python.
The recommended form is a module docstring, not stand-alone #
comments. A docstring shows up in help(module), so a Befunge program
imported as a Python module documents itself the same way any other
Python module does:
"""Hello from Befunge!
Demonstrates string-mode push, the ":#,_@" print loop, and the v/^
vertical IP-redirect cells.
"""
from unpythonic.dialects.befunge import dialects, Befunge
<the actual Hello-from-Befunge program — to be hand-written and
verified during implementation># noqa / Python-tooling directives go in the prologue too, as
ordinary Python comments — same boundary, same machinery.
The canonical "how to use" example in this module's docstring will be the docstring-headed form; bare-comment files still work but are the less idiomatic choice.
Nothing supports comments inside the body or after the program —
Befunge has no end-of-program textual marker (@ is a runtime
halt), so trailing comments are equally ambiguous.
Following bf's shape, with split_at_dialectimport:
class Befunge(Dialect):
def transform_source(self, text):
r = split_at_dialectimport(text, type(self).__name__, self.lineno)
if r is None:
return text
prologue, other, body = r
body = _strip_leading_blank_lines(body)
shim = (
"from unpythonic.dialects.befunge import run\n"
f"run({body!r})\n"
)
return prologue + "".join(other) + shim
def _strip_leading_blank_lines(text: str) -> str:
lines = text.splitlines(keepends=True)
while lines and not lines[0].strip():
lines.pop(0)
return "".join(lines)Leading blank lines must be stripped. A typical dialect-activated file looks like:
"""Hello, World."""
from unpythonic.dialects.befunge import dialects, Befunge
>25*"!dlrow ,olleH":v
...split_at_dialectimport returns body starting at the line after the
dialect-import — including the blank line that separates it from the
program. If we don't strip, that blank line becomes row 0 of the
playfield (all spaces); the IP starts at (0, 0) going east, walks
80 no-op cells, wraps toroidally back to (0, 0), and loops forever.
The strip is line-level: lines whose stripped form is empty get
removed from the start of body. Leading spaces inside a non-blank
line are preserved — those are meaningful no-op cells in the
playfield and column alignment matters. Trailing blank lines are not
stripped (harmless; Playfield(src) pads to 25 rows anyway).
The playfield text is then embedded as a string literal via repr(),
which handles escaping and preserves every remaining character verbatim,
including in-line whitespace.
class Playfield:
"""Strict Befunge-93 playfield: 80×25 byte cells.
Reads and writes outside the grid raise `IndexError`. Used by the
interpreter, and exposed publicly so that grid layout, padding,
truncation, and OOB policy can be unit-tested in isolation from
the interpreter loop.
"""
WIDTH = 80
HEIGHT = 25
def __init__(self, src: str = ""): ...
def __getitem__(self, xy: tuple[int, int]) -> int: ...
def __setitem__(self, xy: tuple[int, int], value: int) -> None: ...The constructor parses src:
- Split on newlines.
- Strip leading and trailing entirely-blank lines (in-line leading spaces on a non-blank line are preserved — those are no-op cells).
- If the remaining line count exceeds 25, raise
SyntaxError("befunge: program exceeds 25-row grid (got N rows)"). - If any line exceeds 80 characters, raise
SyntaxError("befunge: line K exceeds 80-column grid (got M cols)"). - Otherwise: pad with blank rows up to 25, right-pad each line with spaces to 80.
- Cells store
ord(ch) & 0xFF(academic for ASCII source, keeps the contract uniform with__setitem__).
Why SyntaxError, not silent truncation. Strict Befunge-93's
grid is fixed at 80×25; an oversize program is not Befunge-93.
Silent truncation could drop the @ halt and turn a finite program
into an infinite loop — the worst possible failure mode (a program
that compiles, runs, and never returns). Failing loudly at compile/
load time is symmetric with the runtime IndexError policy for
out-of-grid g/p: out-of-grid is an error, period.
The interpreter loop. Pseudo-code:
def run(src: str, *, seed: int | None = None) -> None:
import sys
rng = random.Random(seed)
pf = Playfield(src)
stack: list[int] = []
def push(v): stack.append(v)
def pop(): return stack.pop() if stack else 0
x, y = 0, 0
dx, dy = 1, 0
string_mode = False
while True:
cell = pf[(x, y)]
ch = chr(cell)
if string_mode:
if ch == '"':
string_mode = False
else:
push(cell)
else:
# dispatch on ch: digits, + - * / % ! `, < > ^ v ?, _ |, " :, \\, $, ., ,, &, ~, #, p, g, @, space
...
x = (x + dx) % Playfield.WIDTH
y = (y + dy) % Playfield.HEIGHT
if ch == '@': # halt — handled inside dispatch by `return`
break(Halt actually short-circuits inside the dispatch with return; the
sketch is illustrative.)
Operator coverage — the full Befunge-93 set:
| Char | Meaning |
|---|---|
0–9 |
push digit |
+ - * / % |
arithmetic; / and % by zero push 0 (per spec) |
! |
logical not |
` |
greater-than |
> < ^ v |
set IP direction |
? |
random direction (uses rng) |
_ |
horizontal if: pop; if 0 go right, else left |
| |
vertical if: pop; if 0 go down, else up |
" |
toggle string mode |
: |
duplicate top |
\\ |
swap top two |
$ |
discard top |
. |
pop, print int + space |
, |
pop, print char (chr(value & 0xFF)) |
& |
read int, push |
~ |
read char, push ord |
# |
trampoline: skip next cell |
p |
put: pop y, x, v; pf[(x, y)] = v |
g |
get: pop y, x; push pf[(x, y)] |
@ |
halt |
| (space) | no-op |
Anything else: raise UnknownOpcodeError(f"unknown command at ({x}, {y}): {ch!r}").
Strict mode — Befunge-93 has a fixed command set, and silently treating
unknowns as no-ops would hide source-corruption bugs.
UnknownOpcodeError is a custom exception subclassing RuntimeError. The
case for inventing a type rather than reusing a stdlib one: this is a
VM hitting an unknown opcode, which doesn't fit any stdlib category
cleanly — SyntaxError is reserved by convention for parse-time use,
RuntimeError is too generic, ValueError doesn't quite match
("inappropriate value" is a stretch for "byte at this cell isn't a
command"). The pattern is "stdlib has no clean fit for the domain
concept, so define a domain exception": pickle.UnpicklingError and
struct.error follow the same principle.
Subclassing RuntimeError keeps blanket runtime-error catchers
working; the specific class enables targeted except UnknownOpcodeError.
This error fires at runtime, not at Playfield(src) load time. A
Befunge source can be entirely valid (every cell is printable ASCII)
yet contain a cell that's never a recognized command. Self-modifying
code (p) can also write arbitrary bytes into cells. Detection
necessarily happens when the IP actually visits the cell as an
instruction.
Three distinct error categories, three distinct conditions:
SyntaxError— source-level malformation atPlayfield(src)construction (oversize grid). Pre-execution.IndexError— out-of-grid runtime access viag/p. Sequence-subscript convention.UnknownOpcodeError— in-grid byte at the IP isn't a recognized command. Runtime, custom domain exception.
Each picks the most precise type available; we only invent where stdlib has no clean answer.
unpythonic/dialects/tests/test_befunge.py, with the usual runtests()
entry point. Coverage:
-
Playfieldunit tests:- 80×25 dimensions; default-blank cells read as
ord(' '). - Source shorter than 25 lines pads with blank rows.
- Source with >25 lines raises
SyntaxError. - Lines shorter than 80 cols right-pad with spaces.
- Lines longer than 80 cols raise
SyntaxError. - Trailing blank lines are stripped before the dimension check (so a 25-line program with a trailing blank line still loads).
- OOB read raises
IndexError. - OOB write raises
IndexError. - In-bounds write masks to byte (
pf[(0, 0)] = 0x1FF; pf[(0, 0)] == 0xFF).
- 80×25 dimensions; default-blank cells read as
-
Interpreter unit tests via direct
runcalls with captured stdout:- Arithmetic:
9 5 - .style. - String mode:
"!dlroW">:#,_@-style print loop. - Stack ops:
:,\\,$. #trampoline._and|conditional direction.?with seeded rng — assert deterministic output for fixed seed.p/ground-trip on in-bounds coordinates.g/praiseIndexErrorfor OOB coordinates.- Toroidal IP wrap (program at
x = 79moving east lands atx = 0). - Empty stack underflow returns 0.
@halts.- Unknown command raises
UnknownOpcodeError.
- Arithmetic:
-
Hello from Befunge!: a custom Hello World matching the Lispython / Pytkell / bf family tradition. Rewards the curious reader of CI logs, and exercises the full machinery — string mode, the:#,_@print loop, vertical IP-redirect cells, halt. -
Dialect activation test: a minimal Befunge-in-
.pysnippet loaded through the dialect machinery (mcpyrate.compiler.create_modulerun, same idiom astest_bf.py) actually executes and produces the expected stdout. Cover the realistic case with a blank line between the dialect-import and the program — verifies the leading-blank-line strip; without it the program would loop forever on a blank row 0.
-
&/~smoke test usingredirect_stdin(public, fromunpythonic, added in commit 1); EOF returns 0.
- Befunge-98 features: unbounded grid, multiple IPs, stack-of-stacks,
fingerprints,
k(iterate), etc. Strict-93 only. - Optimization. The interpreter is a clean dispatch loop. No JIT, no
basic-block caching, no peephole optimizations, no static analysis
of common Befunge idioms. (And in any case, full Befunge static
analysis is defeated by
p— self-modifying code can change a cell between any two visits, so a pre-execution analysis can never be sound for general programs.) Pedagogic transparency trumps cleverness, same call asbf. - Compile-time tracing or partial evaluation. Befunge-93 is Turing
complete; symbolic execution at compile time would over-promise and
under-deliver, and would conflate compilation with interpretation
in a way
bfdeliberately avoided. - A
befunge_compile(orcompile) function that returns a Python source string. The shim's only content isrun(src!r), which has no pedagogic value to expose as a separate API.
Conventional unpythonic.dialects module style — matter-of-fact
docstrings, commit messages, and test names, matching bf and the
other sibling dialect modules. The transform_source contrast with
bf is presented as a design observation in the module docstring.
2.2.0. Three commits:
unpythonic.misc.redirect_stdin+ tests + CHANGELOG; replace the local helper intest_bf.py.bfrename:bf_compile→compile(small, isolated).- Befunge dialect: module + tests + CHANGELOG entry.
Commits 1 and 2 are mutually independent; both must land before
commit 3. All three on master for the in-progress 2.2.0 release.
Memory's "Queued for the 2.2.0 release" list grows accordingly.