AST composition helpers: markers, hygiene, lifting, and lowering for composable Python code generation
Project description
astichi
AST-level composition and stitching for Python code generation.
Astichi takes small, marker-bearing Python snippets, composes them into one coherent program, and emits runnable Python. It is built for generators that need low-overhead output without falling back to brittle string templates or runtime abstraction in hot paths.
Astichi is a fit when you want to:
- describe codegen intent in Python-shaped snippets
- stitch block and expression fragments at named composition sites
- bind compile-time values into source before final lowering
- unroll compile-time loops into straight-line Python
- synthesize managed imports that participate in hygiene
- inspect composables with descriptors before wiring them
- emit inspectable source with provenance instead of opaque runtime machinery
It is not a general macro system and not a generic codemod framework. It is a focused library for generators that need a reliable AST stitcher.
Why Astichi
Code generators often hit the same wall: the desired output is simple, specialized Python, but the implementation ends up split between string concatenation, ad hoc templates, and fragile scope management.
Astichi handles the parts that usually go wrong:
- valid Python ASTs instead of fragile template fragments
- deterministic insertion order for stitched code
- compile-time binding and loop unrolling before emission
- specialized straight-line Python instead of runtime dispatch layers
- emitted source you can diff, test, and round-trip
Marker mental model
Astichi is marker-bearing Python source plus a small build pipeline.
- Markers are recognized from authored Python source.
- Marker meaning comes from AST position, not string matching alone.
compile(...)parses marker-bearing source into aComposable.build()wires composables together.describe()exposes holes, binds, ports, and builder target addresses for data-driven composition.materialize()resolves inserts, bindings, and hygiene, then produces real Python.
The core markers are:
astichi_hole(name)-> insertion siteastichi_keep(name)-> hygiene-preserved name in expression / statement sourcename__astichi_keep__-> hygiene-preserved name in identifier positionname__astichi_arg__-> identifier demandname__astichi_param_hole__-> function-parameter insertion targetastichi_funcargs(...)-> call-argument payloadastichi_bind_external(name)-> external/literal value slotastichi_ref(path)-> compile-time reducible identifier / attribute pathastichi_pyimport(module=..., names=(...))-> managed Python importastichi_comment("...")-> final-output source commentastichi_pass(name, outer_bind=True)-> explicit same-name boundary readastichi_import(name)-> explicit whole-scope boundary importastichi_export(name)-> explicit outward supplyastichi_insert(...)-> internal emitted metadata, not general authored API
Comment marker note:
astichi_comment("...")is statement-only. Ordinarymaterialize()strips it for executable output;emit_commented()renders it as real#comments.- Multi-line payloads keep the marker statement's indentation, and only exact
{__file__}/{__line__}substrings are expanded.
Value-form target note:
astichi_ref(...)andastichi_pass(...)are ordinary value-form surfaces in expressions.- If the marker result itself must occupy an
Assign/AugAssign/Deletetarget position, append._or.astichi_v:astichi_ref("self.f0")._ = 1,astichi_pass(counter).astichi_v = 1. - If you immediately continue to a real attribute, plain Python target syntax
already works:
astichi_pass(obj).field = 1.
The one rule that matters most is scope:
astichi_insertis the basic Astichi boundary.- Each inserted composable lives in its own Astichi scope.
- There is no implicit capture across that boundary.
- If a name crosses the boundary, make it explicit with
keep,pass,import, orexport. - Function parameters are the pinned exception: parameter names and uses in the function scope stay attached to that parameter binding.
Small example:
import astichi
builder = astichi.build()
builder.add.Root(
astichi.compile(
"""
items = []
astichi_hole(body)
result = tuple(items)
"""
)
)
builder.add.Step(
astichi.compile(
"""
astichi_pass(items, outer_bind=True).append("x")
"""
)
)
builder.Root.body.add.Step(order=0)
materialized = builder.build().materialize()
print(materialized.emit(provenance=False))
Emitted Python:
items = []
items.append("x")
result = tuple(items)
Without astichi_pass(items, outer_bind=True), the inner snippet does not get
to reuse items just because the spelling matches. That is deliberate. Astichi
defaults to isolated scopes and only crosses them when the source says so.
The fluent builder is also available as a data-driven named API. Descriptor target data can feed that API directly:
hole = root.describe().single_hole_named("body")
builder = astichi.build()
builder.add("Root", root)
builder.add("Step", astichi.compile("value = 1\n"))
builder.target(hole.with_root_instance("Root")).add("Step")
That builder.target(...) call uses the same target address as
builder.Root.body.add.Step(), but the address came from describe() instead
of a Python attribute chain.
Example: schema-specialized row projector
Suppose an ingestion pipeline knows its event schema at build time, and each field needs its own normalization step. A runtime loop or dispatch table adds overhead to every row. String templating works until ordering, scope, and correctness start fighting each other.
Astichi lets you define the skeleton once, stitch in field-specific steps, and emit the straight-line Python you actually want to run.
import astichi
root = astichi.compile(
"""
astichi_bind_external(FIELDS)
def project_row(row):
out = {}
for field in astichi_for(FIELDS):
astichi_hole(step)
return out
"""
).bind(FIELDS=("user_id", "total_cents", "created_at"))
builder = astichi.build()
builder.add.Root(root)
builder.add.UserId(
astichi.compile("out['user_id'] = int(row['user_id'])\n")
)
builder.add.TotalCents(
astichi.compile("out['total_cents'] = int(row['total_cents'])\n")
)
builder.add.CreatedAt(
astichi.compile("out['created_at'] = row['created_at'][:10]\n")
)
builder.Root.step[0].add.UserId()
builder.Root.step[1].add.TotalCents()
builder.Root.step[2].add.CreatedAt()
projector = builder.build().materialize()
print(projector.emit(provenance=False))
Emitted Python:
def project_row(row):
out = {}
out["user_id"] = int(row["user_id"])
out["total_cents"] = int(row["total_cents"])
out["created_at"] = row["created_at"][:10]
return out
That is the point: no runtime field loop, no dispatch registry, no handwritten template surgery. The generated function is plain Python, specialized to the known schema, and suitable for hot-path use.
This is exactly the class of problem where a reliable AST stitcher matters:
- block fragments must land in the right lexical scope
- per-field steps must keep deterministic order
- compile-time schema data must become literal Python
- the final output must still be valid, inspectable source
Current surface
Astichi currently provides:
astichi.compile(source, file_name=None, line_number=1, offset=0)astichi.build()for builder-based composition- concrete composables with
.bind(...),.describe(),.materialize(), and.emit(...)/.emit_commented() - data-driven builder calls such as
builder.add("Root", root)andbuilder.target(hole.with_root_instance("Root")).add("Step") - provenance helpers in
astichi.emit
Supported pieces today include block holes, expression inserts, external binding, managed Python imports, materialization, emission, and builder-driven loop unrolling.
Layout
| Path | Role |
|---|---|
src/astichi/ |
Library code |
docs/ |
User-facing docs |
tests/ |
Pytest suite |
dev-docs/ |
Design notes, active summary, and requirements |
scratch/ |
Throwaway experiments (not shipped) |
Development
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 python -m pytest
Status
Early development (0.1.0), but already useful for controlled codegen
pipelines.
Start with:
docs/for the user-facing surfacedev-docs/AstichiSingleSourceSummary.mdfor the current implementation snapshot and known gaps
License
MIT. See LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file astichi-0.1.0.tar.gz.
File metadata
- Download URL: astichi-0.1.0.tar.gz
- Upload date:
- Size: 476.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a654842abb6d8fafafe7015b87ac9e735b1e614371e882d1db68570f76dd3710
|
|
| MD5 |
f3d8119aa4d75a3e43de40a42fc718b3
|
|
| BLAKE2b-256 |
a7d986b233951144ff1bb87079947ed12f57255edd9063b7a06e9335c24b06f8
|
Provenance
The following attestation bundles were made for astichi-0.1.0.tar.gz:
Publisher:
publish.yml on owebeeone/astichi
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
astichi-0.1.0.tar.gz -
Subject digest:
a654842abb6d8fafafe7015b87ac9e735b1e614371e882d1db68570f76dd3710 - Sigstore transparency entry: 1440261692
- Sigstore integration time:
-
Permalink:
owebeeone/astichi@4d2a7bc326e0967d0bab40da332a7bd0a6686248 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/owebeeone
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@4d2a7bc326e0967d0bab40da332a7bd0a6686248 -
Trigger Event:
release
-
Statement type:
File details
Details for the file astichi-0.1.0-py3-none-any.whl.
File metadata
- Download URL: astichi-0.1.0-py3-none-any.whl
- Upload date:
- Size: 138.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7c92449581beccace429b4aa30ab4ed66c4dd3b4b3144da6e8a226b7855dca6f
|
|
| MD5 |
e1f1a7c76f819e42b25403ffbc26787c
|
|
| BLAKE2b-256 |
44f8b752110fbbbd371065eb21684bf3c5588b062a1670e4c1027989f8bd5196
|
Provenance
The following attestation bundles were made for astichi-0.1.0-py3-none-any.whl:
Publisher:
publish.yml on owebeeone/astichi
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
astichi-0.1.0-py3-none-any.whl -
Subject digest:
7c92449581beccace429b4aa30ab4ed66c4dd3b4b3144da6e8a226b7855dca6f - Sigstore transparency entry: 1440261700
- Sigstore integration time:
-
Permalink:
owebeeone/astichi@4d2a7bc326e0967d0bab40da332a7bd0a6686248 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/owebeeone
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@4d2a7bc326e0967d0bab40da332a7bd0a6686248 -
Trigger Event:
release
-
Statement type: