Skip to main content

Sanopy

CI PyPI - Version PyPI - Python Versions Ruff Mypy Pyright License

Sanopy is a CLI tool for improving Python code quality. It runs multiple linters concurrently and emits findings as JSON to stdout.

Requirements

  • Python 3.12+
  • uv — optional. Sanopy uses it to install linters when present, and falls back to pip otherwise.

Installation

# Core only (click + rich); linters installed later by `sanopy init`
pip install sanopy

# Or pull in every linter up front
pip install 'sanopy[all]'

# Or pick just the ones you want
pip install 'sanopy[ruff,mypy]'

# Optional with uv
uv venv .venv
uv pip install 'sanopy[all]'

Each linter is an optional extra, so the base install stays small. Extra names match the linter names: ruff, pylint, bandit, mypy, pyright, semgrep, vulture, radon, safety, pip-audit, plus all.

Quick Start

1. Initialize

Sanopy requires a .sanopy.toml configuration file in your project. Run init to create it and install the linters you need:

# Interactive (manual) — prompts before installing anything
sanopy init

# Non-interactive (CI/automation) — installs missing linters directly
sanopy init --only ruff,mypy --skip bandit

# Write the config only, never touch the environment
sanopy init --only ruff,mypy --no-install

init installs any selected linter that is not already available, into the same environment Sanopy runs from. The interactive flow asks first; the non-interactive flow just does it, so pass --no-install if your pipeline manages dependencies itself. init exits 2 if an install fails, so a following scan will not run against a half-built environment.

If you run sanopy scan before initializing, Sanopy will tell you to run sanopy init first and exit 2.

2. Scan a Codebase

sanopy scan src/

By default, machine-readable JSON is printed to stdout in a versioned envelope:

{
  "schema_version": "1.0.0",
  "run": {
    "target": "src",
    "generated_at": "2026-08-09T00:00:00+00:00",
    "active_linters": ["ruff", "pylint", "mypy"],
    "finding_count": 0
  },
  "findings": []
}

Use human output mode for terminal-friendly progress and summaries:

sanopy scan src/ --output-mode human

You can scan multiple directories or files at once:

sanopy scan src/ tests/

When scanning multiple targets, the results are merged into a single JSON document, and run.target becomes an array of the scanned paths.

Generate a human-readable Markdown report:

sanopy scan src/ --human-readable

The report is saved as linting-report-<target>.md (e.g., linting-report-src.md).

Save results to a custom file:

sanopy scan src/ -o my-scan.json

Exit Codes

sanopy scan distinguishes "clean" from "could not check", so a failed run never looks like a passing one:

Code Meaning
0 Scan completed, no findings
1 Scan completed, findings reported
2 Scan could not run, or crashed

Exit 2 covers a missing or unreadable .sanopy.toml, a selected linter that is not installed, filters that select no linters at all, and an unexpected error during the scan. Every case prints its reason to stderr, so stdout stays a valid JSON document.

sanopy init exits 0 on success and 2 if a linter installation fails, so sanopy init && sanopy scan src/ will not scan against a half-built environment.

Linter Filtering

Run only selected linters:

sanopy scan . --only ruff,mypy

Skip selected linters:

sanopy scan . --skip safety

Names are case-insensitive and surrounding whitespace is ignored, so --only " Ruff , MyPy " works.

You can also set default only_linters and skip_linters values in .sanopy.toml via sanopy init.

Precedence. Each CLI flag replaces its own counterpart in .sanopy.toml, but not the other one. Given skip_linters = ["ruff"] in the config, --only ruff,mypy runs only mypy: the CLI --only replaced only_linters, while the config's skip_linters still applies. Pass --skip explicitly to override it. --only is applied before --skip, so a linter named in both is skipped.

Selecting nothing is an error. If the filters leave no linters to run, Sanopy exits 2 instead of reporting a clean scan, and names any unrecognised linter:

$ sanopy scan src/ --only rufff
No linters selected.
Unknown linter name(s): rufff
Available: bandit, mypy, pip-audit, pylint, pyright, radon, ruff, safety, semgrep, vulture

How Sanopy Finds Linters

Linters run as subprocesses, never as imports. For each one, Sanopy tries in order:

  1. The console script on PATH (e.g. ruff).
  2. The console script next to the running Python interpreter — this reaches Sanopy's own environment even when its bin/ directory is not on PATH, as with a non-activated virtualenv, pipx, or uv tool.
  3. python -m <module> in that same interpreter, for linters that support it.

A linter is reported as missing only when all three fail, and scan then exits 2 rather than silently skipping it.

Supported Linters

The Name column is what you pass to --only/--skip; the Extra column is what you pass to pip install 'sanopy[...]'.

Linter Name Extra Category Detects
Ruff ruff ruff Style PEP 8, imports, code smells
Pylint pylint pylint Style Code quality, conventions
Bandit bandit bandit Security Common security vulnerabilities
MyPy mypy mypy Typing Static type checking
Pyright pyright pyright Typing Advanced type inference
Semgrep semgrep semgrep Semantic Pattern-based analysis
Vulture vulture vulture Dead code Unused variables, functions
Radon radon radon Complexity Cyclomatic complexity
Safety safety safety Dependencies Known vulnerabilities
pip-audit pip-audit pip-audit Dependencies Known vulnerabilities in dependency tree

