Skip to content

Contributing

Setting up

git clone <repo> && cd termopy
uv sync
make test

Python 3.12 or later. Everything runs through uv.

The toolchain

make test        # 597 tests
make lint        # ruff, ty, pyrefly, mypy --strict, zuban --strict
make format      # ruff format + --fix

Five type checkers, because they disagree and the disagreements are where the bugs are. ty passed on code where mypy found eight real errors. mypy --strict finds one more than zuban --strict. zuban can check examples/ where mypy cannot, because two __main__.py files collide on module name.

Tests

A pyramid, and the directory decides the marker:

tests/a_unit/          pure functions, no I/O, no event loop   (~0.3 s)
tests/b_integration/   widgets and apps through a UI
tests/c_e2e/           real programs on a pty
uv run pytest -m unit
uv run pytest -m "not slow"     # skips pty and tmux tests

Conventions the existing tests follow:

  • Name the behaviour. test_a_listbox_scrolls_to_keep_the_selection_in_view.
  • Arrange, act, assert, separated by blank lines.
  • Parametrize when it removes duplication.
  • Assert on rendered output wherever that is possible.

House rules

Never silence a linter. No # noqa, no # type: ignore. If a rule is wrong for this codebase, ignore it in ruff.toml with a one-line reason next to it. A growing list of per-line suppressions means the types or the structure are wrong.

Comments say why. The code already says what.

Keyword-only arguments for anything after the required ones on a public function. PLR0917 is deliberately not ignored, and it is what keeps this true.

Immutable by default. NamedTuples and frozen dataclasses for values; mutable dataclasses only where something is genuinely assigned after construction (a Task, a future's result).

Adding a widget

A widget is a function returning a View. There is no base class. See the guide for the two conventions that matter: the caller owns the state, and return a View unless the caller needs more than pixels.

Put pure helpers (width arithmetic, key tables, formatting) at module level so they can be unit-tested without a UI. column_widths in widgets/table.py and less_keys.action_for are the pattern.

Adding to the core

The bar is higher. Before adding to view.py or runtime.py, check whether an application can do it. Prefer letting it, until a second application wants the same thing.

Both ported applications needed screen navigation; they needed different shapes, so termopy has open_modal and no Screen class. One application would have produced an abstraction the second did not want.

The notes

notes/ holds the design record: a Pythonic-design review, a plan and verdict per port, and lessons-learned.md. They are worth reading before proposing a change, because several obvious-looking ideas are in there with a reason they were rejected.

If you find something that costs an hour to rediscover, add it.

Documentation

Built with Zensical:

The docs are their own project under docs/, with sources in docs/src/ and their own Makefile:

make docs            # or: cd docs && make build
make docs-serve      # live reload
make docs-check      # strict — warnings become errors

Zensical validates internal links. make docs reports a bad cross-reference as a warning; make docs-check fails the build on it, which is what CI should run.