Skip to main content

Command Palette

Search for a command to run...

Rendering and Writing Files in Python

Updated
3 min readView as Markdown
B
I build CLI tools and developer productivity scripts in Python and Rust.

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.

Building scaffoldr v0.1.0

Part 4 of 5

A behind-the-scenes look at building scaffoldr - a CLI tool that scaffolds new Python projects with GitHub integration. This series walks through the local scaffolding engine, GitHub API integration, automated issue creation, branch protection, and CI generation - the core features that shipped with v0.1.0.

Up next

Running Git Commands from Python

Once scaffold writes all the project files, it needs to turn the directory into an actual git repository - run git init, stage everything, and make the first commit. I went with running the git binary