Project Setup

Running Pyright in Strict Mode on a Python CLI

Adopt Pyright strict mode in a CLI codebase: configuration, gradual rollout per package, typing JSON and YAML input at the boundary, Typer and Click, and CI.

Updated

A CLI is a boundary program: almost everything it handles arrives untyped. Arguments are strings, configuration files become dictionaries, API responses become json.loads() results, environment variables are str | None. Type checkers in their default modes let most of that untyped data flow freely through the program, so the bug where row["status"] is sometimes an integer surfaces in production, not in CI. Pyright's strict mode refuses to let unknown types spread: every parameter must be annotated, and any value whose type the checker cannot determine is an error where it is used. That is exactly the discipline a CLI needs — validate untyped input once, at the edge, and work with real types everywhere else. This guide sets up strict mode, rolls it out gradually, shows the patterns that satisfy it without drowning in casts, and runs it in CI. It belongs to the linting and type checking topic; the mypy side is in type-checking Click and Typer code with mypy.

Prerequisites

  • A CLI project with type hints in at least part of the code.
  • Pyright, run with uvx pyright (it downloads a Node.js runtime if needed) or the basedpyright fork, which ships as a pure Python wheel and adds a few stricter rules.
  • The project installed in a virtual environment, so Pyright can see its dependencies.

What strict mode changes

Pyright's basic and standard modes report clear errors — calling a method that does not exist, passing a str where an int is required. strict mode turns on a family of rules about unknown types: missing parameter annotations, variables and members whose types are partially unknown, and untyped functions called from typed code.

What each Pyright mode reports A comparison of Pyright basic, standard and strict modes on clear type errors, missing annotations and unknown types. What each Pyright mode reports Finding basic standard strict Wrong argument type yes yes yes Missing parameter annotation no no yes Unknown value used no no yes Explicit Any allowed allowed allowed Strict mode targets Unknown — the type of data nobody declared.

The important distinction is between Any and Unknown. Any is a type you (or a library stub) wrote deliberately, and strict mode accepts it. Unknown is what Pyright infers when it has nothing to go on — an unannotated parameter, an empty {} literal, a library without type information — and strict mode reports wherever an Unknown is used. A CLI's untyped input mostly arrives as Any (from json.loads, yaml.safe_load) and becomes Unknown the moment it is stored in an unannotated container or passed to an unannotated function.

The recipe

Configure

# pyproject.toml
[tool.pyright]
include = ["src"]
pythonVersion = "3.11"
typeCheckingMode = "strict"
venvPath = "."
venv = ".venv"

include keeps tests and scripts out of the first pass; venvPath and venv point Pyright at the project environment so imports resolve. Run it:

uvx pyright

Here is what strict mode says about a small, plausible-looking module that reads a JSON report:

import json
from pathlib import Path


def fmt(row):
    return f"{row['name']}: {row['status']}"


def summarise(path: Path) -> list[str]:
    rows = json.loads(path.read_text())
    counts = {}
    for row in rows:
        counts[row["status"]] = counts.get(row["status"], 0) + 1
    return [fmt(r) for r in rows]
report.py:5:9 - error: Type of parameter "row" is unknown (reportUnknownParameterType)
report.py:5:9 - error: Type annotation is missing for parameter "row" (reportMissingParameterType)
report.py:13:33 - error: Type of "get" is partially unknown (reportUnknownMemberType)
3 errors, 0 warnings, 0 informations

Each error points at a place where the code's assumptions about the data are invisible to the checker — and to the next reader.

Fix at the boundary, not everywhere

The tempting fix is to sprinkle Any and cast() until the errors stop. The better fix is to convert untyped input into typed values once, where it enters the program, and let everything downstream use real types:

# src/mytool/report.py
import json
from collections import Counter
from pathlib import Path
from typing import TypedDict, cast


class Row(TypedDict):
    name: str
    status: str


def parse_rows(raw: object) -> list[Row]:
    """Validate untyped JSON once, at the boundary."""
    if not isinstance(raw, list):
        raise ValueError("expected a JSON array")
    rows: list[Row] = []
    for item in cast(list[object], raw):
        if not isinstance(item, dict):
            raise ValueError("expected objects in the array")
        obj = cast(dict[str, object], item)
        name, status = obj.get("name"), obj.get("status")
        if not isinstance(name, str) or not isinstance(status, str):
            raise ValueError("each row needs string 'name' and 'status'")
        rows.append({"name": name, "status": status})
    return rows


def fmt(row: Row) -> str:
    return f"{row['name']}: {row['status']}"


def summarise(path: Path) -> tuple[list[str], Counter[str]]:
    rows = parse_rows(json.loads(path.read_text(encoding="utf-8")))
    counts = Counter(row["status"] for row in rows)
    return [fmt(r) for r in rows], counts

