Taming Tangled Python: A Practical Guide to Measuring and Fixing Code Complexity

Akram Chauhan
Akram Chauhan
10 min read232 views
Taming Tangled Python: A Practical Guide to Measuring and Fixing Code Complexity

We’ve all been there. You open a file, scroll to a function, and your brain just… stops. You’re staring at a wall of nested if statements, for loops with try-except blocks inside, and a sprinkle of elif for good measure. It’s the kind of code that makes you want to close your laptop and take a long walk.

That feeling you get? That mental strain of trying to follow all the possible paths through a piece of logic? That’s called Cognitive Complexity. It’s not about how many lines of code there are, but how hard it is for a human to understand. For years, we’ve relied on gut feelings and code reviews to say, "Hey, this function seems a bit too complicated." But what if we could put a number on it?

That's exactly what we're going to do today. We'll use a fantastic little Python tool called complexipy to turn that vague feeling of "this is a mess" into a concrete, measurable metric. We'll treat code quality not as an art, but as an engineering signal we can measure, visualize, and improve over time. Let's get our hands dirty.

First Things First: Getting Set Up

Before we can start measuring anything, we need to get our environment ready. This is super simple. We just need to install complexipy along with a couple of friends for data handling (pandas) and plotting (matplotlib).

Let's fire up a terminal or a notebook and run this:

pip -q install complexipy pandas matplotlib

# Now let's import everything we'll need
import os
import json
import textwrap
import subprocess
from pathlib import Path

import pandas as pd
import matplotlib.pyplot as plt
from complexipy import code_complexity, file_complexity

print("Ready to go! All libraries are installed.")

With that out of the way, we have everything we need to start our investigation.

Seeing Complexity in a Single Snippet

Let's start small to really understand what we're measuring. Imagine you come across this function in a codebase:

snippet = """
def score_orders(orders):
    total = 0
    for o in orders:
        if o.get("valid"):
            if o.get("priority"):
                if o.get("amount", 0) > 100:
                    total += 3
                else:
                    total += 2
            else:
                if o.get("amount", 0) > 100:
                    total += 2
                else:
                    total += 1
        else:
            total -= 1
    return total
"""

You can just feel the complexity, right? That deep nesting of if statements is a classic red flag. Instead of just squinting at it, let's ask complexipy what it thinks.

res = code_complexity(snippet)

print("=== Code string complexity ===")
print("Overall complexity:", res.complexity)
print("Functions:")
for f in res.functions:
    print(f" - {f.name}: {f.complexity} (lines {f.line_start}-{f.line_end})")

When you run this, you'll see that complexipy gives the score_orders function a specific complexity score. Each if, for, and else adds to the cognitive load, and the nesting makes it even worse. This simple check confirms our intuition with a hard number. Now we're not just saying "it feels complex," we're saying "it has a complexity of X."

Let's Build a Playground Project

Analyzing a single snippet is cool, but the real power comes from running this across an entire project. To do that, we need a project to analyze. Let's quickly create a small, but realistic, toy project structure right from our code.

root = Path("toy_project")
src = root / "src"
tests = root / "tests"

# Create the directories
src.mkdir(parents=True, exist_ok=True)
tests.mkdir(parents=True, exist_ok=True)

# Create some empty __init__.py files
(src / "__init__.py").write_text("")
(tests / "__init__.py").write_text("")

# Now, let's create a few Python files with varying complexity
(src / "simple.py").write_text(textwrap.dedent("""
def add(a, b):
    return a + b

def safe_div(a, b):
    if b == 0:
        return None
    return a / b
""").strip() + "\n")

(src / "legacy_adapter.py").write_text(textwrap.dedent("""
def legacy_adapter(x, y):
    if x and y:
        if x > 0:
            if y > 0:
                return x + y
            else:
                return x - y
        else:
            if y > 0:
                return y - x
            else:
                return -(x + y)
    return 0
""").strip() + "\n")

