<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: youssef-elaasri</title>
    <description>The latest articles on DEV Community by youssef-elaasri (@youssefelaasri).</description>
    <link>https://dev.to/youssefelaasri</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4239060%2F62c63b69-d613-4d1a-b848-1adeabb45092.jpg</url>
      <title>DEV Community: youssef-elaasri</title>
      <link>https://dev.to/youssefelaasri</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/youssefelaasri"/>
    <language>en</language>
    <item>
      <title>Every requirement satisfied, still broken: dependency bugs I found in six Python apps</title>
      <dc:creator>youssef-elaasri</dc:creator>
      <pubDate>Wed, 11 Nov 2026 12:28:54 +0000</pubDate>
      <link>https://dev.to/youssefelaasri/every-requirement-satisfied-still-broken-dependency-bugs-i-found-in-six-python-apps-2a66</link>
      <guid>https://dev.to/youssefelaasri/every-requirement-satisfied-still-broken-dependency-bugs-i-found-in-six-python-apps-2a66</guid>
      <description>&lt;p&gt;&lt;code&gt;pip check&lt;/code&gt; passing doesn't mean your packages work together. It means each package's declared version ranges are met. Whether the installed version of a dependency still has the functions, attributes and parameters the package actually uses is something nothing checks.&lt;/p&gt;

&lt;p&gt;To find out how often that goes wrong in practice, I installed six open-source Python applications the way their docs say to (NetBox, CTFd, redash, FlaskBB, healthchecks and paperless-ngx) and checked every call each installed package makes into another against the version installed next to it. Here's what turned up, which patterns keep recurring, and what maintainers can do about each one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;healthchecks&lt;/strong&gt; and &lt;strong&gt;FlaskBB&lt;/strong&gt;: no breaks. &lt;strong&gt;NetBox&lt;/strong&gt;: one, and only on Windows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CTFd&lt;/strong&gt;: two pinned dependencies that don't work with the other pins.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;redash&lt;/strong&gt;: its SAML library calls a pyOpenSSL class that no longer exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;paperless-ngx&lt;/strong&gt;: breaks in three of its dependencies: channels, nltk and llama-index.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I reproduced each one by hand and reported it upstream. All are listed with their issue links at the end. None of them gets past &lt;code&gt;pip install&lt;/code&gt; with an error. They all fail later, at runtime, on a code path that ordinary use rarely reaches.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 1: deprecated, removed, still called
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;channels on Django 5.&lt;/strong&gt; &lt;code&gt;CookieMiddleware.set_cookie()&lt;/code&gt; converts an aware &lt;code&gt;expires&lt;/code&gt; datetime with &lt;code&gt;django.utils.timezone.utc&lt;/code&gt;. Django deprecated that alias in 4.1 and removed it in 5.0. channels requires &lt;code&gt;Django&amp;gt;=4.2&lt;/code&gt;, so every install on Django 5.x has this line waiting to fail:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="n"&gt;django&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conf&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;
&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;django&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;channels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sessions&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nc"&gt;CookieMiddleware&lt;/span&gt;

&lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"http.response.start"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;expires&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2030&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tzinfo&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nc"&gt;CookieMiddleware&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_cookie&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"sessionid"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"abc"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expires&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;expires&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# AttributeError: module 'django.utils.timezone' has no attribute 'utc'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;nltk on NumPy 2.&lt;/strong&gt; The GIS and IIS trainers for &lt;code&gt;MaxentClassifier&lt;/code&gt; set the weight of features that never appear in the training data to &lt;code&gt;numpy.NINF&lt;/code&gt;, which NumPy 2.0 removed. Train on data where some encoded feature never shows up, and you get &lt;code&gt;AttributeError: np.NINF was removed in the NumPy 2.0 release&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Both fixes are one line: &lt;code&gt;datetime.timezone.utc&lt;/code&gt; and &lt;code&gt;-numpy.inf&lt;/code&gt;, which work on old and new versions alike. Both had a full deprecation cycle. The warnings were simply never seen, because the code paths are rare: an aware &lt;code&gt;expires&lt;/code&gt;, an unattested feature. Fix PRs are open for both (&lt;a href="https://github.com/django/channels/pull/2238" rel="noopener noreferrer"&gt;channels#2238&lt;/a&gt;, &lt;a href="https://github.com/nltk/nltk/pull/4008" rel="noopener noreferrer"&gt;nltk#4008&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What helps:&lt;/strong&gt; run at least one CI job with deprecation warnings turned into errors (&lt;code&gt;-W error::DeprecationWarning&lt;/code&gt;, or &lt;code&gt;filterwarnings = error&lt;/code&gt; in pytest's config), against the newest versions your ranges allow. That only catches calls your tests reach, which is exactly why these two survived, but it's cheap and catches most of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 2: an open-ended &lt;code&gt;&amp;gt;=&lt;/code&gt; meets a removal
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;pysaml2 and pyOpenSSL.&lt;/strong&gt; pysaml2 requires &lt;code&gt;pyopenssl&amp;gt;=26.0.0&lt;/code&gt; with no upper bound. pyOpenSSL 26.3 removed &lt;code&gt;crypto.X509Req&lt;/code&gt; and &lt;code&gt;crypto.load_certificate_request&lt;/code&gt;, deprecated in favour of &lt;code&gt;cryptography&lt;/code&gt;'s CSR builder. So a fresh &lt;code&gt;pip install pysaml2&lt;/code&gt; today gets a pyOpenSSL where &lt;code&gt;OpenSSLWrapper().create_certificate(info, request=True)&lt;/code&gt; raises &lt;code&gt;AttributeError&lt;/code&gt;. Creating a self-signed certificate (&lt;code&gt;request=False&lt;/code&gt;) still works, which is probably why nobody noticed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;llama-index and huggingface_hub.&lt;/strong&gt; When you don't pass &lt;code&gt;model_name&lt;/code&gt;, &lt;code&gt;HuggingFaceInferenceAPIEmbedding&lt;/code&gt; asks &lt;code&gt;InferenceClient.get_recommended_model()&lt;/code&gt; for a default. That method is gone from current huggingface_hub, and the package requires &lt;code&gt;huggingface-hub[inference]&amp;gt;=0.19.0&lt;/code&gt;, again with no upper bound.&lt;/p&gt;

&lt;p&gt;I'm not arguing for upper bounds everywhere. They cause their own resolution problems, and the Python packaging community has mostly settled against them for libraries. The point is that a lower bound is a claim about every future version, and nothing checks that claim. When a dependency announces a removal, it's worth grepping for the name before the release ships.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 3: re-export shims that outlive their target
&lt;/h2&gt;

&lt;p&gt;llama-index-core has a one-line module:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# llama_index/core/workflow/service.py&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;workflows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;service&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nc"&gt;ServiceManager&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ServiceNotFoundError&lt;/span&gt;  &lt;span class="c1"&gt;# noqa&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;workflows&lt;/code&gt; package removed &lt;code&gt;workflows.service&lt;/code&gt; in August 2025, and no version in llama-index-core's allowed range still has it. &lt;code&gt;import llama_index.core.workflow.service&lt;/code&gt; fails on every supported install. Nothing in the repository imports it anymore, so no test caught it. It only breaks for users who imported it themselves.&lt;/p&gt;

&lt;p&gt;Compatibility shims tend to rot quietly, because the code that owns them doesn't use them. A test that just imports every public module would have caught this one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 4: exact pins that disagree with each other
&lt;/h2&gt;

&lt;p&gt;CTFd pins its dependencies exactly, which you might expect to rule all this out. It doesn't, because the resolver only checks the ranges each package declares, and old packages declare loose ones:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Flask-Migrate 2.5.3 with alembic 1.16.&lt;/strong&gt; Flask-Migrate's &lt;code&gt;flask db current&lt;/code&gt; passes &lt;code&gt;head_only=&lt;/code&gt; to &lt;code&gt;alembic.command.current()&lt;/code&gt;, which alembic no longer accepts. CTFd itself only calls &lt;code&gt;upgrade&lt;/code&gt; and &lt;code&gt;stamp&lt;/code&gt;, so this only bites an admin running &lt;code&gt;flask db current&lt;/code&gt; while debugging a migration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flask-Script 2.0.6 with Flask 2.1.&lt;/strong&gt; Flask-Script imports &lt;code&gt;flask._compat&lt;/code&gt;, removed in Flask 2.0, so it can't even be imported. Nothing in CTFd uses it anymore. It's a leftover pin.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pinning gives you reproducible installs, not compatible ones. When a pin is bumped, the packages that depend on it don't get re-checked unless something exercises them.&lt;/p&gt;

