Using Pebble from Python
The Front Door
Pebble is a machine with lots of parts inside: a lexer, a parser, an analyzer, a type checker, a compiler and a virtual machine. Each one has its own page in these docs, and it's fun to open the case and see how they fit together.
But when you just want to run a Pebble program from your own Python code, you don't want to wire all those parts up yourself. You want a front door with a big "go" button.
That's what Pebble's public API is: four functions you can import
straight from pebble.
| Function | What it does |
|---|---|
run_source |
Run a program and hand back everything it printed |
compile_source |
Check a program once, then run it as often as you like |
check |
Is this program well-formed? |
format_source |
Tidy a program into Pebble's one true style |
Every example on this page really runs: the test suite executes each one,
and each checks its own result with assert.
Running a Program
run_source takes Pebble code as a string and gives back whatever the
program printed, as a string:
from pebble import run_source
output = run_source('print("Hello from Pebble!")')
assert output == "Hello from Pebble!\n"
Notice the \n on the end: print finishes every line with a newline,
just like Python's does.
Watching It Print
Sometimes you want to see the output as it happens, not just get it
back at the end. Pass any writable text stream as output and Pebble
copies everything there as it prints. sys.stdout shows it on screen;
here we use a StringIO so the example can check what arrived:
import io
from pebble import run_source
screen = io.StringIO()
result = run_source("for i in range(3) {\n print(i)\n}", output=screen)
assert screen.getvalue() == "0\n1\n2\n"
assert result == screen.getvalue()
When Something Goes Wrong
If the program has a mistake in it, Pebble raises a PebbleError. Every
kind of Pebble mistake -- a typo the parser can't read, a type that
doesn't match, a crash while running -- is a kind of PebbleError, so one
except catches them all:
from pebble import PebbleError, run_source
try:
run_source('let age: Int = "twelve"')
except PebbleError as error:
message = str(error)
else:
message = "no error?!"
assert "expected Int" in message
The error knows where the mistake is, too: error.line and
error.column point at it.
Checking and Tidying
You don't have to run a program to learn something about it. check
asks "is this well-formed?" and stays quiet if it is. format_source
gives back the program tidied into Pebble's one canonical style -- the
same thing pebble fmt does:
from pebble import PebbleError, check, format_source
check("let x = 1") # fine: no error
try:
check("let = 1")
except PebbleError:
looked_wrong = True
assert looked_wrong
assert format_source("let x=1") == "let x = 1\n"
Compile Once, Run Many Times
run_source does two jobs every time: it checks and compiles the
program, then runs it. If you'll run the same program again and again,
compile_source lets you do the first job once and keep the result:
from pebble import compile_source
program = compile_source('print("tick")')
assert program.run() == "tick\n"
assert program.run() == "tick\n"
Mistakes are caught by compile_source, before anything runs.
Giving Pebble Your Own Module
Here's the most powerful trick. Pebble programs can import modules like
math. You can write a brand-new module in Python and hand it to
Pebble for one run.
A module is a StdlibModule: a dictionary of functions (each with how
many arguments it takes) and a dictionary of constants. Each function
receives its arguments as a list of Pebble values and returns a Pebble
value:
from pebble import StdlibModule, Value, run_source
def hello(args: list[Value]) -> Value:
return f"Hello, {args[0]}!"
greet = StdlibModule(
functions={"hello": (1, hello)},
constants={"PLANET": "Earth"},
)
program = 'import "greet"\nprint(hello("Ada"))\nprint(PLANET)'
output = run_source(program, modules={"greet": greet})
assert output == "Hello, Ada!\nEarth\n"
The module only exists for that run. Another program, running at the
same moment with different modules, can't see greet at all -- every run
gets its own toolbox. That's exactly how
PyStack gives each Pebble
program its own database.
You can't reuse a built-in name, though. Asking for a module called
math raises a ValueError instead of quietly replacing the real one:
from pebble import StdlibModule, run_source
fake_math = StdlibModule(functions={}, constants={})
try:
run_source("print(1)", modules={"math": fake_math})
except ValueError as error:
refused = "math" in str(error)
assert refused
Importing Pebble Files
import "helpers.pbl" looks for files in the current folder. To look
somewhere else, pass base_dir:
import tempfile
from pathlib import Path
from pebble import run_source
with tempfile.TemporaryDirectory() as folder:
Path(folder, "helpers.pbl").write_text("fn double(n) {\n return n * 2\n}\n")
output = run_source('import "helpers.pbl"\nprint(double(21))', base_dir=Path(folder))
assert output == "42\n"
What's Public?
Only the names you can import straight from pebble are promised:
run_source, compile_source, check, format_source, Program,
StdlibModule, Value and PebbleError. If a new version of Pebble
changes one of those, its version number says so (the middle number goes
up, like 0.2.0 to 0.3.0).
Everything else -- pebble.lexer, pebble.vm and friends -- is still
there for you to explore and learn from, but it may change between
versions without warning.
How It Works
Behind the front door, run_source runs the same pipeline the rest of
these docs describe, one stage after another:
- The lexer chops the text into tokens.
- The parser builds a tree (the AST).
- The analyzer checks names and scopes, and the
module system pulls in anything you
import. - The type checker checks that the types line up.
- The compiler turns the tree into bytecode, and the optimizer tidies it.
- The virtual machine runs the bytecode.
The front door just presses all six buttons for you, in order.
See also
- PyStack -- see Pebble programs running with the whole stack of PyWeb, PyDB, PyMQ and friends plugged in.