Skip to content

Usage Guide

This guide covers everything you need to know to effectively use complexipy in your Python projects.

Installation

pip install complexipy
uv add complexipy
poetry add complexipy

Command Line Usage

Basic Analysis

Analyze your entire project:

complexipy .

Analyze specific files or directories:

complexipy src/
complexipy src/main.py
complexipy src/ tests/

Setting Complexity Threshold

The default threshold is 15. Functions exceeding this value will be highlighted:

complexipy . --max-complexity-allowed 10

Filtering Results

Show only functions that exceed the threshold:

complexipy . --failed

Show only the top N most complex functions across the analyzed files:

complexipy . --top 10

Show deterministic refactor suggestions for failing functions:

complexipy . --failed --suggest-refactors

When --top is set, results are globally re-sorted by complexity descending before truncation.

Suppress analysis output (useful for CI pipelines):

complexipy . --quiet

Sorting Results

Sort by complexity score:

complexipy . --sort asc   # Ascending (default)
complexipy . --sort desc  # Descending
complexipy . --sort file_name  # Alphabetically by file name

Excluding Files and Directories

Exclude specific paths from analysis:

# Exclude a directory recursively
complexipy . --exclude "tests/**"

# Exclude multiple directories recursively
complexipy . --exclude "tests/**" --exclude "migrations/**" --exclude "build/**"

# Exclude specific files
complexipy . --exclude "src/legacy/old_code.py"

