Skip to content

Python Extension

The Python extension provides two things:

  • Runtime support for fn python ... blocks.
  • Child-LSP configuration so Shape LSP can delegate hover/completion/diagnostics inside Python bodies.
Terminal window
shape ext install python

This downloads, compiles, and installs the extension to ~/.shape/extensions/. Both the CLI and LSP will automatically detect it — no further configuration needed.

If you need a development build from the workspace:

Terminal window
PYO3_PYTHON=$(which python3) cargo build -p shape-ext-python --release

This produces target/release/libshape_ext_python.so (Linux).

If you installed via shape ext install, the extension is loaded automatically. For manual loading, you can use any of the following methods.

Terminal window
shape run script.shape --extension python
---
[[extensions]]
name = "python"
---
[[extensions]]
name = "python"
autoload = true

Extensions installed via shape ext install are resolved by name from ~/.shape/extensions/. You can also specify an explicit path for development builds.

Declare a Python function with fn python:

fn python add(a: int, b: int) -> Result<int> {
return a + b
}
let result = add(1, 2)
// result: Ok(3)

Arguments are marshalled from Shape to Python, the Python body executes, and the return value is marshalled back. Both directions are type-checked against the declared signature.

Every fn python block must have an explicit return type, and that return type must be Result<T> — Python is a dynamic-error-model runtime, so the compiler refuses bare return types to guarantee every call is forced to handle the failure case.

// Dynamic errors — Python exceptions become Err(...)
fn python safe_divide(a: number, b: number) -> Result<number> {
return a / b
}

(Earlier drafts of this chapter showed a “static error model” form without Result<T>. The compiler now rejects that form with Foreign function ...: return type must be Result<T> ....)

Values crossing the Python/Shape boundary are converted according to these rules:

Shape typePython typeDirectionNotes
intintbothPython float with integer value (e.g. 3.0) coerced to int
number / floatfloatbothPython int promoted to float
stringstrboth
boolboolbothPython bool is not coerced to int
noneNoneboth
Vec<T>list[T]bothEach element validated recursively against T
{f: T, ...}dictbothDeclared fields validated; produces a TypedObject on return

Coercion rules:

  • float → int: allowed only when the float has no fractional part (e.g. 3.03, but 3.5 → error)
  • int → float/number: always allowed
  • bool → int: rejected (Python bool is a subclass of int, but Shape treats them as distinct types)
  • Type mismatch → Err(MARSHAL_ERROR) in dynamic error model, or VMError in static model

When a foreign function declares an inline object return type, Shape creates a schema at compile time and validates the Python dict on return:

fn python fetch_user(id: int) -> Result<{name: string, age: int}> {
return {"name": "Alice", "age": 30}
}
let user = fetch_user(1).unwrap()
print(user) // {name: "Alice", age: 30}
print(user.name) // "Alice"
print(user.age) // 30

The returned dict must contain all declared fields with the correct types. Missing fields or type mismatches produce a MARSHAL_ERROR.

When the Python dict uses keys that aren’t valid Shape identifiers (spaces, hyphens, etc.), use @alias to map between the wire name and the Shape field name:

type JokeResponse {
setup: string,
@alias("punch line")
punch_line: string,
}
async fn python fetch_joke(url: string) -> Result<JokeResponse> {
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
data = await response.json()
return data
}
let joke = await fetch_joke("https://example.com/joke")?
print(joke.punch_line) // works — mapped from wire key "punch line"
async fn python fetch(url: string) -> Result<{setup: string, @alias("punch line") punch_line: string}> {
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
data = await response.json()
return data
}

@alias also works for Arrow/DataTable column binding — use it anywhere the external name differs from the Shape field name. It replaces the older @column annotation.

With Result<T> return types, two error codes distinguish failure sources:

Error codeMeaning
RUNTIME_ERRORPython raised an exception
MARSHAL_ERRORReturn value didn’t match declared type T
fn python bad_return(x: int) -> Result<int> {
return "not an int"
}
let r = bad_return(1)
// r: Err({message: "expected int, got str", code: "MARSHAL_ERROR", ...})

Add the async keyword for I/O-bound Python:

async fn python fetch_json(url: string) -> Result<{setup: string, punchline: string}> {
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
data = await response.json()
return data
}
let values = await fetch_json("https://official-joke-api.appspot.com/random_joke")?
print(values)
// {setup: "Why did the scarecrow win an award?", punchline: "He was outstanding in his field."}

Async functions use Python’s asyncio event loop internally. The Shape VM blocks until the coroutine completes.

Shape LSP can discover extension declarations from script frontmatter and shape.toml.

If you want Python always available in the editor (even before the current file declares it), configure always-load extensions in LSP initialization/settings.

The same keys are accepted in:

  • initialize initializationOptions
  • workspace/didChangeConfiguration settings

Accepted key names:

  • alwaysLoadExtensions (camelCase)
  • always_load_extensions (snake_case)

Location:

  • top-level
  • nested under shape

alwaysLoadExtensions is an array of entries. Each entry can be:

  • string path:
{
"shape": {
"alwaysLoadExtensions": [
"/absolute/path/to/libshape_ext_python.so"
]
}
}
  • object:
{
"shape": {
"alwaysLoadExtensions": [
{
"name": "python",
"path": "/absolute/path/to/libshape_ext_python.so",
"config": {}
}
]
}
}

Notes:

  • path is required for object entries.
  • relative paths resolve against the workspace root.
  • config is optional; defaults to {}.
  • duplicate entries are de-duplicated.

After installing the extension (shape ext install python), the LSP auto-detects it from ~/.shape/extensions/. No manual path configuration needed:

return {
{
"neovim/nvim-lspconfig",
opts = function()
vim.filetype.add({ extension = { shape = "shape" } })
vim.lsp.config.shape = {
cmd = { "shape-lsp" },
filetypes = { "shape" },
root_markers = { ".git", "shape.toml" },
}
vim.lsp.enable("shape")
end,
},
}

No language runtime registered for 'python'

Section titled “No language runtime registered for 'python'”

The extension was not loaded in runtime context. Verify frontmatter/shape.toml/--extension path and rebuild if needed.

Hover works but Python semantic highlighting is missing

Section titled “Hover works but Python semantic highlighting is missing”

Shape delegates highlighting to the child Python LSP. If that server does not implement semantic-token methods, hover can work while semantic highlighting is unavailable.

Verify child server availability:

Terminal window
pyright-langserver --stdio

The current Python extension advertises pyright-langserver --stdio for child LSP startup.