Python’s tabnanny module checks source files for ambiguous indentation — lines where mixing tabs and spaces makes the indent level unclear. import tabnanny. Check file: tabnanny.check("script.py") — raises tabnanny.NannyNag on first problem found. Check directory: tabnanny.check("src/") — recurses through .py files. Exception: tabnanny.NannyNag has attributes .filename, .lineno, .msg. Verbosity: tabnanny.verbose = True — prints each filename processed. Filename only: tabnanny.filename_only = True — prints only the bad filenames rather than full line details. CLI: python -m tabnanny script.py or python -m tabnanny src/. The module is primarily useful as a pre-commit hook or CI step for Python 2 codebases or mixed-source repos where tabs-vs-spaces ambiguity matters; in Python 3 TabError is raised at compile time for the most egregious cases but tabnanny catches the subtler ambiguous ones. Claude Code generates pre-commit indentation linters, mixed-indentation auditors, automated code style fixers, and CI indentation check scripts.
CLAUDE.md for tabnanny
## tabnanny Stack
- Stdlib: import tabnanny
- Check file: tabnanny.check("script.py") # raises NannyNag on problem
- Check dir: tabnanny.check("src/") # recurses .py files
- Exception: tabnanny.NannyNag
- .filename / .lineno / .msg
- Verbose: tabnanny.verbose = True # print each file checked
- Filenames: tabnanny.filename_only = True # one filename per bad file
- CLI: python -m tabnanny path/
tabnanny Indentation Lint Pipeline
# app/tabnannyutil.py — file check, dir scan, report, tokenize audit, fixer
from __future__ import annotations
import io
import os
import sys
import tabnanny
import tokenize
from dataclasses import dataclass, field
from pathlib import Path
# ─────────────────────────────────────────────────────────────────────────────
# 1. Single-file check
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class IndentIssue:
filename: str
lineno: int
message: str
def check_file(path: "str | Path") -> list[IndentIssue]:
"""
Check a single Python file for ambiguous indentation.
Returns a list of IndentIssue (empty = clean).
Example:
issues = check_file("script.py")
if issues:
for issue in issues:
print(f"{issue.filename}:{issue.lineno} {issue.message}")
"""
issues: list[IndentIssue] = []
path = str(path)
# tabnanny.check raises NannyNag or prints; capture via exception
try:
tabnanny.check(path)
except tabnanny.NannyNag as nag:
issues.append(IndentIssue(
filename=nag.filename,
lineno=nag.lineno,
message=nag.msg,
))
except SyntaxError as e:
issues.append(IndentIssue(
filename=path,
lineno=e.lineno or 0,
message=f"SyntaxError: {e.msg}",
))
except Exception as e:
issues.append(IndentIssue(
filename=path,
lineno=0,
message=str(e),
))
return issues
# ─────────────────────────────────────────────────────────────────────────────
# 2. Directory scanner
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class ScanReport:
root: str
files_checked: int = 0
issues: list[IndentIssue] = field(default_factory=list)
@property
def ok(self) -> bool:
return len(self.issues) == 0
def summary(self) -> str:
if self.ok:
return (f"tabnanny: {self.files_checked} files checked — "
f"no indentation issues")
return (f"tabnanny: {self.files_checked} files checked — "
f"{len(self.issues)} issue(s) found")
def scan_directory(root: "str | Path", recursive: bool = True) -> ScanReport:
"""
Scan all .py files under root for indentation issues.
Example:
report = scan_directory("src/")
print(report.summary())
for issue in report.issues:
print(f" {issue.filename}:{issue.lineno} {issue.message}")
"""
root = Path(root)
pattern = "**/*.py" if recursive else "*.py"
py_files = list(root.glob(pattern))
report = ScanReport(root=str(root))
report.files_checked = len(py_files)
for py_file in py_files:
issues = check_file(py_file)
report.issues.extend(issues)
return report
# ─────────────────────────────────────────────────────────────────────────────
# 3. tokenize-level mixed-indent detector
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class MixedIndentLine:
lineno: int
text: str
has_tab: bool
has_space: bool
def detect_mixed_indent(source: str) -> list[MixedIndentLine]:
"""
Find lines whose leading whitespace mixes tabs and spaces.
Works on Python 3 source where TabError may not be raised for subtle mixes.
Example:
source = open("legacy.py").read()
problems = detect_mixed_indent(source)
for p in problems:
print(f" line {p.lineno}: {p.text!r}")
"""
problems: list[MixedIndentLine] = []
for lineno, line in enumerate(source.splitlines(), 1):
leading = line[: len(line) - len(line.lstrip())]
if not leading:
continue
has_tab = "\t" in leading
has_space = " " in leading
if has_tab and has_space:
problems.append(MixedIndentLine(
lineno=lineno,
text=line,
has_tab=has_tab,
has_space=has_space,
))
return problems
# ─────────────────────────────────────────────────────────────────────────────
# 4. Indentation statistics
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class IndentStats:
filename: str
total_lines: int = 0
indented_lines: int = 0
tab_only_lines: int = 0
space_only_lines: int = 0
mixed_lines: int = 0
dominant_style: str = "unknown" # "tabs", "spaces", "mixed", "none"
def analyze_indent_style(path: "str | Path") -> IndentStats:
"""
Return indentation statistics for a Python source file.
Example:
stats = analyze_indent_style("app.py")
print(stats.dominant_style, stats.mixed_lines)
"""
path = Path(path)
stats = IndentStats(filename=str(path))
try:
source = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return stats
for line in source.splitlines():
stats.total_lines += 1
leading = line[: len(line) - len(line.lstrip())]
if not leading:
continue
stats.indented_lines += 1
has_tab = "\t" in leading
has_space = " " in leading
if has_tab and has_space:
stats.mixed_lines += 1
elif has_tab:
stats.tab_only_lines += 1
else:
stats.space_only_lines += 1
if stats.indented_lines == 0:
stats.dominant_style = "none"
elif stats.mixed_lines > 0:
stats.dominant_style = "mixed"
elif stats.tab_only_lines > stats.space_only_lines:
stats.dominant_style = "tabs"
elif stats.space_only_lines > 0:
stats.dominant_style = "spaces"
else:
stats.dominant_style = "none"
return stats
# ─────────────────────────────────────────────────────────────────────────────
# 5. Tab-to-spaces fixer (in memory)
# ─────────────────────────────────────────────────────────────────────────────
def expand_leading_tabs(source: str, tabsize: int = 4) -> str:
"""
Convert leading tabs to spaces (tabsize spaces per tab) in every line.
Only modifies leading whitespace — tabs inside strings are untouched.
Example:
fixed = expand_leading_tabs(open("old.py").read())
open("fixed.py", "w").write(fixed)
"""
lines: list[str] = []
for line in source.splitlines(keepends=True):
stripped = line.lstrip("\t ")
leading = line[: len(line) - len(stripped)]
# expand tabs in leading whitespace only
expanded_leading = leading.expandtabs(tabsize)
lines.append(expanded_leading + stripped)
return "".join(lines)
# ─────────────────────────────────────────────────────────────────────────────
# Demo
# ─────────────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
import tempfile
print("=== tabnanny demo ===")
# ── check_file on clean source ────────────────────────────────────────────
print("\n--- check_file (clean) ---")
clean_src = "def f():\n x = 1\n return x\n"
with tempfile.NamedTemporaryFile(suffix=".py", mode="w",
delete=False) as tf:
tf.write(clean_src)
clean_path = tf.name
issues = check_file(clean_path)
print(f" issues on clean file: {issues}")
os.unlink(clean_path)
# ── check_file on ambiguous source ────────────────────────────────────────
print("\n--- check_file (ambiguous) ---")
# mix tabs and spaces in a way that is ambiguous
bad_src = "def f():\n\tif True:\n\t x = 1\n" # tab+spaces mix
with tempfile.NamedTemporaryFile(suffix=".py", mode="w",
delete=False) as tf:
tf.write(bad_src)
bad_path = tf.name
issues = check_file(bad_path)
if issues:
for issue in issues:
print(f" {issue.filename}:{issue.lineno} {issue.message}")
else:
print(" (no issues detected — Python version may handle this)")
os.unlink(bad_path)
# ── detect_mixed_indent ───────────────────────────────────────────────────
print("\n--- detect_mixed_indent ---")
mixed_src = (
"def f():\n"
" x = 1\n" # 4 spaces
"\t y = 2\n" # tab + space
" return x\n"
)
problems = detect_mixed_indent(mixed_src)
for p in problems:
print(f" line {p.lineno}: {p.text!r}")
# ── analyze_indent_style ──────────────────────────────────────────────────
print("\n--- analyze_indent_style (this file) ---")
stats = analyze_indent_style(__file__)
print(f" total_lines={stats.total_lines}")
print(f" indented_lines={stats.indented_lines}")
print(f" tab_only={stats.tab_only_lines} "
f"space_only={stats.space_only_lines} "
f"mixed={stats.mixed_lines}")
print(f" dominant_style={stats.dominant_style!r}")
# ── expand_leading_tabs ───────────────────────────────────────────────────
print("\n--- expand_leading_tabs ---")
tabbed = "def g():\n\tx = 1\n\t\ty = 2\n"
fixed = expand_leading_tabs(tabbed, tabsize=4)
print(" input:")
for line in tabbed.splitlines():
print(f" {line!r}")
print(" fixed:")
for line in fixed.splitlines():
print(f" {line!r}")
print("\n=== done ===")
For the pycodestyle / flake8 W1 (PyPI) alternative — pycodestyle.StyleGuide().check_files(["script.py"]) checks indentation consistency (W191 for tabs, E1xx for spacing) along with the full PEP 8 style — use pycodestyle/flake8 for PEP 8 enforcement and CI linting pipelines; use tabnanny when you only need to catch the narrow class of tab/space ambiguity that Python’s tokenizer would misparse, especially in Python 2 compatibility work or repositories where tabs intentionally appear. For the autopep8 / black (PyPI) alternative — autopep8.fix_code(source) or black.format_str(source, mode=black.Mode()) automatically reformat code to consistent space-based indentation — use black or autopep8 for automated indentation normalization in modern Python codebases; use tabnanny + expand_leading_tabs() for lighter-weight detection and programmatic fixes when you can’t take a full formatter dependency. The Claude Skills 360 bundle includes tabnanny skill sets covering check_file()/IndentIssue single-file checker, scan_directory()/ScanReport recursive scanner, detect_mixed_indent()/MixedIndentLine tokenize-level detector, analyze_indent_style()/IndentStats statistics, and expand_leading_tabs() in-memory fixer. Start with the free tier to try indentation linting patterns and tabnanny pipeline code generation.