Rendering and Writing Files in Python
With the CLI shell in place, the next problem was what scaffoldr init actually creates. In v0.1.0, every project got the same directory structure and files - README, CONTRIBUTING guide, pyproject.toml, an ADR template, a .gitignore, and a CI workflow. All hardcoded.
String templates as functions
Each file's content lives in its own function, returning a formatted string. Here's the README:
def readme(project_name: str, author: str) -> str:
return f"""\
# {project_name}
> Short description of what this project does.
## Installation
```bash
pip install {project_name}
```
## Usage
```bash
{project_name} --help
```
## Development
See [CONTRIBUTING.md](CONTRIBUTING.md) for setup and
contribution guidelines.
## License
{author} - MIT
"""
The \ right after the opening """ strips the leading newline that would otherwise appear before # {project_name}. Without it, the string starts with a blank line.
Every file in the scaffold follows this pattern - a function that takes whatever variables it needs (project_name, author, python_version) and returns the finished file content as a f-string.
Why functions instead of one big template
I could have written this as one function - something that takes every possible variable (project_name, author, python_version, license_) plus a parameter saying which file's content to return - then branching internally to build the right string.
Splitting it into six separate functions instead means each one's signature only lists what it actually uses. gitignore() takes nothing. pyproject() takes project_name, author, license_, python_version. A single dispatcher function would need every one of those parameters available at once, even though most individual files only use a subset.
It also means adding a new content-generating function later doesn't touch the existing ones - no shared dispatcher to extend, no risk of breaking readme() while adding license_file().
Creating the folder structure
The actual scaffolding happens in scaffold:
def scaffold(project_name: str, path: Path) -> None:
cfg = Config.load()
root = path / project_name
if root.exists():
typer.echo(f"Error: {root} already exists.", err=True)
raise typer.Exit(code=1)
typer.echo(f"Creating project at {root} ...")
(root / project_name).mkdir(parents=True)
(root / "tests").mkdir()
(root / "docs" / "adr").mkdir(parents=True)
(root / ".github" / "workflows").mkdir(parents=True)
Before creating anything, scaffold checks if root already exists and exits with an error if it does. This avoids silently overwriting someone's files.
mkdir(parents=True) creates every missing directory in the path, not just the final one. docs / adr is two levels deep - docs and docs/adr. Without parents=True, you'd need two separate mkdir() calls, one for each level.
Writing the files
Once the directories exist, writing files is straightforward:
(root / "README.md").write_text(readme(project_name, cfg.author))
(root / "CONTRIBUTING.md").write_text(contributing(project_name))
(root / "pyproject.toml").write_text(
pyproject(
project_name, cfg.author, cfg.python_version, cfg.license
)
)
Each file gets written the same way - call the content-generating function, pass the result to Path.write_text().
__init__.py files follow the same pattern, just with empty strings:
(root / "tests" / "__init__.py").write_text("")
(root / project_name / "__init__.py").write_text("")
This makes both the project's own package folder and its test folder proper Python packages.
What's next
At this point, scaffold finishes by initializing git and making the first commit. That's the next post.
The code is on GitHub.
