<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[repowise]]></title><description><![CDATA[repowise]]></description><link>https://repowise.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Fri, 13 Nov 2026 21:04:22 GMT</lastBuildDate><atom:link href="https://repowise.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Circular imports in Python: why every big Python repo has them, and the few that actually break]]></title><description><![CDATA[TL;DR This is the error that sends people searching: The usual advice is "you have a circular import, remove it". But I used repowise to look at the import…]]></description><link>https://repowise.hashnode.dev/circular-imports-in-python-why-every-big-python-repo-has-them-and-the-few-that-actually-break</link><guid isPermaLink="true">https://repowise.hashnode.dev/circular-imports-in-python-why-every-big-python-repo-has-them-and-the-few-that-actually-break</guid><category><![CDATA[Python]]></category><category><![CDATA[General Programming]]></category><category><![CDATA[Tutorial]]></category><category><![CDATA[architecture]]></category><dc:creator><![CDATA[Raghav Chamadiya]]></dc:creator><pubDate>Fri, 13 Nov 2026 13:15:10 GMT</pubDate><enclosure url="https://repowise-gtm.vercel.app/covers/devto/02-python-circular-imports.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><strong>TL;DR</strong></p>
<ul>
<li>All 51 popular Python repos I checked (1,000+ GitHub stars each) have circular import chains between files; in the median one, 16.4% of production files sit inside one.</li>
<li>A Python circular import only fails with "partially initialized module" when a name is read from a module that has not finished running, such as a module-level <code>from x import y</code>, a base class, or a decorator that runs on import.</li>
<li>Django has a 179-file import cycle and imports fine, because most of its cycles go through imports inside functions or imports used only for type hints.</li>
<li>Fixes, in order: move the shared code to a third module, import the module instead of the name (with <code>from __future__ import annotations</code>), put type-only imports under <code>TYPE_CHECKING</code>, or import inside the function.</li>
</ul>
<p>This is the error that sends people searching:</p>
<pre><code class="language-text">ImportError: cannot import name 'User' from partially initialized module 'shop.models' (most likely due to a circular import)
</code></pre>
<p>The usual advice is "you have a circular import, remove it". But I used repowise to look at the import graphs of 51 popular Python repositories (1,000+ GitHub stars each), and every single one has circular import chains between its files. In the median repo, 16.4% of production files sit inside one. In Django, one chain links 179 files, and Django imports fine.</p>
<p>So a cycle on its own is not the problem. Below: which cycles actually raise that error, why most do not, and how to fix the ones that do.</p>
<h2>Reproduce it</h2>
<p>Two modules in a package, each importing a name from the other:</p>
<pre><code class="language-python"># shop/models.py
from shop.services import send_welcome


class User:
    def __init__(self, email):
        self.email = email
</code></pre>
<pre><code class="language-python"># shop/services.py
from shop.models import User


def send_welcome(user: User):
    print(f"welcome, {user.email}")
</code></pre>
<pre><code class="language-python"># main.py
from shop.models import User
from shop.services import send_welcome

send_welcome(User("a@example.com"))
</code></pre>
<pre><code class="language-text">$ python main.py
Traceback (most recent call last):
  File "D:\devto-scratch\repro\shop\main.py", line 1, in &lt;module&gt;
    from shop.models import User
  File "D:\devto-scratch\repro\shop\shop\models.py", line 1, in &lt;module&gt;
    from shop.services import send_welcome
  File "D:\devto-scratch\repro\shop\shop\services.py", line 1, in &lt;module&gt;
    from shop.models import User
