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 a ast.Module node you can compile() and exec().

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 Name expression. 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 a str object as a name for the target. In this case, they automatically call create_name for 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 Name for 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

create_function(name, args)

A Function definition

create_class(name, bases)

A Class definition

assign(name, value)

Reserve name + name = value (shortcut)

create_assignment(name, value)

name = value

create_annotation(name, type)

name: type (bare annotation)

create_field(name, type, default=…)

Typed field (annotation ± default), useful in dataclasses

create_import(module)

import module

create_import_from(from_=…, import_=…)

from module import name

create_if()

An If statement

create_with(expr, target)

A with statement

create_for(target, iterable)

A for loop

create_try()

A try/except/else/finally statement

create_return(value)

return value

create_break()

break

create_continue()

continue

create_raise(exc, cause)

raise exc or raise exc from cause

create_assert(test, msg)

assert test, msg

add_comment(text)

A # text comment line

add_statement(stmt)

Any Statement or Expression

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

.call(args, kwargs)

Call

expr(a, b, k=v)

.attr(name)

Attr

expr.name

.method_call(name, args, kwargs)

Call

expr.name(a, b)

.subscript(index)

Subscript

expr[index] — pass a Slice for slicing

.add(other)

Add

expr + other

.sub(other)

Sub

expr - other

.mul(other)

Mul

expr * other

.div(other)

Div

expr / other

.mod(other)

Mod

expr % other

.eq(other)

Equals

expr == other

.ne(other)

NotEquals

expr != other

.lt(other), .gt(other), .le(other), .ge(other)

Comparisons

<, >, <=, >=

.and_(other), .or_(other)

Boolean ops

and, or

.in_(other), .not_in(other)

Membership tests

in, not in

.starred()

Starred

*expr

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

String

codegen.String("hello")

Number

codegen.Number(42) or codegen.Number(3.14)

Bool

codegen.Bool(True)

Bytes

codegen.Bytes(b"data")

List

codegen.List([codegen.Number(1), codegen.Number(2)])

Tuple

codegen.Tuple([codegen.String("a"), codegen.String("b")])

Set

codegen.Set([codegen.Number(1)])

Dict

codegen.Dict([(codegen.String("k"), codegen.Number(1))])

NoneExpr

codegen.NoneExpr()

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.

Comments

Add comments to any block:

module.add_comment("Auto-generated — do not edit.")
module.add_comment(
    "This is a long comment that will be wrapped nicely.",
    wrap=72,
)

Comments appear in the output of as_python_source() as # lines.

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