Skip to main content

Command Palette

Search for a command to run...

Talking to the GitHub API with httpx

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

With local scaffolding done, scaffoldr new needed to create a repo on GitHub. That means authenticating with a token, sending HTTP requests to GitHub's REST API, and handling whatever comes back - success or failure.

Choosing httpx

I used httpx for this instead of the standard library's urllib or the more common requests library. httpx has a requests-like API, but supports sync and async out of the box. scaffoldr only needs sync calls, but I wanted the option to move to async later without switching libraries.

Finding the token

Every request to GitHub needs a personal access token. _get_token looks for one in two places:

def _get_token() -> str:
    token = os.environ.get("SCAFFOLDR_GITHUB_TOKEN")
    if token:
        return token
    cfg = Config.load()
    if cfg.github_token:
        return cfg.github_token
    typer.echo(
        "Error no GitHub token found.\n"
        "Set SCAFFOLDR_GITHUB_TOKEN env var or run "
        "`scaffoldr config init` to save a token.",
        err=True,
    )
    raise typer.Exit(code=1)

It checks the SCAFFOLDR_GITHUB_TOKEN environment variable, then falls back to the saved config file. Checking the environment variable first means anyone who doesn't want a token sitting in a config file on disk has that option, without scaffoldr forcing one approach.

If neither exists, it prints a message telling the user exactly how to fix it, then exits.

Building an authenticated client

Once there's a token, _client builds an httpx.Client configured to talk to GitHub:

def _client() -> httpx.Client:
    token = _get_token()
    return httpx.Client(
        base_url=GITHUB_API,
        headers={
            "Authorization": f"Bearer {token}",
            "Accept": "application/vnd.github+json",
            "X-GitHub-Api-Version": "2022-11-28",
        },
        timeout=10.0,
    )

Setting base_url means requests only need a path - /user/repos instead of the full https://api.github.com/user/repos every time. The headers identify the request (Authorization), set the expected response format (Accept), and pin a specific API version (X-GitHub-Api-Version) so future GitHub API changes don't silently break scaffoldr. timeout=10.0 fails a request after 10 seconds instead of hanging forever.

Creating the repo

create_repo sends the actual request:

def create_repo(
    name: str,
    description: str = "",
    private: bool = False,
) -> dict:
    with _client() as client:
        response = client.post(
            "/user/repos",
            json = {
                "name": name,
                "description": description,
                "private": private,
                "auto_init": False,
            },
    )

with _client() as client uses the client as a context manager, which closes the underlying HTTP connection automatically once the block finishes - no separate client.close() call needed.

auto_init: False tells GitHub not to create a default README or initial commit. scaffoldr already builds the project locally and pushes its own first commit - if GitHub also created one, the two histories wouldn't match when scaffoldr tries to push.

Handling specific failures

GitHub's API returns different status codes depending on what went wrong. 422 and 401 are common enough, and different enough from each other, to be worth a specific message:

if response.status_code == 422:
    typer.echo(
        f"Error: repo '{name}' already exists on GitHub.",
        err=True,
    )
    raise typer.Exit(code=1)
if response.status_code == 401:
    typer.echo(
        "Error: invalid or expired GitHub token.", err=True
    )
    raise typer.Exit(code=1)

422 from this endpoint almost always means the repo name is already taken. 401 means the token is missing, wrong, or expired. Both tell the user exactly what happened instead of a generic "request failed."

Everything else falls through:

if not response.is_success:
    typer.echo(
        f"Error: GitHub API returned "
        f"{response.status_code} - "
        f"{response.text}",
        err=True,
    )
    raise typer.Exit(code=1)

response.is_success is httpx's shorthand for "status code between 200 and 299." This is the fallback for anything that doesn't fit the two cases above - less specific, but it still shows the actual status code and response body, which is enough to debug something unexpected.

What's next

With the repo created, the next step was pushing the local commit to it and creating the default issues every new project gets. That's the next post.

The code is on GitHub

Building scaffoldr v0.1.0

Part 2 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

Setting up a CLI with Typer

I'd used argparse before. It's clunky - lots of boilerplate for even simple commands. Typer works differently. You write a normal function with type annotated parameters. Typer builds the CLI from the