ImportError: cannot import name 'User' from partially initialized module 'shop.models' (most likely due to a circular import) (D:\devto-scratch\repro\shop\shop\models.py)
</code></pre>
<p>What happens, step by step:</p>
<ol>
<li><code>main.py</code> imports <code>shop.models</code>. Python creates an empty module object, puts it in <code>sys.modules</code>, and starts running <code>models.py</code> from the top.</li>
<li>Line 1 of <code>models.py</code> imports <code>shop.services</code>, so Python pauses <code>models.py</code> and starts running <code>services.py</code>.</li>
<li>Line 1 of <code>services.py</code> asks for <code>User</code> from <code>shop.models</code>. The module exists in <code>sys.modules</code>, but it is still on line 1. <code>User</code> has not been defined yet.</li>
</ol>
<p>"Partially initialized" means exactly that: the module object exists, its code has not finished running. The cycle is only fatal because a name is read from the half-built module while it is still half built.</p>
<h2>How common are circular imports in Python projects?</h2>
<p>The 51 Python repos in the set include django, fastapi, airflow, langchain, prefect and paperless-ngx. Looking at file-to-file imports in production code only (tests, generated and vendored code excluded):</p>
<ul>
<li>51 of 51 have at least one circular import group (a set of 2+ files that can all reach each other through imports).</li>
<li>In the median Python repo, 16.4% of production files sit inside one.</li>
<li>Across all 195 repos in the wider set, covering every language, the median is 9.2%.</li>
</ul>
<p>To see why they do not break, I took a fresh clone of Django (commit <code>6a8248f</code>) and looked at its import graph. Counting every import between the 900 non-test <code>.py</code> files under <code>django/</code>, 238 files sit inside a circular group and the largest group holds 179 files (the whole-repo index counts more, because it also includes files outside <code>django/</code>), from <code>django/__init__.py</code> through <code>apps</code>, <code>conf</code>, <code>db</code> and parts of <code>contrib</code>.</p>
<p>Then I dropped two kinds of import edges: imports written inside a function, and imports used only for type hints. Those two kinds do not run when the module is first loaded. The result:</p>
<table>
<thead>
<tr>
<th>Django, files under <code>django/</code></th>
<th>Circular groups</th>
<th>Files inside a group</th>
<th>Largest group</th>
</tr>
</thead>
<tbody><tr>
<td>All imports</td>
<td>14</td>
<td>238</td>
<td>179 files</td>
</tr>
<tr>
<td>Only imports that run at module load</td>
<td>8</td>
<td>58</td>
<td>35 files</td>
</tr>
</tbody></table>
<p>Removing 152 import edges, about 5% of the total, shrinks the files involved by three quarters. Most of Django's cycles exist only because some function, somewhere, imports something when it is called. By then every module has finished loading, so there is nothing half built to trip over. Django's code is explicit about it. <code>django/db/models/query_utils.py</code> has this inside <code>Q.check()</code>:</p>
<pre><code class="language-python">        # Avoid circular imports.
        from django.db.models import Value
        from django.db.models.sql import Query
        from django.db.models.sql.constants import SINGLE