Semgrep is the one linter that cannot be run as python -m semgrep, so it must be reachable as a console script (step 1 or 2 above).

Configuration File

The .sanopy.toml file controls linter defaults for the current project.

  • Manual workflow: run sanopy init and answer prompts.
  • CI/AI workflow: run sanopy init --only ... --skip ... in scripts.
  • The file is required — sanopy scan exits 2 if it is missing.
  • Diagnostics (missing config, missing linters, scan failures) go to stderr, so stdout stays a valid JSON document in machine mode.
[linters]
only_linters = []
skip_linters = []

[linters.pylint]
disable = ["duplicate-code", "too-many-locals"]

[linters.bandit]
skips = []

[linters.ruff]
select = ["E", "F", "W", "I", "N", "UP", "B", "A", "C4", "SIM", "PTH"]
ignore = []

[safety]
ignore_cves = ["CVE-2026-0994"]

[pip-audit]
ignore_vulns = ["PYSEC-2026-3482"]

The [safety] section lists CVE IDs that the Safety linter should suppress. By default a small set of known-unresolvable CVEs is ignored; set ignore_cves = [] to disable all suppressions.

The [pip-audit] section lists vulnerability IDs (or aliases) that the pip-audit linter should suppress, matched by primary ID or alias.

Both suppression lists replace the built-in defaults rather than adding to them, so whatever you write is exactly what gets suppressed. sanopy init seeds the file with the defaults so you can see and edit them.

The optional [linters.<name>] sections provide the configuration that Sanopy passes to linters shipping bundled defaults (pylint, bandit, and ruff). A nested [linters.<name>.test] table overrides the settings used for test code. Sections and keys you omit fall back to the bundled defaults; a freshly generated .sanopy.toml materializes all of them so they are visible and editable.

Example CI step:

steps:
  - name: Configure Sanopy
    run: sanopy init --only ruff,mypy --skip bandit
  - name: Run scan
    run: sanopy scan src/ tests/

Troubleshooting

No .sanopy.toml found. — Run sanopy init in the project root. scan never creates the file for you.

Missing linters: ... — The named linters are not installed in the environment Sanopy runs from. Install them with the suggested command (pip install 'sanopy[ruff,mypy]'), or re-run sanopy init, which offers to install whatever is missing.

No linters selected. — Your --only/--skip flags, or the only_linters/skip_linters values in .sanopy.toml, cancel out or name a linter that does not exist. The message lists the valid names.

Empty or malformed JSON on stdout — Sanopy writes only the JSON document to stdout; everything else goes to stderr. If you are capturing output, redirect the two separately: sanopy scan src/ > out.json.

Development

Clone the repo and install dependencies, including every linter extra:

git clone https://github.com/lpozo/sanopy.git
cd sanopy
uv sync --dev --extra all

The linters are optional extras, so a plain uv sync leaves them out and the self-scan below will not run.

Run the checks:

uv run pytest                        # test suite
uv run ruff check .                  # lint
uv run ruff format --check .         # formatting
uv run mypy src tests                # type check
uv run pyright src tests             # type check
uv run radon cc -n C src tests -s    # complexity
uv run sanopy scan src tests         # dogfood: must report 0 findings

See AGENTS.md for architecture notes and repo conventions.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

sanopy-0.2.0.tar.gz (181.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

sanopy-0.2.0-py3-none-any.whl (45.9 kB view details)

Uploaded Python 3

File details

Details for the file sanopy-0.2.0.tar.gz.

File metadata

  • Download URL: sanopy-0.2.0.tar.gz
  • Upload date:
  • Size: 181.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for sanopy-0.2.0.tar.gz
Algorithm Hash digest
SHA256 075fcb62e41cf8e248f8a08d85631d91230c58d1cdb093ee3fbf8280189ff0c0
MD5 5f47bd3e149a1cda322399fca563b82d
BLAKE2b-256 fe4d05f30505d802c1771291ede29f01ff4d412fbd7c2d2350e90d8e130ec388

See more details on using hashes here.

Provenance

The following attestation bundles were made for sanopy-0.2.0.tar.gz:

Publisher: publish.yml on lpozo/sanopy

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file sanopy-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: sanopy-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 45.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for sanopy-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 715356da51caf6093c4fb09de76e8eea09c03d0e7be24f52f55013c3205a1a4b
MD5 7e8fec9fd85c043f5dcd98f96e62ebc5
BLAKE2b-256 0ead200b8e7aa7e3c705e8a65dfc1af3afa3d93b2820c0ed922a3fab6a243512

See more details on using hashes here.

Provenance

The following attestation bundles were made for sanopy-0.2.0-py3-none-any.whl:

Publisher: publish.yml on lpozo/sanopy

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

0.2.2

2 files

0.2.1

2 files

This release

0.2.0 This release

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page