&lt;h2&gt;
  
  
  A bonus from application code
&lt;/h2&gt;

&lt;p&gt;The same check run against an application's own code found something different in pretix: an error message built as &lt;code&gt;_('... seat "%s" ... already sold.', s.name)&lt;/code&gt;. The seat name goes to &lt;code&gt;gettext_lazy&lt;/code&gt; as a second argument instead of being formatted into the string. The exception is created fine, but as soon as the API turns it into a response with &lt;code&gt;str(e)&lt;/code&gt;, it raises &lt;code&gt;TypeError&lt;/code&gt;. The user gets a 500 error instead of the validation message. It's a typo-level bug, but it lives in an error path, which is precisely the code that tests skip.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why tests didn't catch any of this
&lt;/h2&gt;

&lt;p&gt;Every one of these sits on a path that's easy to skip in a test suite: certificate &lt;em&gt;requests&lt;/em&gt; rather than certificates, cookies with &lt;em&gt;aware&lt;/em&gt; expiry dates, features that never appear in training data, a debugging CLI command, a module nothing imports, an error message. That isn't sloppiness. Most of these are in libraries, where the maintainers can't know which paths a given app will use. Mostly, it's what happens when the code is correct against the version it was written for, and then that version changes underneath it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I found them
&lt;/h2&gt;

&lt;p&gt;I built a tool for this, &lt;a href="https://github.com/youssef-elaasri/pipbreak" rel="noopener noreferrer"&gt;pipbreak&lt;/a&gt;. It reads the source of the installed packages and checks every import, attribute access and call that one package makes into another against what's actually installed, by importing the real modules and inspecting them. It treats the usual compatibility code (&lt;code&gt;try: ... except ImportError&lt;/code&gt;, version checks, &lt;code&gt;hasattr&lt;/code&gt; guards) as intentional, so it doesn't report the fallbacks that make code work across versions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip install pipbreak
pipbreak --deps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's not magic, and the first runs on these apps produced a lot of noise. On paperless-ngx's environment of 17,000 files, an early release reported 59 errors. Most of what I changed between releases was fixing false positives, until what was left was real: 7 errors on Linux, all genuine mismatches. It also has blind spots: arguments forwarded through &lt;code&gt;**kwargs&lt;/code&gt;, methods on objects whose type is only known at runtime, compiled extensions, and behaviour that changes behind an unchanged signature. It complements tests; it doesn't replace them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reports
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;channels: &lt;a href="https://github.com/django/channels/issues/2237" rel="noopener noreferrer"&gt;django/channels#2237&lt;/a&gt;, fix in &lt;a href="https://github.com/django/channels/pull/2238" rel="noopener noreferrer"&gt;#2238&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;nltk: &lt;a href="https://github.com/nltk/nltk/issues/4007" rel="noopener noreferrer"&gt;nltk/nltk#4007&lt;/a&gt;, fix in &lt;a href="https://github.com/nltk/nltk/pull/4008" rel="noopener noreferrer"&gt;#4008&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;pysaml2: &lt;a href="https://github.com/IdentityPython/pysaml2/issues/1048" rel="noopener noreferrer"&gt;IdentityPython/pysaml2#1048&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;llama-index: &lt;a href="https://github.com/run-llama/llama_index/issues/23871" rel="noopener noreferrer"&gt;run-llama/llama_index#23871&lt;/a&gt; and &lt;a href="https://github.com/run-llama/llama_index/issues/23872" rel="noopener noreferrer"&gt;#23872&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;CTFd: &lt;a href="https://github.com/CTFd/CTFd/issues/3147" rel="noopener noreferrer"&gt;CTFd/CTFd#3147&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;pretix: &lt;a href="https://github.com/pretix/pretix/issues/6722" rel="noopener noreferrer"&gt;pretix/pretix#6722&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you maintain one of these and I've got a detail wrong, tell me and I'll fix it here. And if a dependency update has broken your app in a way that sounds like this, I'd like to hear about it, especially if you think pipbreak would have missed it.&lt;/p&gt;

</description>
      <category>python</category>
      <category>dependencies</category>
      <category>django</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