How exclusion works

  • Exclusions are glob patterns evaluated relative to each provided root path
  • Use directory/** to exclude a directory recursively
  • Use an exact relative file path, such as src/legacy/old_code.py, to exclude one file
  • Gitignore rules are still respected during file discovery

Output Formats

Save results to JSON, CSV, GitLab Code Quality, or SARIF:

# JSON output (saved to complexipy-results.json)
complexipy . --output-format json

# CSV output (saved to complexipy-results.csv)
complexipy . --output-format csv

# Both
complexipy . --output-format json --output-format csv

# Explicit destination for a single format
complexipy . --output-format gitlab --output complexipy-code-quality.json

# Multiple formats written into a directory
complexipy . --output-format json --output-format sarif --output reports/

# GitLab Code Quality report
complexipy . --output-format gitlab

# SARIF output
complexipy . --output-format sarif

# Add --suggest-refactors to any of the above to also carry refactor rule
# findings (C001, C007, ...) as their own SARIF/GitLab rule IDs, alongside
# the always-present cognitive-complexity findings.
complexipy . --output-format sarif --suggest-refactors

JSON, SARIF, and GitLab Code Quality all follow the same rule: refactor plan data (refactor_plans in JSON, per-rule findings in SARIF/GitLab) is only emitted when --suggest-refactors is also passed. Without it, only the cognitive-complexity threshold findings appear -- matching the CLI's rich output, which also hides refactor suggestions unless the flag is set.

Complexity Diff

Compare current results against any git reference:

complexipy . --diff HEAD~1
complexipy . --diff main
complexipy src/ --max-complexity-allowed 10 --diff HEAD~1

By default --diff enforces the complexity threshold: the run exits with code 1 only when a change breaches the contract relative to --max-complexity-allowed:

  • A new function introduced above the threshold.
  • A modified function whose complexity increased and ends above the threshold (including already-over functions that get worse).

Functions that regress but stay at or below the threshold (e.g. 3 → 4 with --max-complexity-allowed 15) are not failures.

To see the diff visually without affecting the exit code, use --diff-only instead:

complexipy . --diff-only HEAD~1

To compare the staged (git index) content instead of the working tree — "what complexity am I about to commit?" — add --staged:

# Staged changes vs HEAD (the default baseline for --staged)
complexipy . --staged

# Staged changes vs a specific ref
complexipy . --diff main --staged

Like --diff, --staged enforces the threshold against the staged content and fails when a staged change pushes a function above --max-complexity-allowed. Staged deletions produce REMOVED entries and staged additions NEW entries. Use --diff-only with --staged for a visual-only view.

This requires git and a repository-backed path.

Instead of repeating the reference on every call, declare the comparison policy once in a configuration file — see Diff Configuration.

Ratchet Mode

Plain Output

Use plain output when you need one machine-friendly line per function:

complexipy . --plain
complexipy . --plain --top 5
complexipy . --plain --failed -mx 10

Each line is emitted as:

<path> <function> <complexity>

--plain is CLI-only and cannot be combined with --quiet.

Module-Level Script Complexity

Use --check-script to include module-level code in the results as <module>:

complexipy path/to/script.py --check-script
complexipy path/to/script.py --check-script -mx 5

This is useful for scripts with complex top-level control flow outside functions.

Refactor Suggestions

Use --suggest-refactors to print a small, ranked set of deterministic refactor plans next to rich CLI results:

complexipy . --failed --suggest-refactors

Sample output (abbreviated -- the real output also shows a caret-underlined span, the surrounding source, and a documentation link):

      [1] C007 Merge nested if statements
          --> sample.py:4:9
          Category: ◆ Readability | Applicability: * Safe to apply
          Lines 4-6 -> Estimated reduction: -2 complexity (6 -> 4)

          Suggestion: * Safe to apply
          Merge nested conditions into `if item.active and item.ready:`

Plans are based on the Rust AST analysis only; no AI is used and no code is rewritten automatically. Estimated reductions are approximate, ranked, and limited, so treat them as guidance rather than exact future scores. --plain --suggest-refactors keeps plain output unchanged.

JSON Output Structure:

[
    {
        "path": "src",
        "file_name": "main.py",
        "function_name": "process_data",
        "complexity": 6,
        "refactor_plans": [
            {
                "rule_id": "C007",
                "kind": "collapsible_if",
                "title": "Merge nested if statements",
                "line_start": 4,
                "line_end": 6,
                "column_start": 9,
                "current_complexity": 6,
                "estimated_reduction": 2,
                "estimated_complexity_after": 4,
                "category": "Readability",
                "applicability": "MachineApplicable",
                "description": "Merge nested if statements into a single if with combined conditions",
                "explanation": "Nested if statements with a single body can be merged into a single if with combined conditions using 'and'. This reduces nesting and improves readability.",
                "references": [],
                "suggestion": {
                    "replacement": "        if item.active and item.ready:\n            total += item.value",
                    "applicability": "MachineApplicable",
                    "description": "Merge nested conditions into `if item.active and item.ready:`"
                },
                "help": null,
                "doc_url": "https://rohaquinlop.github.io/complexipy/refactoring-rules/#c007-collapsible-if"
            }
        ]
    }
]

JSON output contains one entry per emitted function. The refactor_plans list is only populated when --suggest-refactors is also passed -- otherwise it's [], matching the CLI's rich-output behavior. CSV output is unchanged and does not include plans. Function line ranges are available through the Python API (line_start, line_end), but are not included in the machine-readable CLI JSON/CSV function entries.

Color Output

Control color output:

complexipy . --color auto  # Default: auto-detect terminal support
complexipy . --color yes   # Force colors
complexipy . --color no    # Disable colors

Configuration Files

Configuration Priority

complexipy loads configuration in this order (highest to lowest priority):

  1. Command-line arguments
  2. complexipy.toml
  3. .complexipy.toml
  4. pyproject.toml (under [tool.complexipy])

Example Configurations

paths = ["src", "tests"]
max-complexity-allowed = 10
exclude = ["migrations/**", "build/**"]
snapshot-create = false
snapshot-ignore = false
quiet = false
ignore-complexity = false
failed = false
color = "auto"
sort = "asc"
output-format = ["json", "gitlab"]
output = "reports/"
check-script = false
no-ignore = false
report-ignored = false
[tool.complexipy]
paths = ["src", "tests"]
max-complexity-allowed = 10
exclude = ["migrations/**", "build/**"]
failed = true
sort = "desc"
check-script = true
# Hidden config file for team-specific settings
max-complexity-allowed = 15
exclude = ["venv/**", ".venv/**", "node_modules/**"]

check-script is supported in TOML. --top and --plain are CLI-only flags.

Diff Configuration

The comparison policy can be declared once in the repository instead of passing the same flags on every call. Add a [tool.complexipy.diff] section to the same configuration file:

[diff]
branch = "main"
staged = true
[tool.complexipy.diff]
branch = "main"
staged = true
  • branch sets the default reference for --diff and --diff-only. A plain complexipy . then behaves like complexipy . --diff main, enforcement included. Pass --diff <ref> or --diff-only <ref> to override the reference for a single run.
  • staged enables staged comparison by default, like passing --staged on every call.
  • CLI flags always take precedence over the section values.
  • branch = "" disables the diff for the current repository (opt-out).
  • Resolution order: CLI flag, then the diff section.
  • If the configured branch does not exist in the local clone (for example a fresh or shallow clone), every function reports as NEW and the enforcement still applies. Fetch the branch or pass --diff <ref> with an existing reference instead.

Python API

Analyzing Files

from complexipy import file_complexity

# Analyze a file
result = file_complexity("src/main.py", check_script=True)

print(f"Total complexity: {result.complexity}")

# Analyze without honoring inline ignore comments
result = file_complexity("src/main.py", no_ignore=True)
print(f"File path: {result.path}")

# Iterate over functions
for func in result.functions:
    print(f"{func.name}:")
    print(f"  Complexity: {func.complexity}")
    print(f"  Lines: {func.line_start}-{func.line_end}")

Analyzing Code Strings

from complexipy import code_complexity

# Analyze code snippet
code = """
def calculate_discount(price, customer):
    if customer.is_premium:
        if price > 100:
            return price * 0.8
        else:
            return price * 0.9
    return price
"""

result = code_complexity(code, check_script=True)
print(f"Complexity: {result.complexity}")

# Analyze code string without honoring ignore comments
result = code_complexity(code, no_ignore=True)

for func in result.functions:
    print(f"{func.name}: {func.complexity}")

Comparing Against a Git Reference

compute_diff compares current complexity results against a git reference (commit, tag, or branch) and returns DiffEntry objects — one per function that changed, appeared, or disappeared. has_regressions reports whether any entry breaches a complexity threshold (a REGRESSED or NEW function above max_complexity).

from complexipy import (
    compute_diff,
    has_regressions,
    file_complexity,
    DiffEntry,
    DiffStatus,
)

# Analyze the current state of the files you care about
current = [file_complexity(p) for p in changed_files]

# Compare against a git reference (the working directory is the default cwd)
entries = compute_diff(current, "origin/main")

# Filter for regressions above your threshold
regressions = [
    e
    for e in entries
    if e.status == DiffStatus.REGRESSED and e.new_complexity > 15
]

# Or use the ratchet gate directly (fails on REGRESSED/NEW above threshold)
if has_regressions(entries, 15):
    raise SystemExit("Complexity regressions detected")

DiffEntry exposes file_path, func_name, old_complexity, and new_complexity (either may be None for NEW / REMOVED functions), plus the status and delta properties. status is a DiffStatus member — a str-based enum, so it compares equal to its string value (e.g. DiffStatus.REGRESSED == "REGRESSED").

for e in entries:
    if e.status != DiffStatus.UNCHANGED:
        print(f"{e.file_path}::{e.func_name}: {e.status} {e.delta}")

Practical API Usage

Example: Pre-commit Hook

#!/usr/bin/env python3
"""Check complexity of staged Python files."""
import sys
from pathlib import Path
from complexipy import file_complexity

MAX_COMPLEXITY = 15

def main():
    # Get staged Python files (integrate with git)
    staged_files = get_staged_python_files()

    violations = []
    for filepath in staged_files:
        result = file_complexity(str(filepath))

        for func in result.functions:
            if func.complexity > MAX_COMPLEXITY:
                violations.append({
                    'file': filepath,
                    'function': func.name,
                    'complexity': func.complexity,
                    'line': func.line_start
                })

    if violations:
        print("Complexity violations found:")
        for v in violations:
            print(f"  {v['file']}:{v['line']} - "
                  f"{v['function']} (complexity: {v['complexity']})")
        sys.exit(1)

    print("All functions pass complexity check!")
    sys.exit(0)

if __name__ == "__main__":
    main()

Example: Code Quality Dashboard

from pathlib import Path
from complexipy import file_complexity
import json

def analyze_project(root_path: str):
    """Generate complexity report for entire project."""
    project = Path(root_path)
    results = []

    for py_file in project.rglob("*.py"):
        if "venv" in str(py_file) or ".venv" in str(py_file):
            continue

        try:
            result = file_complexity(str(py_file))
            results.append({
                'file': str(py_file),
                'complexity': result.complexity,
                'functions': [
                    {
                        'name': f.name,
                        'complexity': f.complexity,
                        'line_start': f.line_start,
                        'line_end': f.line_end
                    }
                    for f in result.functions
                ]
            })
        except Exception as e:
            print(f"Error analyzing {py_file}: {e}")

    # Sort by complexity
    results.sort(key=lambda x: x['complexity'], reverse=True)

    # Save report
    with open("complexity-report.json", "w") as f:
        json.dump(results, f, indent=2)

    # Print summary
    total_files = len(results)
    total_complexity = sum(r['complexity'] for r in results)
    avg_complexity = total_complexity / total_files if total_files else 0

    print(f"Analyzed {total_files} files")
    print(f"Total complexity: {total_complexity}")
    print(f"Average complexity: {avg_complexity:.2f}")

    # Top 10 most complex files
    print("\nTop 10 most complex files:")
    for r in results[:10]:
        print(f"  {r['file']}: {r['complexity']}")

if __name__ == "__main__":
    analyze_project("./src")

Snapshot Baselines

Snapshots allow you to adopt complexipy gradually in large, existing codebases.

Creating a Snapshot

complexipy . --snapshot-create --max-complexity-allowed 15

This creates complexipy-snapshot.json in your working directory, recording all functions that currently exceed the threshold.

How Snapshots Work

Once a snapshot exists, complexipy will:

  • Pass: Functions that were already in the snapshot and haven't gotten worse
  • Pass: Functions that improved (automatically removed from snapshot)
  • Fail: New functions that exceed the threshold
  • Fail: Tracked functions that got more complex

Using Snapshots in CI

# .github/workflows/complexity.yml
name: Complexity Check

on: [push, pull_request]

jobs:
    check:
        runs-on: ubuntu-latest
        steps:
            - uses: actions/checkout@v4

            - name: Install complexipy
              run: pip install complexipy

            - name: Check complexity
              run: complexipy . --max-complexity-allowed 15

The snapshot file (complexipy-snapshot.json) should be committed to version control.

Ignoring Snapshots

Temporarily disable snapshot checking:

complexipy . --snapshot-ignore

Use this when:

  • Refactoring multiple files at once
  • Regenerating the baseline
  • Testing different thresholds

Snapshot File Format

[
    {
        "path": "src",
        "file_name": "legacy.py",
        "functions": [
            {
                "name": "old_function",
                "complexity": 23
            }
        ]
    }
]

Snapshots are stored as a JSON array of analyzed files. Each entry contains only functions above the threshold at the time the snapshot was written. The file is rewritten after successful snapshot checks, so improved functions are removed automatically. Updates only touch the files analyzed in the run — entries for files outside the analysis are preserved, so running on a subset of files (for example through a pre-commit hook) never shrinks the baseline. Snapshots created by older complexipy versions may need to be regenerated with --snapshot-create.

Inline Ignores

Suppress complexity warnings for specific functions using the # complexipy: ignore comment:

def complex_legacy_function():  # complexipy: ignore
    # Complex logic that can't be refactored yet
    pass

# Or with a reason
def another_complex_function():  # complexipy: ignore (technical debt: issue #123)
    pass

The ignore comment can also be placed on the line above the function definition:

# complexipy: ignore
def complex_function():
    pass

Deprecated Syntax

The # noqa: complexipy syntax is deprecated and will be removed in a future version. Please migrate to # complexipy: ignore instead.

Why? Tools like yesqa automatically strip # noqa comments that aren't recognized by flake8, which would silently remove your complexipy suppressions. The new syntax avoids this conflict entirely.

Use Sparingly

Inline ignores should be temporary. Document why the complexity is necessary and track technical debt.

Disabling Inline Ignores

Use --no-ignore to disregard all inline ignore comments and analyze every function:

complexipy . --no-ignore

Functions previously suppressed by # complexipy: ignore or # noqa: complexipy will be analyzed normally and may fail the threshold.

Reporting Ignored Functions

Use --report-ignored to list every location where an ignore comment suppresses a function:

# List ignored functions
complexipy . --report-ignored

# Combine with --no-ignore to report while analyzing everything
complexipy . --report-ignored --no-ignore

The output format is path:line # comment-text. When --output-format json is also active, ignored locations are exported to complexipy-ignored.json. The report prints even under --quiet.

Both flags are also available in the Python API via no_ignore=True:

from complexipy import file_complexity, code_complexity

# Analyze without honoring ignore comments
result = file_complexity("app.py", no_ignore=True)

To programmatically collect ignored locations, use collect_all_ignored_locations():

from complexipy import collect_all_ignored_locations

locations, failed = collect_all_ignored_locations(
    paths=["src"],
    exclude=["tests/"],
)
for loc in locations:
    print(f"{loc.path}:{loc.line}  {loc.comment}")

Removing Stale Ignore Comments

An ignore comment is only necessary while the suppressed function's complexity exceeds --max-complexity-allowed. Once the function's complexity drops to or below the limit, the comment is stale and can be removed. complexipy detects this automatically on every run and reports the locations to clean up:

complexipy .

Example output:

The following ignore comment(s) are no longer necessary (complexity is within the allowed limit) and can be removed:
src/legacy.py:42  function=parse_legacy_config complexity=8  # complexipy: ignore

The report is purely informational: it never affects the exit code, and it is suppressed under --quiet. The same detection is available programmatically via collect_removable_ignored_locations():

from complexipy import collect_removable_ignored_locations

removable, failed = collect_removable_ignored_locations(
    paths=["src"],
    exclude=["tests/"],
    max_complexity_allowed=15,
)
for rem in removable:
    print(f"{rem.path}:{rem.line}  function={rem.function} complexity={rem.complexity}")

CI/CD Integration

GitHub Actions

Use the official action:

- uses: rohaquinlop/complexipy-action@v2
  with:
      paths: src tests
      max_complexity_allowed: 15
      output_format: json

Or run directly:

- name: Check complexity
  run: |
      pip install complexipy
      complexipy . --max-complexity-allowed 15

Pre-commit Hook

Add to .pre-commit-config.yaml:

repos:
    - repo: https://github.com/rohaquinlop/complexipy-pre-commit
      rev: v5.1.0
      hooks:
          - id: complexipy
            args: [--max-complexity-allowed=15]

GitLab CI

.complexipy_code_quality:
    image: python:3.11
    script:
        - pip install complexipy
        - complexipy . --output-format gitlab --output complexipy-code-quality.json --ignore-complexity --max-complexity-allowed 15
    artifacts:
        when: always
        reports:
            codequality: complexipy-code-quality.json

complexity:
    extends: .complexipy_code_quality
    rules:
        - if: $CI_PIPELINE_SOURCE == "merge_request_event"
        - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

Use --ignore-complexity when your main goal is to upload the report even if violations exist. GitLab will still display the findings in the merge request widget and pipeline UI.

If you also want the job to fail when the threshold is exceeded, split the workflow into two jobs:

complexity_check:
    image: python:3.11
    script:
        - pip install complexipy
        - complexipy . --max-complexity-allowed 15

complexity_report:
    image: python:3.11
    script:
        - pip install complexipy
        - complexipy . --output-format gitlab --output complexipy-code-quality.json --ignore-complexity --max-complexity-allowed 15
    artifacts:
        when: always
        reports:
            codequality: complexipy-code-quality.json

VS Code Integration

Install the complexipy extension for real-time complexity analysis:

  • Inline complexity scores
  • Hover tooltips with details
  • Color-coded indicators
  • Quick-fix suggestions

Tips and Best Practices

1. Start with High Thresholds

When introducing complexipy to an existing codebase:

# Create baseline
complexipy . --snapshot-create --max-complexity-allowed 25

# Gradually lower threshold over time
complexipy . --max-complexity-allowed 20
complexipy . --max-complexity-allowed 15

2. Focus on High-Traffic Code

Not all complex code needs immediate refactoring:

# Focus on frequently changed files
complexipy src/core/ --max-complexity-allowed 10
complexipy src/legacy/ --max-complexity-allowed 25

3. Use with Code Reviews

# Check only files in current branch
git diff --name-only main | grep '.py$' | xargs complexipy

4. Combine with Test Coverage

High complexity + low coverage = high risk

# Check coverage for complex functions
pytest --cov=src --cov-report=term-missing
complexipy src/ --failed
# Generate historical data
complexipy . --output-format json
# Commit complexipy-results.json to track changes over time

Troubleshooting

No Python files found

Ensure you're in the correct directory and your files have .py extensions.

Syntax errors in analyzed files

complexipy requires valid Python syntax. Fix syntax errors first:

python -m py_compile file.py

Performance issues on large codebases

Exclude unnecessary directories:

complexipy . --exclude "venv/**" --exclude ".venv/**" --exclude "node_modules/**"

Different results than expected

Check configuration file precedence. Use --help to see active configuration:

complexipy --help

Next Steps