Usage Guide
This guide explains the core concepts of fluent-codegen and walks through progressively more complex examples.
Why fluent-codegen?
When you need to generate Python source code programmatically, the obvious approaches have drawbacks:
String concatenation / templates — easy to produce syntactically broken code, hard to avoid injection bugs, and painful to maintain indentation.
Raw ``ast`` module — correct by construction but extremely verbose; every node requires a half-dozen keyword arguments.
fluent-codegen sits in between: it gives you a simplified AST that maps
closely to Python constructs, with a fluent chaining API for building
expressions. You get correctness (it emits real ast nodes) without the
verbosity.
The design also tries to avoid many mistakes you could make in code generation.
As such, it is slightly opinionated – for example, it assumes that you don’t
want to accidentally shadow builtins like str and dict, so if you use
the recommended APIs for generating code you will be protected from doing so. It
also assumes that Static single-assignment form is a sensible
default for generated code i.e. you won’t normally be re-using variables.
In other words, the design aims to help you to generate code that is a sensible subset of all possible Python code, especially for the context of compiling to Python.
Core Concepts
The library is built around a small number of interacting concepts:
Module — the top-level container
Every code-generation session starts with a Module.
A Module is both a Block (a list of statements)
and contains a Scope (a namespace for names).
from fluent_codegen import codegen
module = codegen.Module()
By default the module’s scope pre-reserves all Python builtins, so you can never
accidentally shadow str, len, etc. You can also access these builtins as
Name objects using Module.scope.name().
When you’re done building, call:
module.as_python_source()— get a string of Python source code.module.as_ast()— get aast.Modulenode you cancompile()andexec().
Scope — safe name management
Scope tracks which names are in use in a given
namespace, and guarantees you never create clashing identifiers.
The two key methods are:
scope.create_name(requested)Reserve a name and return a
Nameexpression. If the requested name is already taken, a numeric suffix is appended automatically (e.g.x,x_2,x_3, …).Note that many convenience methods (such as
create_with()) allow you to pass astrobject as a name for the target. In this case, they automatically callcreate_namefor you, so the variable name may not be the one you requested, but is automatically adjusted to not clash with existing names.scope.name(existing)Return a
Namefor a name that is already reserved (raises if not). Use this to refer to function parameters or previously created names.
scope = codegen.Scope()
a = scope.create_name("x") # Name("x")
b = scope.create_name("x") # Name("x_2") — auto-deduplicated
c = scope.name("x") # Name("x") — refers to existing
Since Module,
Function, and
Class all inherit from
Scope, you typically call create_name on
those directly rather than on a bare Scope.
Block — a sequence of statements
Block is an ordered list of statements. A
Module’s body, a Function’s body, and each branch of an If are all Blocks.
Blocks expose create_* factory methods that simultaneously create a
statement or sub-structure, add it to the block, and (where relevant) register
names in the scope:
Method |
Creates |
|---|---|
|
A |
|
A |
|
Reserve name + |
|
|
|
|
|
Typed field (annotation ± default), useful in dataclasses |
|
|
|
|
|
An |
|
A |
|
A |
|
A |
|
|
|
|
|
|
|
|
|
|
|
A |
|
Any |
These factory methods are the recommended way to build code. They handle scope registration and validation for you.
Name — the bridge between Scope and Expression
Name is the central connecting piece. It is an
Expression, so you can chain further operations
on it (call, attribute access, arithmetic, etc.). But it can only be created
through a Scope, which guarantees the name is
defined.
module = codegen.Module()
func, func_name = module.create_function("add", args=["a", "b"])
# func_name is a Name for "add" in the module scope.
# func is the Function object (which is also a Scope).
a = func.name("a") # Name for the parameter
b = func.name("b")
func.body.create_return(a.add(b)) # return a + b
The pattern of create_* returning a (thing, Name) tuple is pervasive:
you hold onto the Name so you can call or reference the created entity
later.
Expression — the fluent chaining API
Expression is the base class for all
value-producing nodes. Every Expression exposes chainable methods that produce
new Expressions:
Method |
Produces |
Python equivalent |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Comparisons |
|
|
Boolean ops |
|
|
Membership tests |
|
|
|
Because every method returns a new Expression, you can chain them fluently:
# Generates: result.encode('utf-8').decode('ascii')
result.method_call("encode", [codegen.String("utf-8")]) \
.method_call("decode", [codegen.String("ascii")])
In addition to Expression, there is also the E-objects system that provides a more convenient syntax in many cases.
Literal values
The library provides Expression subclasses for all Python literal types:
Class |
Example |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
For convenience, the auto() function converts a
plain Python value into the appropriate Expression:
codegen.auto(42) # Number(42)
codegen.auto("hello") # String("hello")
codegen.auto(None) # NoneExpr()
codegen.auto([1, 2, 3]) # List([Number(1), Number(2), Number(3)])
Pre-made constants are available as codegen.constants.None_,
codegen.constants.True_, and codegen.constants.False_.
Worked Examples
Hello World — a simple function
from fluent_codegen import codegen
module = codegen.Module()
func, func_name = module.create_function("hello", args=["name"])
name = func.name("name")
greeting = codegen.FStringJoin.build([
codegen.String("Hello, "),
name,
codegen.String("!"),
])
func.body.create_return(greeting)
print(module.as_python_source())
Output:
def hello(name):
return f'Hello, {name}!'
Creating names and calling them
A common pattern is to create a name (for a function, variable, or import) and then call or reference it later:
module = codegen.Module()
# Import a module and hold the Name
_, json_name = module.create_import("json")
func, _ = module.create_function("serialize", args=["data"])
data = func.name("data")
# Use the held Name to call json.dumps(data)
result = json_name.attr("dumps").call([data])
func.body.create_return(result)
print(module.as_python_source())
Output:
import json
def serialize(data):
return json.dumps(data)
The same pattern works with create_function, create_class, and
create_import_from — all return a (object, Name) tuple.
Assignments and variables
The easiest way to create a local variable is
assign(), which reserves the name and
emits the assignment in one call:
module = codegen.Module()
func, _ = module.create_function("compute", args=["x"])
x = func.name("x")
# assign reserves "result" and adds result = x * 2
result = func.body.assign("result", x.mul(codegen.Number(2)))
func.body.create_return(result.add(codegen.Number(1)))
print(module.as_python_source())
Output:
def compute(x):
result = x * 2
return result + 1
assign returns the Name so you can
reference the variable immediately. It also accepts a type_hint keyword
argument:
result = func.body.assign("result", expr, type_hint=module.scope.name("int"))
# result: int = expr
For tuple-unpacking assignments, pass a tuple of strings as the target:
q, r = func.body.assign(("q", "r"), module.scope.name("divmod").call([
codegen.Number(10), codegen.Number(3),
]))
# q, r = divmod(10, 3)
The return type follows the input: a str target returns a single
Name; a tuple[str, ...] target returns
a tuple[Name, ...].
If you need more control — for example assigning to an attribute or subscript target, or allowing multiple assignments to the same name — use the lower-level steps directly:
# Reserve the name yourself, then assign
result_name = func.create_name("result")
func.body.create_assignment(result_name, x.mul(codegen.Number(2)))
func.body.create_return(result_name.add(codegen.Number(1)))
Classes and decorators
module = codegen.Module()
_, dc = module.create_import_from(from_="dataclasses", import_="dataclass")
cls, cls_name = module.create_class(
"Point",
decorators=[dc],
)
cls.body.create_field("x", module.scope.name("float"))
cls.body.create_field("y", module.scope.name("float"))
cls.body.create_field("z", module.scope.name("float"),
default=codegen.Number(0.0))
print(module.as_python_source())
Output:
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
z: float = 0.0
Control flow — if / elif / else
If is built incrementally with
create_if_branch:
module = codegen.Module()
func, _ = module.create_function("classify", args=["n"])
n = func.name("n")
if_stmt = func.body.create_if()
# if n > 0:
pos = if_stmt.create_if_branch(n.gt(codegen.Number(0)))
pos.create_return(codegen.String("positive"))
# elif n < 0:
neg = if_stmt.create_if_branch(n.lt(codegen.Number(0)))
neg.create_return(codegen.String("negative"))
# else:
if_stmt.else_block.create_return(codegen.String("zero"))
print(module.as_python_source())
Output:
def classify(n):
if n > 0:
return 'positive'
elif n < 0:
return 'negative'
else:
return 'zero'
With statements
module = codegen.Module()
func, _ = module.create_function("read_file", args=["path"])
path = func.name("path")
f_name = func.create_name("f")
with_stmt = func.body.create_with(
module.scope.name("open").call([path]),
target=f_name,
)
with_stmt.body.create_return(f_name.method_call("read"))
print(module.as_python_source())
Output:
def read_file(path):
with open(path) as f:
return f.read()
For loops
For creates a for loop with an optional
else clause:
module = codegen.Module()
func, _ = module.create_function("process", args=["items"])
items = func.name("items")
item = func.create_name("item")
for_stmt = func.body.create_for(item, items)
for_stmt.body.add_statement(
module.scope.name("print").call([item])
)
print(module.as_python_source())
Output:
def process(items):
for item in items:
print(item)
The target can also be a tuple of names for unpacking:
key = func.create_name("key")
value = func.create_name("value")
for_stmt = func.body.create_for(
(key, value),
items.method_call("items"),
)
Comprehensions and generator expressions
Use list_comprehension() and friends as convenient
ways to create ListComp etc. objects. It can be useful to use the
“walrus” operator to define Name objects, to allow you to create a list
comprehension with a single expression:
module = codegen.Module()
x = module.assign("x", codegen.auto([1, 2, 3]))
# List comprehension
lc = codegen.list_comprehension(
iterable=x,
target=(loop_var := module.scope.create_name("item")),
element=loop_var.e + 1,
)
# -> [y + 1 for y in x]
# Dict comprehension (with tuple unpacking)
items = module.assign("items", codegen.auto([("a", 1), ("b", 2)]))
dc = codegen.dict_comprehension(
iterable=items,
target=(
(key_var := module.scope.create_name("k")),
(value_var := module.scope.create_name("v")),
),
key=key_var.e.upper(),
value=value_var.e + 1,
)
# -> {k.upper(): v + 1 for k, v in items}
All these functions accept an optional condition keyword argument:
lc = codegen.list_comprehension(
iterable=data,
target=loop_var,
element=loop_var.e + 1,
condition=loop_var.e > 0
) # -> [y + 1 for y in x if y > 0]
Function arguments — positional, keyword, defaults
For simple cases, pass argument names as strings. For finer control use
FunctionArg:
from fluent_codegen.codegen import FunctionArg
module = codegen.Module()
func, _ = module.create_function("connect", args=[
FunctionArg.positional("host"),
FunctionArg.positional("port", default=codegen.Number(5432)),
FunctionArg.keyword("timeout", default=codegen.Number(30)),
FunctionArg.keyword("ssl", default=codegen.constants.False_),
])
print(module.as_python_source())
Output:
def connect(host, port=5432, /, *, timeout=30, ssl=False):
pass
Imports
module = codegen.Module()
# import os
_, os_name = module.create_import("os")
# import numpy as np
_, np_name = module.create_import("numpy", as_="np")
# from pathlib import Path
_, path_cls = module.create_import_from(from_="pathlib", import_="Path")
# from collections import OrderedDict as OD
_, od_name = module.create_import_from(
from_="collections", import_="OrderedDict", as_="OD"
)
Each call returns the statement and a Name that
you use to reference the imported entity.
String joining / f-strings
Use FStringJoin (the default
StringJoin) or
ConcatJoin to build dynamic strings:
greeting = codegen.FStringJoin.build([
codegen.String("Hello, "),
name,
codegen.String("! You have "),
count.method_call("__str__"), # or any expression
codegen.String(" items."),
])
# Generates: f'Hello, {name}! You have {count.__str__()} items.'
build() is smart: it merges adjacent String literals and simplifies
down to a plain String when possible.
Compiling and executing generated code
module = codegen.Module()
func, func_name = module.create_function("double", args=["n"])
n = func.name("n")
func.body.create_return(n.mul(codegen.Number(2)))
# Compile to a code object
code = compile(module.as_ast(), "<generated>", "exec")
# Execute into a namespace
ns: dict[str, object] = {}
exec(code, ns)
# Call the generated function
assert ns["double"](21) == 42
Comments
Add comments to any block:
Comments appear in the output of
as_python_source()as# …lines.