parse_rows takes object — the honest type of anything — and narrows it with isinstance checks, which Pyright understands. The two cast() calls are the only places the code asserts something the checker cannot prove, and both sit directly after the isinstance check that justifies them. Everything after parse_rows works with Row, and a typo such as row["stauts"] is now a type error. For larger schemas, a Pydantic model or msgspec struct does the same validation with less code, as in typed settings with pydantic-settings.

Typing untyped input once Untyped JSON enters as object, is narrowed and validated by a boundary function into a TypedDict, and the rest of the program works with real types. Typing untyped input once json.loads() Any parse_rows(raw: object) isinstance checks list[Row] TypedDict fmt(), summarise() fully typed object validated typed Casts live only next to the checks that justify them.

Typer and Click under strict mode

Typer's Annotated style is fully typed and passes strict mode without help: parameters are annotated, and typer.Option(...) metadata lives inside Annotated. Click decorators are typed too, but command callbacks must annotate every parameter, and values from ctx.obj are Any — wrap them in a typed accessor (def get_config(ctx: click.Context) -> Config: return cast(Config, ctx.obj)) rather than reading ctx.obj everywhere. The patterns in sharing state with Click context objects fit naturally.

Roll out gradually

Turning strict mode on for a large codebase at once produces hundreds of errors and a tempting # type: ignore spree. Two gentler routes:

[tool.pyright]
typeCheckingMode = "standard"
strict = ["src/mytool/core", "src/mytool/config.py"]

The strict list applies strict rules only to the listed paths, so new or important modules can be strict while the rest catches up. Alternatively, add # pyright: strict as the first comment in a file to opt that file in. Grow the strict set module by module; it is satisfying, visible progress, and each module you convert stays converted because CI enforces it.

UX considerations

The users of a type checker are the people who maintain the code:

  • Prefer narrowing to casting. isinstance checks are verified by the checker and fail loudly at runtime; cast is a promise with no enforcement. Keep casts next to the check that justifies them.
  • Make # pyright: ignore[rule] specific. A bare ignore hides every future error on that line; naming the rule documents what was suppressed.
  • Keep the noisy rules on. reportUnknownMemberType is the rule people most want to disable, and the one that finds untyped data leaking from dependencies. Fix the boundary instead.
  • Run it where people code. Pylance in VS Code uses the same engine, so errors appear while typing — set the workspace to the same configuration as CI.
Rolling strict mode out A gradual adoption path for Pyright strict mode, from standard mode everywhere to strict mode for the whole package. Rolling strict mode out standard everywhere, CI enforced week 1 strict list config + core modules week 2 one module per pull request ongoing strict typeCheckingMode done each converted module stays converted because CI checks it Visible progress beats a one-off pull request with hundreds of ignores.

Testing the behaviour

Type checking is itself a test, so the "test" here is making CI enforce it with the same configuration everyone uses locally:

# .github/workflows/types.yml (excerpt)
      - uses: astral-sh/setup-uv@v6
      - run: uv sync --locked
      - run: uvx pyright@1.1.414 --outputjson > pyright.json || true
      - run: uvx pyright@1.1.414

Pin the Pyright version: new releases add checks, and a red build caused by a checker upgrade should be a deliberate pull request, not a surprise on an unrelated change. Pair it with runtime tests of the boundary functions — parse_rows with a non-list, a list containing a number, a row with an integer status — because the type checker proves the downstream code is consistent only if the boundary really rejects bad input. Property-based testing CLI arguments with Hypothesis is a good way to throw unexpected shapes at it.

Conclusion

Strict mode suits CLIs because their hardest bugs come from untyped input leaking into typed code. Enable typeCheckingMode = "strict" (or a strict list of paths to start), convert JSON, YAML and environment data into typed structures once at the boundary with narrowing, keep casts rare and justified, and pin the Pyright version in CI. The errors stop being noise and start pointing at exactly the assumptions that would have failed in production.

Frequently asked questions

Pyright or mypy?

Both are good. Pyright is fast, has the strict "unknown type" rules built in, and powers editor feedback in VS Code; mypy has a plugin system (for example for Pydantic and SQLAlchemy) and a long history in many codebases. Running both is common in libraries; for a CLI, pick one and enforce it.

What is basedpyright?

A community fork of Pyright distributed as a Python package (no Node.js needed), with extra rules and a "baseline" feature that records existing errors so only new ones fail CI — handy for adopting strict mode in a large codebase.

Why does Pyright complain about a library I use?

The library ships without type information, so its functions return Unknown. Look for a stubs package (types-…), check whether Pyright's bundled typeshed covers it, or wrap the calls you use in a small typed module of your own so the Unknown stops at that boundary.

Should tests be type-checked strictly?

Usually in standard mode. Tests poke at internals and use fixtures in ways that strict rules make tedious; the value of strict checking is in the shipped code.