Six days ago I posted Your Python code is already assembly. I made it runnable.
It ended with a question. One word in the comments, I said. ship or stupid.
Comments: 0.
Two reactions. I'm fairly sure one of them was a bot and the other one was my own thumb.
So I made an executive decision.
$ pip install pyasm-lang
$ pyasm main.pya
Hello, World!
PyAsm v0.0.1 is out. Public repo, tagged release, CI on Linux, macOS and Windows.
👉 github.com/Natuworkguy/PyAsm
Wait, what is it
Thirty second version, for anyone who missed the first post.
This is a file. It's called main.pya. It is a complete, working program:
LOAD_GLOBAL 1 (print + NULL)
LOAD_CONST 0 ('Hello, World!')
CALL 1
POP_TOP
RETURN_CONST (None)
That is not pseudocode. That is the exact text dis.dis() prints, saved to a file, and run.
dis output has always been a receipt. Something Python hands you on the way out. PyAsm makes it an input: it parses that text and translates every instruction into ordinary Python that walks the same value stack the interpreter would, so LOAD_CONST becomes _st.append(...) and POP_TOP becomes _st.pop(). There's a --dump-python flag that shows you the whole trick, and the file it writes has no PyAsm import in it at all.
In fact, that file has no imports. None from the standard library. No dependencies from PyPI. Nothing.
That post explains the how. This one is about what happened when I actually let it out of my laptop.
Also: sorry for the silence
Six days without a post. I wasn't procrastinating, I was doing the boring part: writing a README, tagging a release, standing up a CI matrix of three operating systems and three Python versions, and designing a big new project (coming soon).
Which is where it got interesting, because shipping it found three bugs that my machine was structurally incapable of finding. All three had been sitting in the code the entire time I was writing that first post, quietly working fine on exactly one computer.
Here they are, worst first.
Bug 1: Windows ate my docstring
Every file PyAsm generates opens with a docstring saying where it came from:
"""Generated by PyAsm from main.pya.
This file is a faithful translation of the assembly,
not idiomatic Python: it walks the same value stack the
interpreter would.
"""
Harmless. Nice, even. Then the Windows job came back and twelve tests were red at once:
main.pya: error: internal error: generated invalid Python
((unicode error) 'unicodeescape' codec can't decode bytes in
position 26-27: truncated \UXXXXXXXX escape)
Look at where the tests put their scratch files:
C:\Users\runneradmin\AppData\Local\Temp\tmp8f2a\hello.pya
Look at \U.
I pasted a path into a Python string literal, and \U is the start of an eight digit unicode escape. C:\Users isn't a path once it's inside quotes. It's a syntax error with a trench coat on.
The fix is three lines:
def _docstring_text(text: str) -> str:
"""Make *text* safe to paste inside a triple-quoted docstring."""
return text.replace("\\", "\\\\").replace('"""', '\\"\\"\\"')
Here's the part that actually rattled me. My program's entire job is generating Python source, which means every value I interpolate into it is a value being injected into a language. I'd written a careful literal-escaping path for user constants and then hand-rolled f'"""Generated by PyAsm from {name}.' for the header without a second thought, because on macOS and Linux a path is just letters and slashes.
Every backslash-free day was a day the bug looked like working code.
Bug 2: Python 3.13 popped the iterator twice
Next up, three round-trip tests exploding on 3.13 only, inside the generated module:
IndexError: pop from empty list
Every one of the three had a for loop in it. Here's the tail of a loop, disassembled on 3.13:
L3: 74 END_FOR
76 POP_TOP
78 RETURN_CONST 5 (None)
Now the same thing across three releases of CPython:
| Version | How the loop cleans up its exhausted iterator |
|---|---|
| 3.12 |
END_FOR pops it |
| 3.13 |
END_FOR does nothing, the POP_TOP after it pops |
| 3.14 |
END_FOR does nothing, the POP_ITER after it pops |
My code asked exactly one question: is POP_ITER anywhere in this program? If yes, 3.14, so END_FOR is a no-op. If no, pop.
Which is right on 3.12, right on 3.14, and confidently wrong on 3.13, where END_FOR popped the iterator and then the POP_TOP right behind it reached into an empty stack.
The fix stops guessing the interpreter version and just looks at what's actually next to the instruction:
def _end_for_cleans_up(program: Program) -> bool:
"""Whether ``END_FOR`` has to pop the exhausted iterator itself."""
for index, instruction in enumerate(program.instructions):
if instruction.opname != "END_FOR":
continue
after = program.instructions[index + 1:index + 2]
if after and after[0].opname in ("POP_TOP", "POP_ITER"):
return False
return True
The lesson I'm taking: when CPython changes bytecode, it doesn't only rename opcodes. It moves stack effects between them. An opcode table that only knows names will pass every test you write on the version you happen to be running.
Bug 3: 3.11 doesn't have inlined comprehensions
Last one, and this one isn't a bug in my code so much as physics.
On 3.11, this round-trip test died:
error: cannot assemble the constant
<code object <listcomp> at 0x106a16bf0, file "<snippet>", line 2>
hint: constants must be literals; code objects, and the functions
and classes built from them, are out of scope
The snippet was one line:
squares = [n * n for n in range(6) if n % 2]
PEP 709 inlined comprehensions in 3.12. Before that, a list comprehension compiled to its own separate code object, and a separate code object shows up in dis output as <code object <listcomp> at 0x7f...>.
You cannot rebuild an object from its repr. The address isn't the object, it's a rumour about where an object used to be. So on 3.11 that comprehension is genuinely unassemblable, and the honest fix is a skip that says so out loud:
if name in INLINED_FROM_312 and sys.version_info < (3, 12):
self.skipTest(
"before 3.12 a comprehension compiles to a nested "
"code object, which is out of scope (PEP 709)"
)
Same comprehension, on 3.12 and up, round-trips perfectly. A language feature quietly changed shape underneath me and my test suite was the thing that noticed.
What actually shipped
-
152 opcodes, covering 3.11 through 3.14 spellings, both calling conventions,
LOAD_SMALL_INT,TO_BOOL,POP_ITER, the lot - Zero dependencies. Nothing. Not one.
- 79 tests, including full Python -> assembly -> Python round trips that assert the output matches plain Python character for character
- CI on Linux, macOS and Windows, three Python versions each, which is the only reason this post has three war stories in it
- A real CLI:
run,dump,check,dis,opcodes - Errors that point at your assembly, with a caret, even when the failure happens deep inside a runtime helper
One naming quirk to save you a confused minute: it installs as pyasm-lang and imports as pyasm. The short name was already taken on PyPI by an unrelated project from years ago, so the distribution got the suffix and the package kept the name you'd expect.
>>> from pyasm import run
>>> _ = run("LOAD_NAME (print)\nPUSH_NULL\nLOAD_CONST ('hi')\nCALL 1")
hi
Yes, there's a Python API too. assemble, run, disassemble_source, and the .pya text goes in as a plain string.
Try it in 60 seconds
$ pip install pyasm-lang
Write a program by hand, with labels instead of byte offsets:
LOAD_CONST 3
STORE_NAME n
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
JUMP_BACKWARD loop
done: LOAD_NAME print
PUSH_NULL
LOAD_CONST ('Liftoff!')
CALL 1
POP_TOP
RETURN_CONST (None)
Or take the shortcut and let Python write it for you:
$ pyasm dis fizzbuzz.py -o fizzbuzz.pya
$ pyasm fizzbuzz.pya
1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
...
And when you want to see how the sausage is made:
$ pyasm dump fizzbuzz.pya -o generated.py
$ python generated.py # no pyasm import anywhere in it
Still can't do it
Unchanged from last time, and for the same reason both times: text disassembly doesn't carry everything a code object has.
try/except is driven by a code object's exception table, and dis doesn't print one you can reconstruct. raise works fine.
def, class, lambda compile to separate code objects, which is bug 3 above wearing a different hat. You can import and call any Python function you like, you just can't define one inside a .pya file.
Both of these are on the table if enough people want them. Both are real work.
The ask, redesigned
Last time I asked for one word and got zero words, which is a completely fair review of my ask. So here's a better one.
Write me the most cursed valid .pya file you can, and put it in the comments.
That's it. Anything that assembles. Hand-written assembly that shells out. A quine. FizzBuzz with no %. Something that abuses the NULL slot in a way I didn't anticipate and files a bug for me. I will run every single one of them, and I'll reply to every single one.
Star it if you want to see where it goes: github.com/Natuworkguy/PyAsm
Issues are open too, and honestly, "this crashed" is my favourite kind of issue right now. Three of them just made the thing meaningfully better and they all came from a computer I don't own.
What's the first thing you'd break? 👇

Top comments (0)