(src / "engine.py").write_text(textwrap.dedent("""
def route_event(event):
    kind = event.get("kind")
    payload = event.get("payload", {})
    if kind == "A":
        if payload.get("x") and payload.get("y"):
            return _handle_a(payload)
        return None
    elif kind == "B":
        if payload.get("flags"):
            return _handle_b(payload)
        else:
            return None
    elif kind == "C":
        for item in payload.get("items", []):
            if item.get("enabled"):
                if item.get("mode") == "fast":
                    _do_fast(item)
                else:
                    _do_safe(item)
        return True
    else:
        return None

def _handle_a(p):
    total = 0
    for v in p.get("vals", []):
        if v > 10:
            total += 2
        else:
            total += 1
    return total

def _handle_b(p):
    score = 0
    for f in p.get("flags", []):
        if f == "x":
            score += 1
        elif f == "y":
            score += 2
        else:
            score -= 1
    return score

def _do_fast(item):
    return item.get("id")

def _do_safe(item):
    if item.get("id") is None:
        return None
    return item.get("id")
""").strip() + "\n")

print(f"Created project at: {root.resolve()}")

Perfect. We now have a mini-project with a simple module, a horribly nested "legacy" module, and a more complex engine.py file. This gives us some interesting code to analyze.

From a Single File to the Whole Shebang

We can analyze a single file using the Python API, just like we did with the snippet. Let's point it at our new engine.py file.

engine_path = src / "engine.py"
file_res = file_complexity(str(engine_path))

print("\n=== File complexity (Python API) ===")
print("Path:", file_res.path)
print("File complexity:", file_res.complexity)
for f in file_res.functions:
    print(f" - {f.name}: {f.complexity} (lines {f.line_start}-{f.line_end})")

This is useful for quick checks, but for integrating this into a real workflow (like a CI/CD pipeline), the command-line interface (CLI) is where the magic happens. We can run complexipy on our entire toy_project directory and tell it to fail if any function exceeds a certain complexity threshold.

Let's try running it and have it generate JSON and CSV reports for us.

# We'll set a max complexity we're comfortable with. Let's say 8.
MAX_ALLOWED = 8

# This is a helper function to run the CLI and find the report files
def run_complexipy_cli(project_dir: Path, max_allowed: int = 8):
    cmd = [
        "complexipy",
        ".",
        "--max-complexity-allowed",
        str(max_allowed),
        "--output-json",
        "--output-csv",
    ]
    # We run the command from inside our toy project directory
    proc = subprocess.run(cmd, cwd=str(project_dir), capture_output=True, text=True)

    # The tool might create reports with different names, so let's find them
    csv_report = project_dir / "complexipy.csv"
    json_report = project_dir / "complexipy.json"

    if proc.returncode != 0:
        print("Complexipy found issues! (This is expected)")

    return proc.returncode, csv_report, json_report

rc, csv_report, json_report = run_complexipy_cli(root, MAX_ALLOWED)

print(f"\nCLI finished. Found CSV report: {csv_report.exists()}")
print(f"Found JSON report: {json_report.exists()}")

Because we set our max complexity to 8, the command will likely "fail" (return a non-zero exit code), which is exactly what we want! This is how you'd use it in a CI pipeline to automatically block code that's too complex. More importantly, it generated reports for us. Now, let's do something with that data.

Turning Reports into Actionable Insights

A JSON or CSV file is nice, but it's still just raw data. To really understand what's going on, we need to load it up and structure it properly. This is where pandas comes in. We can load the report into a DataFrame, which is basically a super-powered spreadsheet for programmers.

# Let's load the data from the CSV report
df = pd.read_csv(csv_report)

