Skip to content

Code quality

photo-tagger relies on a small set of linters, type checkers, and security scanners to keep the code consistent and safe. Most of them run both locally (through pre-commit hooks) and in CI, so problems surface before review.

Tools

Each tool owns one slice of quality. Run them from the repository root.

ruff

ruff handles both linting and formatting. The project sets a 100-column width and enables the full rule set (select = ALL), so ruff is the first place style and lint issues show up.

Lint and auto-fix what it can, then format the tree:

uv run ruff check --fix .
uv run ruff format .

zuban

zuban is the strict, mypy-compatible type checker. It catches type mismatches that ruff does not.

uv run zuban check

Note

Use uv run, not a bare zuban check: a global uv tool install zuban on PATH would shadow the version pinned in uv.lock.

pycroscope

pycroscope is a semi-static analyzer that complements zuban with additional checks. Its config lives under [tool.pycroscope] in pyproject.toml.

uv run pycroscope --config-file pyproject.toml

Warning

pycroscope does not auto-discover the config file. Always pass --config-file pyproject.toml when invoking it directly, otherwise it runs with the wrong settings. The pre-commit hook already passes this flag for you.

The GUI module is a static-analysis exception

src/photo_tagger/gui.py is the one file excluded from the type checker, the analyzer, and coverage. PySide6 builds its widget attributes at runtime through shiboken, so the static tools report false undefined_attribute errors on every .addWidget, .clicked, and the like. It is therefore excluded from zuban (via a # mypy: ignore-errors header), from pycroscope (a [[tool.pycroscope.overrides]] entry), and from coverage ([tool.coverage.run].omit). The testable, Qt-free logic lives in gui_state.py, which is checked and covered normally. This is the documented exception to "fix, do not silence" below: a Qt event-loop shell cannot be modeled statically or run headlessly. See Testing for how it is verified instead.

bandit

bandit scans the source for common security problems (for example, unsafe subprocess use or hard coded secrets). It runs as a pre-commit hook and in CI.

zizmor

zizmor audits the GitHub Actions workflows themselves, which no Python linter looks at. It catches the CI-specific mistakes: credentials left behind in the checkout, caches that a lower-privileged run could poison, untrusted input interpolated into a run: block, jobs holding more permissions than they use.

uv run zizmor .

It runs as a pre-commit hook on anything under .github/workflows/, and in the zizmor.yml workflow, which also sends its findings to the repository's Security tab. CI passes a GH_TOKEN so the audits that query the GitHub API (a referenced action being vulnerable or its tag having moved) are switched on; without a token those quietly skip and the local run only does offline checks.

SonarCloud and SonarLint

SonarCloud analysis runs in CI as part of the test workflow. SonarLint surfaces the same rules in your editor so you can catch findings before pushing. SonarQube has many false positives; see the warning below before silencing anything.

Pre-commit hooks

The repository uses prek to run the git hooks. They are configured in prek.toml, prek's native TOML format, rather than the .pre-commit-config.yaml the original pre-commit tool reads. Run every hook against all files with one command:

prek run -a

The configured hooks are:

Hook What it checks
pre-commit-hooks basics Whitespace, end-of-file, merge conflicts, and similar basics.
uv-lock Keeps uv.lock in sync with pyproject.toml.
docformatter Reflows docstrings to 100 columns (runs before ruff).
ruff-check Lints with ruff and applies auto-fixes.
ruff-format Formats code to the 100-column style.
mdformat Formats Markdown (a base pass plus a docs pass for docs/).
bandit Scans for common security issues.
pyupgrade Rewrites code to modern syntax (--py314-plus).
cspell Spell-checks code and docs.
typos Catches common typos.
renovate-config-validator Validates the Renovate config.
zizmor Audits the GitHub Actions workflows for security.
zuban (local) Strict type check.
pycroscope (local) Semi-static analysis with the project config.
check-version-sync (local) Keeps the package version consistent across project files.
check-docs-sync (local) Keeps the documented CLI surface matching the real app.
check-translations (local) Fails if a .po changed without recompiling the catalog.

The local hooks all run through uv run, so they use the versions pinned in uv.lock rather than whatever happens to be first on PATH.

The check-version-sync hook checks the version in pyproject.toml, uv.lock, SECURITY.md, and CHANGELOG.md. The package itself no longer hardcodes a version: photo_tagger.__version__ reads the installed distribution metadata, so pyproject.toml is the only code-side source.

Before committing

Run the full local sequence before each commit. The pre-commit hooks cover most of it, but running the tools directly gives clearer output when something fails:

uv run ruff check --fix .
uv run ruff format .
uv run zuban check
uv run pycroscope --config-file pyproject.toml
prek run -a

Warning

Never silence a lint, type, or SonarQube finding just to make CI pass. Fix the underlying cause. SonarQube does produce false positives, so silencing is allowed only when the finding clearly does not apply, and only with a one-line reason explaining why.

See Testing for the test suite and coverage target, and Dependencies for how updates are managed.