thunc write
Functions that write themselves. A function declared with write=True writes its own body on its first call. The model writes Python from the docstring, thunc checks it against the model's own answers and puts it into your file in place of ..., and the call runs it. From then on it's plain Python, with no model calls.
Experimental, new in 0.3. Its behavior, options and the code it writes may change, or it may be removed, in a later release without a deprecation period. thunc write edits your source files. It only does it while you develop, and every change is a diff for you to review before you commit it.
Before and after the first call
import thunc
@thunc.function(write=True)
def minutes(duration: str) -> int:
"""Convert a duration like '1h 30m', '90 min' or '2 hours' to whole minutes."""
...thunc: writing minutes() in durations.py (first call)
thunc: checked against 6 model answers: all agree
thunc: wrote durations.py lines 5-27 in 14s (answer 4s, draft 11s, test calls 9s; side by side). Removed @thunc.function. Review: git diff durations.pyimport thunc
import re
def minutes(duration: str) -> int:
"""Convert a duration like '1h 30m', '90 min' or '2 hours' to whole minutes.
>>> minutes('1h 30m')
90
>>> minutes('2 hours')
120
"""
# Written by thunc from the docstring on 2026-10-07. Review it.
units = {"h": 60, "hr": 60, "hrs": 60, "hour": 60, "hours": 60,
"m": 1, "min": 1, "mins": 1, "minute": 1, "minutes": 1}
parts = re.findall(r"(\d+(?:\.\d+)?)\s*([a-z]+)", duration.lower())
if not parts or any(unit not in units for _, unit in parts):
raise ValueError(f"not a duration: {duration!r}")
return round(sum(float(n) * units[unit] for n, unit in parts))The decorator is gone, and the docstring stays as the function's documentation, with the calls it was checked on added as doctest examples (python -m doctest durations.py runs them). import thunc stays, because other code in the file, or code that imports it, may use it. The program that made the first call goes on using the written code without being restarted. thunc_write.py shows it end to end.
What happens on the first call
- thunc checks that writing is allowed here (see below), and takes a lock for the function, so of several calls at once one writes and the others wait for its code.
- Three requests start side by side:
- this call's answer, as an ordinary
@thunc.functionwould get it; - a draft of the body, written from the docstring and signature with the whole file in view, plus any classes from elsewhere in your project that the signature uses. The model may answer instead that the task needs judgment that rules can't capture, such as rating urgency or summarising;
- five test calls, from a request of their own so they don't share the draft's blind spots, each then answered by the model separately.
- this call's answer, as an ordinary
- The draft is checked. It's linted, compiled, and run on this call and the test calls with a time limit of 10 seconds each, and every result has to match the model's answer. Results are compared by the return type: an
intmust be anint, not90.0, and floats may differ by rounding.ensure=applies to the code's results too. A draft that fails goes back with the failing calls, up to three drafts. - A passing draft goes into the file in one step, and only the function's lines and any imports it needs change. The written function is compiled into the running program, and this call runs it.
The first call takes about as long as a draft and the test calls, typically 10 to 40 seconds depending on the backend. Later calls take as long as your code does.
When it isn't written
If the model says the task needs judgment, or no draft passes in three tries, the call returns the model's answer, the file is left as it was, and a warning says why. The reason is saved in .thunc_write/ at the project's root (it ignores itself in git), so later runs don't ask again. They answer through the model, as without write=True. Changing the docstring or the signature tries again, and so does thunc write. If something fails along the way, such as a backend that's down or too few test calls the model could answer, nothing is saved, and the next run tries again.
Where writing is refused
In each of these cases the function answers through the model and warns once with the reason:
| Situation | How thunc tells |
|---|---|
| Running in CI, or switched off | CI is set, or THUNC_WRITE=0. Set THUNC_WRITE=0 in production |
| No source file | A REPL, a notebook cell or exec |
| Installed code | The file is under site-packages or the Python installation |
| Outside the project | The file isn't in a git repository, or under the current directory when there's none |
| A read-only file | No write permission |
| The file changed since it was imported | Its contents differ from when the program loaded it, for example after another process wrote the function. Restart to use the new code |
| A function inside another function | Module-level functions and methods only |
The jev backend | It can't write code |
Two more are errors when the function is declared: write=True needs the prompt in the docstring, not instructions=, and it must be the decorator nearest the def line.
Writing ahead of the first call
The thunc write command writes a function without calling it, for example before you deploy, or to see the change first:
thunc write durations.py::minutes --dry-run # the change as a diff; the file stays as it is
thunc write durations.py::minutes # write it
thunc write app.py::minutes --backend codex # with another backend or --modelIt imports the file to find the function, which runs its top-level code as any import does, but not its if __name__ == "__main__": block. With no call to answer, the draft is checked on the test calls alone. A reason saved by an earlier attempt is ignored, because you asked. A dry run saves nothing. Methods need an instance to be tested with, so call them once instead.
What to review
- The checks are only as good as the model's answers. The code agrees with the model on six inputs. If the model is consistently wrong about something, the code will be too. Read the rule it wrote, and add your own tests for the cases that matter.
- Inputs it can't handle raise
ValueError. The written code doesn't fall back to the model. If you need inputs it rejects, extend the code, or keep the function a model call. - The lint catches mistakes, not attacks. Written code may use only the standard library and modules the file already imports. It may not run commands, use the network, call
evalorexec, or write files. It runs in your program once it passes, so the diff is the boundary. Writing happens only in development, in your own files. - Free-text functions rarely pass. A function that returns prose needs its exact words to match the model's, so it usually stays a model call. That's the right outcome for it.