# The report is structured one row per file, with functions nested inside.
# We need to "explode" it so each row represents a single function.
def explode_functions_table(df_in):
    if "functions" in df_in.columns:
        # This line is the magic. It creates a new row for each function in the list.
        tmp = df_in.explode("functions", ignore_index=True)
        # The function data is still a bit messy, so we normalize it
        if tmp["functions"].notna().any() and isinstance(tmp["functions"].dropna().iloc[0], dict):
            fn = pd.json_normalize(tmp["functions"])
            base = tmp.drop(columns=["functions"])
            return pd.concat([base.reset_index(drop=True), fn.reset_index(drop=True)], axis=1)
    return df_in

fn_df = explode_functions_table(df)

# Let's clean up the column names to be consistent
fn_df = fn_df.rename(columns={
    "filename": "path",
    "function_name": "function",
    "start_line": "line_start",
    "end_line": "line_end"
})

print("\n=== Function-level Complexity DataFrame ===")
print(fn_df[["path", "function", "complexity", "line_start", "line_end"]])

Boom! Now we have a clean table where every single row is a function in our project, complete with its path, name, and complexity score. This is huge. We've turned our codebase into a dataset, and now we can ask it questions.

Visualizing the Problem and Finding the Culprits

Numbers in a table are great, but a picture is worth a thousand lines of code. Let's use matplotlib to create a histogram of our complexity scores. This will instantly show us the overall health of our project.

# Make sure the complexity column is numeric
fn_df["complexity"] = pd.to_numeric(fn_df["complexity"], errors="coerce")

plt.figure(figsize=(10, 6))
fn_df["complexity"].dropna().plot(kind="hist", bins=15, title="Cognitive Complexity Distribution of Functions")
plt.xlabel("Cognitive Complexity Score")
plt.ylabel("Number of Functions")
plt.grid(axis='y', alpha=0.75)
plt.show()

This chart tells a story. You'll likely see a big bar on the left for all the simple, healthy functions (low complexity). But you'll also see a few bars trailing off to the right. Those are your trouble spots. Those are the functions that are dragging down the maintainability of your entire project.

Now, let's get specific. We can sort our DataFrame to find the worst offenders and even offer some automated refactoring advice.

def refactor_hints(complexity):
    if complexity >= 20:
        return "Extreme! Break this down immediately. Consider redesign."
    if complexity >= 12:
        return "High! Extract helper functions and flatten nesting."
    if complexity >= 8:
        return "Moderate. Look for opportunities to simplify or use guard clauses."
    return "Acceptable."

# Sort by complexity and show the top offenders
top_offenders = fn_df.sort_values("complexity", ascending=False).head(5)

print("\n=== Top Complexity Offenders ===")
for _, row in top_offenders.iterrows():
    cx = row['complexity']
    print(f"- Function: {row['function']} in {row['path']}")
    print(f"  Complexity: {cx}")
    print(f"  Suggestion: {refactor_hints(cx)}\n")

And there you have it. We've gone from a vague feeling of unease to a prioritized, actionable list of technical debt to tackle. We know exactly which functions are the most complex (legacy_adapter is probably at the top of the list!) and we have a starting point for how to fix them.

This entire process closes the loop. You measure, you visualize, you identify, and you act. By integrating a tool like complexipy into your workflow, you're not leaving code quality to chance. You're making maintainability a first-class citizen in your development process, and your future self (and your teammates) will thank you for it.

Tags

AI Engineering Python Code Quality Programming Best Practices Developer Tools Software Development Data Visualization Software Engineering Cognitive Complexity complexipy Python Code Complexity Code Readability Static Code Analysis Software Metrics Python Development Maintainable Code Technical Debt Code Refactoring Python Tools Code Enforcement

Stay Updated

Get the latest articles and insights delivered straight to your inbox.

We respect your privacy. Unsubscribe at any time.

Aicosoft

AI & Technology News, Insights & Innovation

AICOSOFT delivers cutting-edge AI news, technology breakthroughs, and innovation insights. Stay informed about artificial intelligence, machine learning, robotics, and the latest tech trends shaping tomorrow.

Connect With Us

© 2026 Aicosoft. All rights reserved.