</code></pre>
<p><code>git grep -i "circular import" -- 'django/*.py'</code> finds 16 comments like that in 11 files.</p>
<p>The 35-file group that remains is <code>django/db/models</code>, and it survives import because of ordering. <code>django/db/models/__init__.py</code> re-exports names from its submodules, and the order of those imports is pinned:</p>
<pre><code class="language-python"># Imports that would create circular imports if sorted
from django.db.models.base import DEFERRED, Model  # isort:skip
from django.db.models.fields.related import (  # isort:skip
    ForeignKey,
    ForeignObject,
    OneToOneField,
</code></pre>
<p>So the real picture of a big Python codebase is: lots of cycles, nearly all of them harmless, and a handful held together by import order. The error shows up when someone reorders those imports or adds a new module-level import in the wrong direction.</p>
<h2>Which circular imports raise "partially initialized module"?</h2>
<h3>1. A module-level <code>from x import y</code> that runs while <code>x</code> is half built</h3>
<p>That is the reproduction above. <code>from module import name</code> reads the name immediately. If <code>module</code> is still running higher up the stack, the name does not exist yet.</p>
<h3>2. Inheriting from a class across the cycle</h3>
<p>A class statement needs its base class at the moment the class is created, so even "import the module, not the name" does not save you:</p>
<pre><code class="language-python"># plugins/base.py
from plugins.email import EmailNotifier


class Notifier:
    def send(self, msg):
        raise NotImplementedError


DEFAULT = EmailNotifier
</code></pre>
<pre><code class="language-python"># plugins/email.py
import plugins.base as base


class EmailNotifier(base.Notifier):
    def send(self, msg):
        print("email:", msg)
</code></pre>
<pre><code class="language-text">  File "D:\devto-scratch\repro\inherit\plugins\email.py", line 4, in &lt;module&gt;
    class EmailNotifier(base.Notifier):
                        ^^^^^^^^^^^^^
AttributeError: partially initialized module 'plugins.base' from 'D:\devto-scratch\repro\inherit\plugins\base.py' has no attribute 'Notifier' (most likely due to a circular import)
</code></pre>
<h3>3. Registries and decorators that run on import</h3>
<p>The classic web-app layout: <code>app.py</code> imports the views so the routes get registered, and <code>views.py</code> imports <code>app</code> to use its decorator.</p>
<pre><code class="language-python"># web/app.py
from web import views  # load the routes


class App:
    def __init__(self):
        self.routes = {}

    def route(self, path):
        def register(fn):
            self.routes[path] = fn
            return fn
        return register


app = App()
</code></pre>
<pre><code class="language-python"># web/views.py
from web.app import app


@app.route("/")
def index():
    return "hello"
</code></pre>
<pre><code class="language-text">  File "D:\devto-scratch\repro\registry\web\views.py", line 1, in &lt;module&gt;
    from web.app import app
ImportError: cannot import name 'app' from partially initialized module 'web.app' (most likely due to a circular import) (D:\devto-scratch\repro\registry\web\app.py)
</code></pre>
<p>The decorator runs at import time, so it needs <code>app</code> before <code>app.py</code> has created it.</p>
<h2>How do you fix a circular import in Python?</h2>
<p>All of these were run on Python 3.13.</p>
<h3>Move the shared piece to a third module</h3>
<p>The best fix removes the cycle. In the inheritance example, <code>base.py</code> only imported <code>EmailNotifier</code> to set a default. Move the default somewhere that sits above both:</p>
<pre><code class="language-python"># plugins/base.py
class Notifier:
    def send(self, msg):
        raise NotImplementedError
</code></pre>
<pre><code class="language-python"># plugins/defaults.py
from plugins.email import EmailNotifier

DEFAULT = EmailNotifier
</code></pre>
<pre><code class="language-text">$ python main.py
email: hi
</code></pre>
<p>Now <code>base</code> imports nothing, <code>email</code> imports <code>base</code>, <code>defaults</code> imports <code>email</code>. The arrows only point one way. For the registry example the same idea is an app factory, or a <code>main.py</code> that imports <code>app</code> first and <code>views</code> second.</p>
<h3>Import the module, not the name, and defer annotations</h3>
<p><code>from shop import models</code> binds the module object, which exists even while it is half built. You then read <code>models.User</code> later, when it exists. One catch: annotations are evaluated when the function is defined, so this still fails on Python 3.13:</p>
<pre><code class="language-python"># shop/services.py
from shop import models


def send_welcome(user: models.User):
    print(f"welcome, {user.email}")
</code></pre>
<pre><code class="language-text">    def send_welcome(user: models.User):
                           ^^^^^^^^^^^
AttributeError: partially initialized module 'shop.models' from 'D:\devto-scratch\repro\shop\shop\models.py' has no attribute 'User' (most likely due to a circular import)
</code></pre>
<p>Add <code>from __future__ import annotations</code> at the top of <code>services.py</code> and it runs, because annotations become strings that are never evaluated at import time. (Python 3.14 defers annotation evaluation by default, PEP 649.)</p>
<h3>If the import is only for type hints, use <code>TYPE_CHECKING</code></h3>
<pre><code class="language-python"># shop/services.py
from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from shop.models import User


def send_welcome(user: User):
    print(f"welcome, {user.email}")
</code></pre>
<pre><code class="language-text">$ python main.py
welcome, a@example.com
</code></pre>
<p>The import only exists for mypy, pyright and your editor. At runtime it never executes.</p>
<h3>Import inside the function</h3>
<pre><code class="language-python"># shop/models.py
class User:
    def __init__(self, email):
        self.email = email

    def welcome(self):
        from shop.services import send_welcome
        send_welcome(self)
</code></pre>
<p>This is what Django does in those 16 commented places. It works, and it is fine as a local patch. It is also how a codebase ends up with a 179-file cycle that nobody can see, so leave a comment, like Django does, saying why the import is there.</p>
<h2>How do you find circular imports in your own project?</h2>
<p>For a list in the terminal, pylint has a check for exactly this:</p>
<pre><code class="language-bash">pylint --disable=all --enable=cyclic-import django/db
</code></pre>
<p>On Django's <code>django/db</code> it reported 72 cyclic-import messages in 8.5 seconds, for example:</p>
<pre><code class="language-text">django\db\models\sql\__init__.py:1:0: R0401: Cyclic import (django.db.models.manager -&gt; django.db.models.query) (cyclic-import)
django\db\models\sql\__init__.py:1:0: R0401: Cyclic import (django.db.models.base -&gt; django.db.models.fields.related) (cyclic-import)
</code></pre>
<p>pylint counts imports inside functions too, so expect it to list cycles that never fail at runtime. <code>pydeps yourpackage --show-cycles</code> draws the same cycles as a graph (it needs Graphviz installed). To stop new cycles in CI, import-linter can fail the build on cycles between subpackages. The setup for that, plus the TypeScript and Go equivalents, is in <a href="https://repowise.dev/blog/guides/find-circular-dependencies-monorepo?utm_source=hashnode&amp;utm_medium=community&amp;utm_campaign=devto-02-python-circular-imports">this guide to finding circular dependencies</a>.</p>
<p>To look at the cycles on a picture instead of a list, <a href="https://repowise.dev/repo/django/django/architecture?utm_source=hashnode&amp;utm_medium=community&amp;utm_campaign=devto-02-python-circular-imports">Django's architecture view</a> shows the same import graph, and the trace tool on that page finds the path between any two files.</p>
<h2>How I got these numbers</h2>
<p>The 51 repos are the Python part of a set of 195 of the most-starred public repositories indexed on repowise (1,000+ GitHub stars each, 253,688 production source files, November 2026), each measured at its own latest index between May and November 2026. The cross-language numbers are in <a href="https://repowise.dev/research/code-health-2026?utm_source=hashnode&amp;utm_medium=community&amp;utm_campaign=devto-02-python-circular-imports#finding-6">finding 6 of the code health 2026 report</a>. Cycles are counted on file-to-file imports between production files, so <code>__init__.py</code> re-exports count as edges. The Django breakdown comes from <code>repowise init --yes --no-prose --no-editor-setup</code> on a fresh clone at <code>6a8248f</code>, then a strongly connected components pass over the stored import graph, once with every import edge and once without the edges marked as function-level or type-only.</p>
<h2>FAQ</h2>
<p><strong>What does "partially initialized module ... most likely due to a circular import" mean?</strong>
Python has created the module object and put it in <code>sys.modules</code>, but the module's code is still running further up the import chain. Something asked for a name that the module has not defined yet, so the import fails with that message.</p>
<p><strong>Does <code>from __future__ import annotations</code> fix circular imports?</strong>
Only when the cycle breaks on a type hint. It turns annotations into strings that are not evaluated at import time, so <code>def f(user: models.User)</code> stops failing. It does not help when the name is needed while the module loads, such as a base class or a decorator.</p>
<p><strong>Are circular imports always a bug in Python?</strong>
No. All 51 Python repos in this set have circular import chains between files and import fine. A cycle only fails when one module reads a name from another before that module has finished running.</p>
]]></content:encoded></item><item><title><![CDATA[Python dead code: what 51 popular Python repos ship that nothing imports]]></title><description><![CDATA[TL;DR I ran a dead-code pass with repowise over 51 popular Python repositories, each with 1,000+ GitHub stars: Django, FastAPI, Airflow, LangChain…]]></description><link>https://repowise.hashnode.dev/python-dead-code-what-51-popular-python-repos-ship-that-nothing-imports</link><guid isPermaLink="true">https://repowise.hashnode.dev/python-dead-code-what-51-popular-python-repos-ship-that-nothing-imports</guid><category><![CDATA[Python]]></category><category><![CDATA[Open Source]]></category><category><![CDATA[codequality]]></category><category><![CDATA[General Programming]]></category><dc:creator><![CDATA[Raghav Chamadiya]]></dc:creator><pubDate>Thu, 12 Nov 2026 13:15:09 GMT</pubDate><enclosure url="https://repowise-gtm.vercel.app/covers/devto/01-python-dead-code-51-repos.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><strong>TL;DR</strong></p>
<ul>
<li>45 of 50 popular Python repos (1,000+ GitHub stars) have at least one dead-code finding in production code, and in the median repo 3.9% of production files are flagged as unreachable.</li>
<li>In Python, a file nothing imports is often loaded by name: 82 of Django's 100 dead-code candidates are locale files imported from a string at runtime.</li>
<li>814 of the 819 files flagged in LangChain are compatibility shims that keep old import paths working for its users.</li>
<li>vulture finds unused names inside a file and an import-graph pass finds whole files nothing imports; Python projects need both, plus a string search before each delete.</li>
</ul>
<p>I ran a dead-code pass with repowise over 51 popular Python repositories, each with 1,000+ GitHub stars: Django, FastAPI, Airflow, LangChain, Prefect, paperless-ngx and 45 more. Then I took three of them, cloned them fresh, and read the flagged files one group at a time to see what they really were.</p>
<p>The short version: almost every popular Python repo has code that nothing imports, and in Python "nothing imports this" very often means "something loads this by name". Below are the numbers, what the hand check found, and a way to clean up your own project that takes both facts into account.</p>
<h2>What counts as dead code in Python?</h2>
<p>Two kinds of finding, on production source files only (tests, generated code, vendored code, build scripts and docs examples are excluded):</p>
<ul>
<li>A file flagged as <strong>unreachable</strong>: no other file imports it, and it is not an entry point.</li>
<li>An <strong>unused export</strong>: a public function, class or constant defined in one module that no other module imports.</li>
</ul>
<p>Each finding has a confidence between 0 and 1. I call 0.7 and above "high confidence".</p>
<h2>How much dead code do popular Python projects carry?</h2>
<p>Dead-code data exists for 50 of the 51 Python repos.</p>
<ul>
<li>45 of 50 have at least one finding. 45 of 50 have at least one unused export, 42 have at least one file flagged as unreachable, and 37 have at least one high-confidence finding.</li>
<li>The median repo has 15.7 findings per 100 production files, and 3.9% of its production files are flagged as unreachable.</li>
<li>Pooled across the 50 repos: 4,074 flagged files and 6,008 unused exports, in 38,285 production files.</li>
</ul>
<p>Some recognisable names, each measured at its own latest index:</p>
<table>
<thead>
<tr>
<th>Repo</th>
<th>Production files</th>
<th>Files flagged unreachable</th>
<th>Unused exports</th>
<th>Indexed</th>
</tr>
</thead>
<tbody><tr>
<td>fastapi/fastapi</td>
<td>52</td>
<td>0</td>
<td>0</td>
<td>2026-11-04</td>
</tr>
<tr>
<td>tobymao/sqlglot</td>
<td>185</td>
<td>0</td>
<td>2</td>
<td>2026-10-26</td>
</tr>
<tr>
<td>litestar-org/litestar</td>
<td>325</td>
<td>6</td>
<td>1</td>
<td>2026-11-10</td>
</tr>
<tr>
<td>paperless-ngx/paperless-ngx</td>
<td>443</td>
<td>13</td>
<td>8</td>
<td>2026-10-15</td>
</tr>
<tr>
<td>django/django</td>
<td>870</td>
<td>154</td>
<td>73</td>
<td>2026-07-29</td>
</tr>
<tr>
<td>PrefectHQ/prefect</td>
<td>2,281</td>
<td>153</td>
<td>166</td>
<td>2026-07-17</td>
</tr>
<tr>
<td>apache/airflow</td>
<td>4,387</td>
<td>424</td>
<td>556</td>
<td>2026-08-19</td>
</tr>
<tr>
<td>langchain-ai/langchain</td>
<td>1,714</td>
<td>819</td>
<td>2</td>
<td>2026-11-04</td>
</tr>
</tbody></table>
<p>FastAPI's own package has no findings at all (the table counts production files only). When I re-ran it on a fresh clone, all 259 candidates were in <code>docs_src/</code> (the code samples behind its documentation), <code>scripts/</code> and <code>docs/</code>, none in <code>fastapi/</code>.</p>
<p>The bottom rows are where it gets interesting, because what a flagged file means depends on how the project loads its code. Here is what the flagged files were.</p>
<h2>What I found when I checked by hand</h2>
<p>I cloned each repo, indexed it with the repowise CLI, listed the findings, and then read the flagged files in groups.</p>
<h3>Django: 82 of 100 candidates are locale files loaded by string</h3>
<p>On Django at commit <code>6a8248f</code>, the dead-code pass returned 100 candidates. 82 of them are files like these:</p>
<pre><code class="language-text">django/conf/locale/ar/formats.py
django/conf/locale/ar_DZ/formats.py
django/conf/locale/az/formats.py
</code></pre>
<p>No Python file imports <code>django/conf/locale/ar/formats.py</code>. Django loads it at runtime, by building the module name as a string, in <code>django/utils/formats.py</code>:</p>
<pre><code class="language-python">    for location in format_locations:
        for loc in locales:
            try:
                yield import_module("%s.formats" % (location % loc))
            except ImportError:
                pass
