DEV Community

Cover image for Your Python code is already assembly. I made it runnable.
Nathan C.
Nathan C.

Posted on AI-assisted

Your Python code is already assembly. I made it runnable.

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
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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()
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
$ pyasm examples/countdown.pya
3
2
1
Liftoff!
Enter fullscreen mode Exit fullscreen mode

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
...
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
    ^^^^^^
Enter fullscreen mode Exit fullscreen mode

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
    ^^^^^^
Enter fullscreen mode Exit fullscreen mode

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)