Here is a complete, working program:
LOAD_GLOBAL 1 (print + NULL)
LOAD_CONST 0 ('Hello, World!')
CALL 1
POP_TOP
LOAD_CONST 1 (None)
RETURN_VALUE
That is not pseudocode. That is not a diagram. That is a file, main.pya, and when I run it:
$ pyasm main.pya
Hello, World!
I call it PyAsm. It's a Python-based language that reads like assembly, because it is assembly. It's the output of dis.dis(), pasted into a file, and run.
Where this came from
Every Python developer has done this at least once:
>>> import dis
>>> dis.dis(lambda: print("hi"))
1 RESUME 0
LOAD_GLOBAL 1 (print + NULL)
LOAD_CONST 0 ('hi')
CALL 1
RETURN_VALUE
You stare at it. You go "huh, neat." And then you close the terminal, because that text is a read-only artifact. It's something Python tells you. It's a receipt.
And one day I thought: why is this a one-way street?
The interpreter turned my code into this. Why can't I write this and get a program back?
So I built the other direction.
The trick: it doesn't build bytecode
Here's the part I think is actually interesting, and it's the opposite of what everyone guesses.
PyAsm does not hand-assemble a code object. No types.CodeType, no packing opargs into bytes, no exception tables, no CPython-version-specific adaptive instruction layout. That road is a nightmare and it breaks every single release.
Instead, PyAsm translates. Every program becomes an ordinary Python module that walks the same value stack the interpreter would.
And you can see it, because there's a --dump-python flag:
$ pyasm dump main.pya -o hello.py
def _pyasm_main(_ns):
_st = []
# LOAD_GLOBAL 1 (print + NULL)
_st.append(_pyasm_load_name(_ns, 'print'))
_st.append(NULL)
# LOAD_CONST 0 ('Hello, World!')
_st.append('Hello, World!')
# CALL 1
_st.append(_pyasm_call(_st, 1))
# POP_TOP
_st.pop()
# LOAD_CONST 1 (None)
_st.append(None)
# RETURN_VALUE
return _st.pop()
That's the whole magic trick. _st is the value stack. Every instruction is a couple of lines of Python that pushes or pops. The original assembly is right there as a comment on each one.
The generated file carries its own runtime, so it's completely standalone:
$ python hello.py
Hello, World!
No PyAsm import. No dependency. Just a Python file that happens to have been written by a stack machine.
But what about jumps?
Python has no goto. So how do you translate JUMP_BACKWARD?
You split the program into basic blocks and generate a dispatch loop, one case per block, with _ip as the instruction pointer:
_ip = 0
while True:
match _ip:
case 0:
# LOAD_CONST 3
_st.append(3)
# STORE_NAME (n)
_ns['n'] = _st.pop()
_ip = 1
continue
case 1:
# loop: LOAD_NAME (n)
_st.append(_pyasm_load_name(_ns, 'n'))
# TO_BOOL
_t0 = _st.pop()
_st.append(bool(_t0))
# POP_JUMP_IF_FALSE (done)
_t0 = _st.pop()
if not _t0:
_ip = 3
continue
Backward jumps, forward jumps, loops, FOR_ITER: all of it becomes _ip = n; continue. Straight-line programs skip the loop entirely and stay straight-line.
You don't have to write byte offsets
Real dis output pastes in unchanged, offsets and all. But nobody wants to hand-count byte offsets, so hand-written assembly can use labels and plain names instead:
LOAD_CONST 3
STORE_NAME n
LOAD_SMALL_INT 0
LOAD_CONST None
IMPORT_NAME time
STORE_NAME time
loop: LOAD_NAME n
TO_BOOL
POP_JUMP_IF_FALSE done
LOAD_NAME print
PUSH_NULL
LOAD_NAME n
CALL 1
POP_TOP
LOAD_NAME n
LOAD_CONST 1
BINARY_OP (-)
STORE_NAME n
LOAD_NAME time
LOAD_ATTR sleep
PUSH_NULL
LOAD_CONST 1
CALL 1
POP_TOP
JUMP_BACKWARD loop
done: LOAD_NAME print
PUSH_NULL
LOAD_CONST ('Liftoff!')
CALL 1
POP_TOP
RETURN_CONST (None)
$ pyasm examples/countdown.pya
3
2
1
Liftoff!
Yes, it imports time and actually sleeps a second between each number. Hand-written assembly, calling into the standard library, in a language whose entire syntax is "one instruction per line."
It round-trips
There's a disassembler too, so you can send real Python through and get assembly back that PyAsm accepts:
$ pyasm dis fizzbuzz.py -o fizzbuzz.pya
$ pyasm fizzbuzz.pya
1
2
Fizz
4
Buzz
...
The generated file looks exactly like what you'd expect:
1 2 LOAD_NAME 0 (range)
4 PUSH_NULL
6 LOAD_SMALL_INT 1
8 LOAD_SMALL_INT 16
10 CALL 2
18 GET_ITER
L1: 20 FOR_ITER 83 (to L5)
24 STORE_NAME 1 (n)
Python -> assembly -> Python -> identical output. There's a test suite that does exactly that with loops, comprehensions, f-strings, imports, unpacking and slicing, and asserts the output matches plain Python character for character.
Errors point at your file
This was the detail I cared most about. The generated Python is an implementation detail, so when a program blows up you should never see it. You see your assembly:
$ pyasm hi.pya
hi.pya:3: RuntimeError: stack underflow, needed 2 values
CALL 1
^^^^^^
Even when the failure happens deep inside a runtime helper, it gets attributed back to the instruction that caused it. Same for a NameError, same for a ValueError raised inside a function you called.
My favourite one:
main.pya:4: RuntimeError: NULL reached a callable or argument slot; a call
wants the callable and its NULL/self slot directly below the arguments
(callable, NULL, arg1, ..., argN)
CALL 1
^^^^^^
Because it turns out the single most confusing thing about writing Python assembly by hand is that CALL n pops n + 2 values, not n + 1. There's a NULL slot under the arguments (that's what print + NULL means in the disassembly), and if you put it in the wrong place, the sentinel silently becomes an argument and you get Hi NULL on stdout instead of Hi. Now it's a hard error with a caret.
What it can't do (being honest)
Two things, and both for the same reason: text disassembly doesn't carry everything a code object has.
Exceptions. try/except in CPython is driven by a code object's exception table, and dis doesn't print one you can reconstruct. So no try/except. raise works fine and propagates normally.
def, class, lambda. These compile to separate code objects, which dis prints as <code object f at 0x7f...>. You cannot rebuild an object from its repr. So no function definitions inside .pya, though you can import and call any Python function you like.
There's a nice consequence of the first one, though. CPython emits unreachable exception-handler tails after every list comprehension. Rather than reject those files, PyAsm proves those blocks are unreachable by ordinary control flow, replaces them with a stub, and warns. So comprehensions assemble and run correctly anyway.
The state of it
-
152 opcodes supported: 3.11 through 3.14 spellings, both calling conventions,
LOAD_SMALL_INT,TO_BOOL,POP_ITER, the lot - Zero dependencies, Python 3.11+
- 70+ tests, including full Python → assembly → Python round trips
- A real argparse CLI:
run,dump,check,dis,opcodes
So... is this a good idea, or is it deeply stupid?
Genuine question. I haven't published it yet.
I built it because "the disassembler is a one-way street" annoyed me, and somewhere along the way it turned into a working language with error diagnostics and a test suite. But I honestly cannot tell from the inside whether this is:
- a legitimately useful teaching tool for how CPython's stack machine works, or
- a beautifully engineered answer to a question nobody asked
Both feel plausible from where I'm sitting.
So tell me. One word in the comments:
👉 ship: publish it, this is cool
👉 stupid: this is a solution in search of a problem
I'll read every one. And if you think it's worth existing, tell me what for. Teaching bytecode? Debugging? Code golf? Something I haven't thought of? That's the part I actually need help with.
Follow me for updates. If this goes anywhere, the repo, the release, and the writeup of how the codegen actually works all land here first.
What's the most cursed thing you'd write in this? 👇
Top comments (0)