pycmdcheck v0.1.1 Released - R CMD check for Python Packages

pycmdcheck runs a battery of static quality checks over a Python package - metadata, structure, license, documentation, versioning, CI, and more - in a single command. It is built to inspect packages you have not installed, which makes it a good fit for triaging arbitrary repositories in CI.
software-releases
python-package
Author

James Balamuta

Published

June 26, 2026

pycmdcheck logo

pycmdcheck logo

The pycmdcheck package (GitHub, PyPI, documentation) answers a question Python packaging never gave a single tool for: is my package healthy? R users have had R CMD check for two decades - one command that walks a package and tells you, plainly, where it falls short. Python has the pieces, but they are scattered across a dozen places: build to confirm the package builds, twine check for metadata, mypy and ruff and pytest for the code, and a long oral tradition for everything else (does it ship a py.typed? a LICENSE? a changelog? does requires-python still allow a dead Python?). pycmdcheck gathers a representative set of those checks behind one command.

The motivation was concrete. I wanted to triage pyOpenSci software submissions - to run a quick, honest health check on a submitted repository before a human reviewer spends time on it - and there was no off-the-shelf way to do it. The catch is that you cannot install a stranger’s package just to inspect it: it may not build, it may pull in heavy dependencies, it may do something you would rather it not do on your CI runner. So pycmdcheck is designed, first, to read a package statically, and only optionally to run the heavier tooling.

One command

Install it from PyPI and point it at a directory:

pip install pycmdcheck
pycmdcheck /path/to/package

The default run prints a table you can read at a glance, one row per check:

┏━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ St… ┃ Check           ┃ Message                              ┃
┡━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│  ✓  │ metadata        │ Package metadata is valid            │
│  ✓  │ structure       │ Valid src layout structure           │
│  ✓  │ license         │ License file present                 │
│  ✓  │ docs            │ Documentation present                │
│  ✓  │ version         │ Version 1.2.0 is consistent          │
│  ℹ  │ citation        │ No citation file found               │
│  ⚠  │ python_versions │ requires-python allows EOL Python    │
│  …  │ …               │ … (every enabled check runs)         │
└─────┴─────────────────┴──────────────────────────────────────┘

Each check reports one of five statuses - OK, NOTE, WARNING, ERROR, or SKIPPED - and the process exits non-zero when something at or above your chosen threshold turns up, so pycmdcheck drops straight into a CI step. You can run a single check (-c metadata), skip one (-s typing), or ask for machine output with --json and parse the report yourself.

The checks cover the surface a reviewer actually looks at: package metadata and PEP 621 fields, the project layout, the license file, README and docstrings, version consistency between pyproject.toml and the code, the py.typed marker, a changelog, a citation file, community health files, CI configuration, and whether requires-python still admits an end-of-life Python. A second tier - tests, linting, type checking, imports, the dependency audit, the build, and doctests - runs the real tooling when you want it.

Checking packages you cannot install

The part I am most pleased with is the triage profile. It selects only the checks that are purely static - no installing the package, no spawning external tools, no network - so it is safe to run against an arbitrary, un-trusted repository:

pycmdcheck /path/to/package --profile triage --json --exit-zero

That single line is the heart of the pyOpenSci triage bot: clone a submission, run the static profile, and post the findings as a comment. --exit-zero is there for a subtle but important reason - in a triage context the exit code should tell you whether pycmdcheck ran, not what it found. A package with a real problem is the normal, expected outcome; it should not turn the CI job red. With --exit-zero, a non-zero exit means pycmdcheck itself could not run, and everything it discovered lives in the JSON report.

Reading packages statically sounds simple until you meet the actual diversity of how Python projects ship. The version is frequently not a literal in pyproject.toml - it is dynamic, computed at build time by setuptools_scm, hatch-vcs, or uv-dynamic-versioning. Plenty of projects still carry legacy Poetry metadata under [tool.poetry] with no [project] table at all. Source lives in src/, or flat at the root, or as a single foo.py module, or as a PEP 420 namespace package with no __init__.py. A license might be in LICENSE, LICENCE, COPYING, or a dual-licensed LICENSE-MIT / LICENSE-APACHE pair. A first cut of pycmdcheck flagged a perfectly good package as “no version specified” simply because it used dynamic versioning - exactly the kind of false positive that erodes trust in a tool like this. Getting the static checks to recognize the real ecosystem, rather than an idealized one, has been most of the work, and most of the value.

A library and a plugin system

Everything the CLI does is available from Python:

from pycmdcheck import check

report = check(".", checks=["metadata", "structure", "license"])
if not report.passed:
    for result in report.results:
        print(f"{result.status}: {result.name} - {result.message}")

And the check set is not fixed. pycmdcheck discovers checks through the pycmdcheck.checks entry-point group, so a third-party package - or your own project - can register additional checks and have them picked up automatically alongside the built-ins. The built-in roster is just the first set of plugins.

For a tour of every check and its options, see the Getting Started, Configuration, and CLI Reference pages in the documentation.

Installation

pycmdcheck is available now on PyPI:

pip install pycmdcheck

# optional extras for the tool-backed checks
pip install pycmdcheck[all]

For details, see the pycmdcheck release notes below.

pycmdcheck release highlights for version 0.1.1

Features

  • A single pycmdcheck command (and a check() Python API) that runs a battery of package checks and reports OK / NOTE / WARNING / ERROR / SKIPPED with Rich tables or --json.
  • Built-in checks spanning metadata, structure, license, documentation, version consistency, py.typed, dependencies, build, formatting, linting, typing, imports, tests, doctests, CI, changelog, citation, community files, project URLs, and end-of-life Python versions.
  • Profiles (minimal, triage, default, pyopensci, strict) that select a curated set of checks. The triage profile is static only - no install, no external tools, no network - for safely checking arbitrary repositories.
  • --exit-zero, so CI/automation that consumes --json can treat a non-zero exit as “pycmdcheck failed to run” rather than “the package has findings.”
  • Robust handling of real-world packaging: PEP 621 dynamic fields, legacy [tool.poetry] projects, src / flat / single-module / PEP 420 namespace layouts, and dual-license filenames.
  • A plugin system: checks are discovered via the pycmdcheck.checks entry-point group, so third-party packages can register their own.

GitHub Repository