Lo innovador, en cinco líneas
- El coste está donde debe estar. Quien entra a leer el README no descarga ni un byte de intérprete. El módulo de Python vive en su propio chunk y solo se pide cuando alguien escribe el comando.
- El anfitrión está escrito en Python, no en plantillas de JavaScript. Eso hace que las excepciones salgan con números de línea estables y permite recortar del traceback los frames de la propia terminal.
- Es un REPL de verdad, con
codeop.CommandCompilery modosingle: por eso escribir2 + 2imprime4sinprint(), y por eso unforabierto pide más líneas en vez de reventar. - Los
.pyviajan dentro del chunk, incrustados en tiempo de build. Ejecutar un script no dispara ni una petición de red extra. - Una sola instancia, cacheada a propósito. Salir del REPL y volver a entrar reengancha el mismo intérprete, con tus variables donde las dejaste.
Qué es
La gaveta de terminal de este escritorio no es atrezo. Escribe python y se descarga un CPython 3.14 completo compilado a WebAssembly —vía Pyodide— que se queda corriendo en tu pestaña. A partir de ahí el prompt pasa a ser >>> y todo lo que teclees lo ejecuta un intérprete de Python real.
No hay una API al otro lado evaluando tu código. Si desconectas la red después de cargarlo, sigue funcionando.
Cómo funciona
1. Nada de intérprete hasta que lo pidas
El módulo que sabe de Python se importa dinámicamente, así que Vite lo separa en su propio chunk. Nada pesado se importa arriba del archivo, a propósito:
const { load } = await import('./py/run');
El bundle principal del escritorio no menciona el intérprete. El chunk pesa unos 9 KB —los scripts incluidos— y los ~6 MB del runtime salen de un CDN solo cuando hace falta. El aviso del tamaño está medido, no estimado.
2. El anfitrión vive dentro del intérprete
Podría haber compuesto cada ejecución concatenando strings de Python desde JavaScript. En vez de eso hay un pequeño programa anfitrión que se ejecuta una vez al arrancar y define cuatro funciones. Ejecutar un script es entonces una llamada, no una plantilla:
def __masami_trace(exc):
"""Imprime el traceback sin el frame de este anfitrión."""
tb = exc.__traceback__
traceback.print_exception(type(exc), exc, tb.tb_next if tb else None)
Ese tb.tb_next es el detalle que importa: quien ejecuta scoring.py y se equivoca ve su error, no el andamiaje de la terminal por encima. Y como el anfitrión es un archivo de verdad y no una cadena montada al vuelo, los números de línea de las excepciones son estables.
runpy.run_path corre el script con __name__ = "__main__", y SystemExit se atrapa aparte: argparse sale por ahí tanto con --help como con un error de uso, y que un script termine por su propio pie no es un fallo del intérprete.
3. Un REPL, no un eval()
La diferencia entre una consola y un evaluador es el modo de compilación:
code = __masami_compile(source, "<consola>", "single")
El modo single es lo que hace que las expresiones sueltas pasen por el displayhook y su repr acabe en stdout — el eco que uno espera de >>>. Y cuando CommandCompiler devuelve None significa “esto todavía no es una sentencia completa”: un for, un def, un paréntesis sin cerrar. Entonces el buffer se conserva, el prompt cambia a ... y se piden más líneas.
exit() se atiende del lado de JavaScript y no en Python, por dos razones: en Pyodide ese builtin viene de site, que no siempre está, y lo que hay que cerrar es la gaveta, no el intérprete. Solo vale fuera de un bloque abierto — dentro de un for, exit() es una línea de código más y se ejecuta como tal.
4. Los scripts viajan dentro del chunk
const SOURCES = import.meta.glob('../../../scripts/*.py', {
query: '?raw', import: 'default', eager: true,
});
eager parece contradecir lo de la carga diferida, pero no: se resuelve en tiempo de build y el resultado queda incrustado en un módulo que a su vez solo se descarga bajo demanda. Servirlos por HTTP habría sido una petición extra por ejecución para mover tres kilobytes.
Al arrancar se escriben en el sistema de archivos virtual del intérprete y se hace chdir al home. Por eso python scoring.py funciona sin ruta — y ./scoring.py, ~/scoring.py y scripts/scoring.py se normalizan a lo mismo, porque dentro todo vive plano.
5. Una sola instancia, y el fallo no se cachea
Pyodide no expone forma de destruirse, así que crear una segunda instancia sería fugar la primera con su intérprete dentro. Se cachea la promesa. Pero un fallo de red no debe dejar una promesa rechazada cacheada para siempre:
instance.catch(() => { instance = null; });
Así el segundo intento vuelve a empezar de cero en vez de repetir el mismo error para siempre.
6. El color sale del stream, no de adivinar
setStdout y setStderr se registran una vez y apuntan a un sumidero mutable. Lo que un script mande a stderr se pinta en rojo aunque no parezca un error, y lo que mande a stdout en verde aunque diga “ERROR”. El intérprete ya sabe por qué canal habla; no hay que inferirlo del texto.
Un detalle pequeño y molesto: batched entrega el búfer con su salto de línea final incluido. Sin recortarlo, cada print() dejaría una línea en blanco detrás.
Los dos scripts
No son de relleno, y ninguno es un hola mundo:
scoring.pypuntúa una ronda de 30 m —36 flechas, 360 posibles— y calcula cuánto falta para 300. Lo que mide de verdad no es la media sino la varianza: un 9 de media con desviación cero son 324; un 9 de media saltando entre 10 y 7 es una ronda que se cae sola.escalate.pymodela cuándo un agente debería dejar de intentarlo y llamar a un humano. La regla no es “poca confianza”: es(1 - confianza) × coste_error > coste_interrupción. Lo interesante es el punto de indiferencia — con un error de 500 y una interrupción de 20, el umbral cae en 0.96, así que un agente “bastante seguro” al 90% debería estar preguntando. Casi ninguno lo hace.
Los dos conectan con cosas que hay en otras ventanas de este escritorio, que era medio el punto.
Decisiones que definen el proyecto
- Un portafolio que se puede ejecutar. Contar que sabes de WebAssembly es una frase; dejar que el visitante escriba
pythony lo compruebe es otra cosa. - Nadie paga por lo que no usa. El grueso de las visitas no va a abrir la terminal, y esas visitas no descargan nada.
- La lógica en el lenguaje al que pertenece. El manejo del REPL, los tracebacks y
SystemExitson problemas de Python y se resuelven en Python. JavaScript solo orquesta.
Alternativas evaluadas y descartadas
| Idea | Por qué se descartó |
|---|---|
| Una VM Alpine i386 completa sobre CheerpX | Arrastraba xterm.js más una imagen de sistema, y lo que daba era una shell aislada — no un intérprete que pueda hablar con la página |
| Ejecutar el código en un backend | Deja de ser interesante y me convierte en el dueño de un evaluador remoto de código ajeno |
Servir los .py por HTTP | Una petición de red por ejecución para mover tres kilobytes |
| Construir cada ejecución concatenando strings | Números de línea inestables en las excepciones y tracebacks con el andamiaje dentro |
| Un simulador de Python escrito a mano | Miente en cuanto alguien prueba algo que no anticipé; esto no puede mentir, porque es CPython |