</code></pre>
<p>Delete one of those files and Arabic date formatting silently falls back to the default. The other 18 candidates were eight JavaScript files (the admin's static scripts and a JS template, served to the browser), the Sphinx config, extensions and docs linter under <code>docs/</code>, and three functions in <code>scripts/manage_translations.py</code> that its command-line parser dispatches to.</p>
<p>These are files to keep, and the tool treats them that way. Asking for deletion-ready findings only returns nothing, with the reason:</p>
<pre><code class="language-text">$ repowise dead-code --safe-only
100 finding(s), none deletion-ready: a finding is deletion-ready only when missing imports are evidence for its language. Review the ranked list without the safe-only filter.
</code></pre>
<h3>LangChain: 814 files that keep old import paths working</h3>
<p>On LangChain at commit <code>1a41b51</code>, 819 files are flagged as unreachable, and 814 of them sit in one package: <code>libs/langchain/langchain_classic</code>. Nearly all look like this:</p>
<pre><code class="language-python">from typing import TYPE_CHECKING, Any

from langchain_classic._api import create_importer

if TYPE_CHECKING:
    from langchain_community.agent_toolkits.jira.toolkit import JiraToolkit

# Create a way to dynamically look up deprecated imports.
# Used to consolidate logic for raising deprecation warnings and
# handling optional imports.
DEPRECATED_LOOKUP = {"JiraToolkit": "langchain_community.agent_toolkits.jira.toolkit"}

_import_attribute = create_importer(__package__, deprecated_lookups=DEPRECATED_LOOKUP)


def __getattr__(name: str) -&gt; Any:
    """Look up attributes dynamically."""
    return _import_attribute(name)
</code></pre>
<p>That is a compatibility shim. Code written against an older LangChain still does <code>from langchain.agents.agent_toolkits.jira.toolkit import JiraToolkit</code>, and this file forwards the import to its new home with a deprecation warning. Nothing inside the repo imports it because the importers are LangChain's users.</p>
<p>I sorted all 819 by what they contain: 758 use <code>create_importer</code> like the file above, 48 only re-export names. Of the remaining 13, nine are importable modules of the same package (prompt templates and API documentation strings), two are <code>version.py</code> files kept for backwards compatibility in partner packages, and two sit in an examples folder. Removing any of the package files is a breaking change for users, not a cleanup.</p>
<p>Across the whole repo, one finding is marked ready to delete: an example function, <code>run_calculator_http</code>, that <code>git grep</code> finds nowhere except its own definition.</p>
<h3>Airflow: example DAGs, docs configs, and one repeated helper</h3>
<p>On Airflow at commit <code>6a8bc716</code>, the pass returned 791 candidates: 352 flagged files, 436 unused exports and 3 unused workspace packages. I sorted the 352 files:</p>
<ul>
<li>111 are example DAGs under <code>example_dags/</code> folders. Airflow never imports them. It points its DAG processor at the folder and parses every file in it, in <code>airflow-core/src/airflow/dag_processing/bundles/manager.py</code>:</li>
</ul>
<pre><code class="language-python">def _add_example_dag_bundle(bundle_config_list: list[_ExternalBundleConfig]):
    from airflow import example_dags

    example_dag_folder = next(iter(example_dags.__path__))
</code></pre>
<ul>
<li>109 are <code>docs/conf.py</code> files, one Sphinx config per provider package. Sphinx loads them by path.</li>
<li>14 are generated OpenAPI client files for the web UI, and most of the rest are other front-end and dev tooling files.</li>
<li>59 are copies of a small <code>version_compat.py</code> helper from the provider package template. In those 59 providers, <code>git grep</code> finds nothing that imports it, so they are the ones worth a look: a few lines each, ready for whenever a provider needs the version check.</li>
</ul>
<p>So most of Airflow's flagged files are loaded from a folder or by a tool, the same shape as the other repos, and the clear candidates come down to one small template file.</p>
<h3>The pattern</h3>
<p>In all three, most of the files an import graph cannot reach were reached another way:</p>
<ul>
<li>by a module name built from a string (<code>import_module</code>, <code>__import__</code>, Django's <code>INSTALLED_APPS</code>, Celery task names), or a folder scanned at runtime (Airflow's DAG folders),</li>
<li>by packaging metadata (console scripts and plugin entry points in <code>pyproject.toml</code>),</li>
<li>or by code outside the repo (a published library's public import paths).</li>
</ul>
<p>That is why a good dead-code pass for Python reports confidence and a separate "ready to delete" decision, and why the second step is always a search for the module's dotted name as a string.</p>
<h2>Vulture or an import graph: what does each one find?</h2>
<p>The tool most Python developers know is <a href="https://github.com/jendrikseipp/vulture">vulture</a>. It reads each file's syntax tree and reports names that are defined and never used. On the same Django clone:</p>
<pre><code class="language-text">$ vulture django/ --min-confidence 80
django\contrib\admindocs\middleware.py:14: unused variable 'view_args' (100% confidence)
django\contrib\admindocs\middleware.py:14: unused variable 'view_kwargs' (100% confidence)
django\contrib\auth\middleware.py:54: unused variable 'view_args' (100% confidence)
django\contrib\auth\middleware.py:54: unused variable 'view_kwargs' (100% confidence)
django\contrib\gis\management\commands\ogrinspect.py:14: unused variable 'option_string' (100% confidence)
django\contrib\gis\management\commands\ogrinspect.py:28: unused variable 'option_string' (100% confidence)
django\core\serializers\xml_serializer.py:506: unused variable 'has_internal_subset' (100% confidence)
django\core\serializers\xml_serializer.py:510: unused variable 'is_parameter_entity' (100% confidence)
django\db\backends\utils.py:94: unused variable 'ignored_wrapper_args' (100% confidence)
django\db\backends\utils.py:107: unused variable 'ignored_wrapper_args' (100% confidence)
django\db\models\enums.py:78: unused variable 'last_values' (100% confidence)
django\utils\inspect.py:138: unused variable 'annotation_format' (100% confidence)
</code></pre>
<p>Every one of these is a parameter the function has to keep: a callback signature, an overridden method, or a keyword kept for API compatibility. <code>process_view(self, request, view_func, view_args, view_kwargs)</code> has to accept <code>view_args</code> even if this middleware never reads it. At vulture's default threshold of 60%, the same run prints 2,010 lines, mostly unused variables, methods and classes that Django's own machinery or its users call.</p>
<p>The two checks answer different questions:</p>
<table>
<thead>
<tr>
<th></th>
<th>vulture</th>
<th>Import-graph pass</th>
</tr>
</thead>
<tbody><tr>
<td>Unit</td>
<td>a name inside a file</td>
<td>a whole file, or a name other modules should import</td>
</tr>
<tr>
<td>Good at</td>
<td>dead branches, leftover variables, unused private helpers</td>
<td>modules nothing reaches, exports nobody imports</td>
</tr>
<tr>
<td>Blind spot</td>
<td>names used by frameworks through reflection</td>
<td>files loaded by string or from outside the repo</td>
</tr>
</tbody></table>
<p>Use both. They rarely flag the same thing.</p>
<h2>How do you find dead code in a Python project?</h2>
<p>Start with vulture at a high threshold, and keep a whitelist for the framework hooks it cannot see:</p>
<pre><code class="language-bash">pip install vulture
vulture src/ --min-confidence 80
vulture src/ --make-whitelist &gt; whitelist.py   # review it, then:
vulture src/ whitelist.py --min-confidence 80
</code></pre>
<p>Then look at whole files and exports. With the repowise CLI, which builds the import graph locally and needs no API key:</p>
<pre><code class="language-bash">pip install repowise
repowise init --yes --no-prose --no-editor-setup
repowise dead-code --min-confidence 0.7
repowise dead-code --safe-only
</code></pre>
<p>For every file on that list, before deleting it, search for its dotted module name and its file stem as strings:</p>
<pre><code class="language-text">$ git grep -n "django.conf.locale" -- 'django/*.py'
django/utils/formats.py:76:    format_locations.append("django.conf.locale.%s")
django/utils/translation/__init__.py:270:    from django.conf.locale import LANG_INFO
django/utils/translation/trans_real.py:14:from django.conf.locale import LANG_INFO
</code></pre>
<p>If anything builds the name dynamically, you will usually find the prefix, as in the first line here. Search <code>pyproject.toml</code> and <code>setup.cfg</code> too, for entry points.</p>
<h2>What I would delete first</h2>
<ol>
<li>Files that are flagged, have no string reference anywhere, and have not changed in a long time. Old and unreferenced is the safest combination.</li>
<li>Unused private helpers vulture finds at 80% or more inside files you already own. Low risk, small diffs.</li>
<li>Nothing in a published package's import surface until a major version. Move it behind a deprecation warning first, the way LangChain does.</li>
</ol>
<p>Delete in small pull requests, one directory or one kind of finding at a time. Run the full test suite on each, plus an import of every module in the package, which catches a deleted file that something still imports by name at load time:</p>
<pre><code class="language-bash">python -c "import importlib, pkgutil, mypkg; [importlib.import_module(m.name) for m in pkgutil.walk_packages(mypkg.__path__, 'mypkg.')]"
</code></pre>
<p>If you are choosing a tool, <a href="https://repowise.dev/blog/comparisons/best-dead-code-detection-tools?utm_source=hashnode&amp;utm_medium=community&amp;utm_campaign=devto-01-python-dead-code">this comparison of dead code detection tools</a> covers vulture, the import-graph approach and the options for other languages. Django's <a href="https://repowise.dev/repo/django/django/code-health?utm_source=hashnode&amp;utm_medium=community&amp;utm_campaign=devto-01-python-dead-code">code health page</a> shows the dead-code list from the table above in the browser.</p>
<p>Which loading pattern has bitten you when deleting "unused" Python code: string imports, entry points, or something else?</p>
<h2>How I got these numbers</h2>
<p>The 51 repos are the Python part of a set of 195 of the most-starred public repositories indexed on repowise (1,000+ GitHub stars each, 253,688 production source files, November 2026); the dead-code numbers for the full set are in the <a href="https://repowise.dev/research/code-health-2026?utm_source=hashnode&amp;utm_medium=community&amp;utm_campaign=devto-01-python-dead-code#dead-code">code health 2026 report</a>. Findings are counted at confidence 0.4 and above, on production files only, with each repo measured at its own latest index between May and November 2026, so the table is a snapshot per repo. The hand checks were fresh clones on 10 November 2026: Django <code>6a8248f</code> (full history), LangChain <code>1a41b51</code> and Airflow <code>6a8bc716</code> (last 300 commits), FastAPI <code>ba5d72b</code> (last 500 commits), each indexed with <code>repowise init --yes --no-prose --no-editor-setup</code> (repowise 0.60.1), plus vulture 2.16 on Django. A fresh run can differ from the table, because the code and the tool both move between index dates.</p>
<h2>FAQ</h2>
<p><strong>What does vulture do in Python?</strong>
vulture reads each file's syntax tree and reports functions, classes, variables and imports that are defined and never used, each with a confidence score. It works on names, so it does not report a whole module that nothing imports, and it cannot see names that a framework calls through reflection.</p>
<p><strong>Does Ruff find dead code?</strong>
Ruff finds unused imports (F401) and unused local variables (F841) inside each file, and commented-out code with its eradicate rules (ERA001). It does not track whether another module ever uses a function or file, so unused exports and unreachable files need vulture or an import-graph tool.</p>
<p><strong>How do I find unreachable code in Python?</strong>
For statements that can never run, such as code after a <code>return</code>, pylint reports <code>unreachable</code> (W0101). For whole files that nothing reaches, build the import graph (for example <code>repowise dead-code --min-confidence 0.7</code>), then search for each file's dotted module name as a string before deleting it, because Python code is often loaded by name.</p>
]]></content:encoded></item></channel></rss>