Python code smell detector -- 83 refactoring patterns, 56 AST checks, zero dependencies
Project description
smellcheck
Python Code Smell Detector & Refactoring Guide
83 refactoring patterns · 56 automated AST checks · zero dependencies
smellcheck is a Python code smell detector and refactoring catalog. It works as a pip-installable CLI, GitHub Action, pre-commit hook, or Agent Skills plugin for AI coding assistants.
No dependencies. Pure Python stdlib (ast, pathlib, json). Runs anywhere Python 3.10+ runs.
What are code smells? Code smells are surface-level patterns in source code that hint at deeper design problems — not bugs, but structural weaknesses that make code harder to maintain, extend, or understand. Learn more →
Installation
pip
pip install smellcheck
smellcheck src/
smellcheck myfile.py --format json
smellcheck src/ --min-severity warning --fail-on warning
Also available as a GitHub Action, pre-commit hook, SARIF/Code Scanning integration, Agent Skills plugin, and Cursor native plugin for Claude Code, Cursor, Copilot, Gemini CLI, and more.
Usage
# Scan a directory
smellcheck src/
# Scan multiple files
smellcheck file1.py file2.py
# JSON output
smellcheck src/ --format json
# GitHub Actions annotations
smellcheck src/ --format github
# SARIF output (for GitHub Code Scanning)
smellcheck src/ --format sarif > results.sarif
# JUnit XML output (for Jenkins, GitLab, CircleCI, Azure DevOps)
smellcheck src/ --format junit > smellcheck-results.xml
# GitLab CodeClimate output (for MR code quality widget)
smellcheck src/ --format gitlab > gl-code-quality-report.json
# Filter by severity
smellcheck src/ --min-severity warning
# Control exit code
smellcheck src/ --fail-on warning # exit 1 on warning or error
smellcheck src/ --fail-on info # exit 1 on any finding
# Run only specific checks
smellcheck src/ --select SC101,SC701,SC210
# Skip specific checks
smellcheck src/ --ignore SC601,SC202
# Module execution
python3 -m smellcheck src/
# Generate a baseline of current findings
smellcheck src/ --generate-baseline > .smellcheck-baseline.json
# Only report findings not in the baseline
smellcheck src/ --baseline .smellcheck-baseline.json
# Disable caching for a fresh scan
smellcheck src/ --no-cache
# Use a custom cache directory
smellcheck src/ --cache-dir .my-cache
# Clear cached results
smellcheck --clear-cache
# Show documentation for a rule (description + before/after example)
smellcheck --explain SC701
# List all rules in a family
smellcheck --explain SC4
# List all rules grouped by family
smellcheck --explain all
Configuration
smellcheck reads [tool.smellcheck] from the nearest pyproject.toml:
[tool.smellcheck]
extends = "base.toml" # inherit from a shared config file
select = ["SC101", "SC201", "SC701"] # only run these checks (default: all)
ignore = ["SC601", "SC202"] # skip these checks
per-file-ignores = {"tests/*" = ["SC201", "SC206"]} # per-path overrides
fail-on = "warning" # override default fail-on
format = "text" # override default format
baseline = ".smellcheck-baseline.json" # suppress known findings
cache = true # enable file-level caching (default: true)
cache-dir = ".smellcheck-cache" # cache directory (default: .smellcheck-cache)
CLI flags override config values.
Config inheritance (extends)
Use extends to inherit settings from a shared base config:
# base.toml — shared across repos
[tool.smellcheck]
ignore = ["SC601"]
fail-on = "warning"
# pyproject.toml — project overrides
[tool.smellcheck]
extends = "base.toml"
ignore = ["SC202"] # adds to base; final ignore = ["SC601", "SC202"]
Multiple bases are supported — later entries override earlier ones for scalar values, while ignore lists are unioned and per-file-ignores are deep-merged:
extends = ["base.toml", "strict.toml"]
Paths are relative to the file containing the extends key. Chains are resolved recursively (up to 5 levels deep).
Suppression
Per-line
Add # noqa: SC701 to a line to suppress that check on that line:
def foo(x=[]): # noqa: SC701
return x
Use # noqa (no codes) to suppress all findings on that line. Multiple codes: # noqa: SC601,SC202
Block-level
Disable specific checks for a range of lines with # smellcheck: disable / # smellcheck: enable:
# smellcheck: disable SC301, SC305
class LegacyGodObject:
"""This class is intentionally large for backward compatibility."""
def method_one(self):
self._temp = compute() # SC305 suppressed by block directive
def method_two(self):
use(self._temp)
# smellcheck: enable SC301, SC305
Disable all checks for a range:
# smellcheck: disable-all
# ... everything in this range is suppressed ...
# smellcheck: enable-all
File-level
Suppress checks for an entire file (place at top of file):
# smellcheck: disable-file SC301, SC305
Use # smellcheck: disable-file (no codes) to suppress all checks for the entire file.
Scope rules
disable/enableapply from that line to the matchingenable(or end of file if no match)disable-all/enable-allwork the same way but for all checks at oncedisable-fileapplies to the entire file- Per-line
# noqastill works alongside block directives - Block directives do not affect cross-file findings (use
per-file-ignoresin config instead)
Baseline
For large codebases, you can adopt smellcheck incrementally using a baseline file. The baseline records fingerprints of existing findings so only new issues are reported.
# 1. Generate a baseline from the current state
smellcheck src/ --generate-baseline > .smellcheck-baseline.json
# 2. Run with the baseline — only new findings are reported
smellcheck src/ --baseline .smellcheck-baseline.json
# 3. Or set it in pyproject.toml so every run uses it automatically
Fingerprints are resilient to line-number changes — renaming or moving code around won't break the baseline. When you fix a baselined smell, its entry is silently ignored.
--generate-baseline and --baseline are mutually exclusive.
Diff-Aware Scanning
Focus on files you actually changed — skip the rest of the codebase:
# Only scan files changed vs. main branch
smellcheck src/ --diff main
# Only scan files changed in the last commit
smellcheck src/ --diff HEAD~1
# Only scan uncommitted changes (shorthand for --diff HEAD)
smellcheck src/ --changed-only
In CI, this keeps PR feedback fast and relevant:
- uses: cheickmec/smellcheck@v0
with:
diff: origin/main
fail-on: warning
Cross-file checks (cyclic imports, shotgun surgery, etc.) run on the changed file set only. This is best-effort — for full cross-file accuracy, run without --diff.
--diff and --generate-baseline are mutually exclusive. --diff composes with all other flags (--baseline, --format, --fail-on, --select, --ignore).
Caching
smellcheck caches per-file analysis results in .smellcheck-cache/ to skip unchanged files on repeated scans. This is especially useful for pre-commit hooks and editor integrations.
Cache entries are keyed by file content hash, config hash, and smellcheck version — any change invalidates the relevant entry. Cross-file analysis (cyclic imports, duplicate code, etc.) always re-runs since it depends on the full file set.
# Caching is enabled by default — just run normally
smellcheck src/
# Disable caching for a guaranteed fresh scan
smellcheck src/ --no-cache
# Use a custom cache directory
smellcheck src/ --cache-dir /tmp/sc-cache
# Clear all cached results
smellcheck --clear-cache
Old cache entries are not automatically evicted. Run smellcheck --clear-cache periodically or after upgrading to reclaim disk space.
Add .smellcheck-cache/ to your .gitignore. You can also configure caching in pyproject.toml:
[tool.smellcheck]
cache = false # disable caching
cache-dir = ".smellcheck-cache" # custom cache directory
Features
- 56 automated smell checks -- per-file AST analysis, cross-file dependency analysis, and OO metrics
- 83 refactoring patterns -- numbered catalog with before/after examples, trade-offs, and severity levels
- Zero dependencies -- stdlib-only, runs on any Python 3.10+ installation
- Multiple output formats -- text (terminal), JSON (machine-readable), GitHub annotations (CI), SARIF 2.1.0 (Code Scanning), JUnit XML (Jenkins/GitLab/CircleCI), GitLab CodeClimate (MR quality widget)
- Configurable -- pyproject.toml config, inline suppression, CLI overrides
- Baseline support -- adopt incrementally by suppressing existing findings and only failing on new ones
- File-level caching -- content-hash based caching skips unchanged files for fast repeated scans
- Multiple distribution channels -- pip, GitHub Action, pre-commit, Agent Skills (full list)
Detected Patterns
Every rule is identified by an SC code (e.g. SC701). Use SC codes in --select, --ignore, and # noqa comments.
Per-File (41 checks)
| SC Code | Pattern | Severity |
|---|---|---|
| SC101 | Setters (half-built objects) | warning |
| SC102 | UPPER_CASE without Final | info |
| SC103 | Unprotected public attributes | info |
| SC104 | Half-built objects (init assigns None) | warning |
| SC105 | Boolean flag parameters | info |
| SC106 | Global mutable state | info |
| SC107 | Sequential IDs | info |
| SC201 | Long functions (>20 lines) | warning |
| SC202 | Generic names (data, result, tmp) | info |
| SC203 | input() in business logic | warning |
| SC204 | Functions returning None or list | info |
| SC205 | Excessive decorators (>3) | info |
| SC206 | Too many parameters (>5) | warning |
| SC207 | CQS violation (query + modify) | info |
| SC208 | Unused function parameters | warning |
| SC209 | Long lambda (>60 chars) | info |
| SC210 | Cyclomatic complexity (>10) | warning |
| SC301 | Extract class (too many methods) | info |
| SC302 | isinstance chains | warning |
| SC303 | Singleton pattern | warning |
| SC304 | Dataclass candidate | info |
| SC305 | Sequential tuple indexing | info |
| SC306 | Lazy class (<2 methods) | info |
| SC307 | Temporary fields | info |
| SC401 | Dead code after return | warning |
| SC402 | Deep nesting (>4 levels) | warning |
| SC403 | Loop + append pattern | info |
| SC404 | Complex boolean expressions | warning |
| SC405 | Boolean control flag in loop | info |
| SC406 | Complex comprehension (>2 generators) | info |
| SC407 | Missing default else branch | info |
| SC501 | Error codes instead of exceptions | warning |
| SC502 | Law of Demeter violation | info |
| SC601 | Magic numbers | info |
| SC602 | Bare except / unused exception variable | error |
| SC603 | String concatenation for multiline | info |
| SC604 | contextlib candidate | info |
| SC605 | Empty catch block | warning |
| SC701 | Mutable default arguments | error |
| SC702 | open() without context manager | warning |
| SC703 | Blocking calls in async functions | warning |
Cross-File (10 checks)
| SC Code | Pattern | Description |
|---|---|---|
| SC211 | Feature envy | Function accesses external attributes more than own |
| SC308 | Deep inheritance | Inheritance depth >4 |
| SC309 | Wide hierarchy | >5 direct subclasses |
| SC503 | Cyclic imports | DFS cycle detection |
| SC504 | God modules | >500 lines or >30 top-level definitions |
| SC505 | Shotgun surgery | Function called from >5 different files |
| SC506 | Inappropriate intimacy | >3 bidirectional class references between files |
| SC507 | Speculative generality | Abstract class with no concrete subclasses |
| SC508 | Unstable dependency | Stable module depends on unstable module |
| SC606 | Duplicate functions | AST-normalized hashing across files |
OO Metrics (5 checks)
| SC Code | Metric | Threshold |
|---|---|---|
| SC801 | Lack of Cohesion of Methods | >0.8 |
| SC802 | Coupling Between Objects | >8 |
| SC803 | Excessive Fan-Out | >15 |
| SC804 | Response for a Class | >20 |
| SC805 | Middle Man (delegation ratio) | >50% |
Refactoring Reference Files
Each pattern includes a description, before/after code examples, and trade-offs:
| File | Patterns |
|---|---|
state.md |
Immutability, setters, attributes (SC101–SC107) |
functions.md |
Extraction, naming, parameters, CQS (SC201–SC210) |
types.md |
Classes, reification, polymorphism, nulls (SC301–SC309) |
control.md |
Guards, pipelines, conditionals, phases (SC401–SC407) |
architecture.md |
DI, singletons, exceptions, delegates (SC501–SC508) |
hygiene.md |
Constants, dead code, comments, style (SC601–SC606) |
idioms.md |
Context managers, generators, unpacking, async (SC701–SC703) |
metrics.md |
OO metrics: cohesion, coupling, fan-out, response, delegation (SC801–SC805) |
How It Compares
| Feature | smellcheck | PyExamine | SMART-Dal | Pyscent |
|---|---|---|---|---|
| Automated detections | 56 | 49 | 31 | 11 |
| Refactoring guidance | 83 patterns | None | None | None |
| Dependencies | 0 (stdlib) | pylint, radon | DesigniteJava | pylint, radon, cohesion |
| Python-specific idioms | Yes | No | No | No |
| Cross-file analysis | Yes | Limited | Yes | No |
| OO metrics | 5 | 19 | 0 | 1 |
| Distribution channels | 4 (pip, GHA, pre-commit, Agent Skills) | 1 | 1 | 1 |
Contributing
Contributions welcome — see CONTRIBUTING.md for the full guide. The core detector is src/smellcheck/detector.py; add new checks by extending the SmellDetector AST visitor class and adding a cross-file analysis function if needed.
# Development setup
git clone https://github.com/cheickmec/smellcheck.git
cd smellcheck
pip install -e .
pip install pytest
# Run tests
pytest tests/ -v
# Self-check
smellcheck src/smellcheck/
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file smellcheck-0.3.7.tar.gz.
File metadata
- Download URL: smellcheck-0.3.7.tar.gz
- Upload date:
- Size: 1.7 MB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8bbd60821d246dd371694a57cd83e7eefd7c707817ac596a4b7a9f4bedebe0df
|
|
| MD5 |
2b6bdb9a636c17cb63a43c1c0de92026
|
|
| BLAKE2b-256 |
1a78aed76ad0fb2233f5a67455c176ee7a9e6557a65cae27aa7cca488bffa323
|
Provenance
The following attestation bundles were made for smellcheck-0.3.7.tar.gz:
Publisher:
release-please.yml on cheickmec/smellcheck
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
smellcheck-0.3.7.tar.gz -
Subject digest:
8bbd60821d246dd371694a57cd83e7eefd7c707817ac596a4b7a9f4bedebe0df - Sigstore transparency entry: 974564316
- Sigstore integration time:
-
Permalink:
cheickmec/smellcheck@09216530378e2a990782915914ea7e1c830227e0 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/cheickmec
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release-please.yml@09216530378e2a990782915914ea7e1c830227e0 -
Trigger Event:
push
-
Statement type:
File details
Details for the file smellcheck-0.3.7-py3-none-any.whl.
File metadata
- Download URL: smellcheck-0.3.7-py3-none-any.whl
- Upload date:
- Size: 50.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b1869b057b2081825049a0860c50ce7240d3ed41403822f7f9ac1bb2d9f55037
|
|
| MD5 |
33640d9f1f4da3fd5cec07865e5b408e
|
|
| BLAKE2b-256 |
8604ab98e87a6469cd7c64bd219745c249b9fdc91edcd57f23c22695f3557966
|
Provenance
The following attestation bundles were made for smellcheck-0.3.7-py3-none-any.whl:
Publisher:
release-please.yml on cheickmec/smellcheck
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
smellcheck-0.3.7-py3-none-any.whl -
Subject digest:
b1869b057b2081825049a0860c50ce7240d3ed41403822f7f9ac1bb2d9f55037 - Sigstore transparency entry: 974564340
- Sigstore integration time:
-
Permalink:
cheickmec/smellcheck@09216530378e2a990782915914ea7e1c830227e0 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/cheickmec
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release-please.yml@09216530378e2a990782915914ea7e1c830227e0 -
Trigger Event:
push
-
Statement type: