DEV Community

Taylor Wang
Taylor Wang

Posted on

I Bumped __version__ for 48 Hours. dist-info Never Read the File I Edited.

Have you ever bumped a package version, rerun the CLI, and watched the old number walk back out of stdout? I spent a messy forty-eight hours on that exact mismatch, and the trail was uglier than a simple cache. Locally the module attribute looked correct, yet the console script on a clean machine kept serving last week's dist-info. Why would two Python processes that import the same package disagree about a single version string?

I reconstructed the smallest tree that reproduced the split, because I did not want another folklore story about "pip being weird." The notes below are what I tried, what broke, and what I would actually repeat. No production metrics, no mystery customers, just a checkout that lied through two official version channels.

What I thought was broken

I started from a boring --version flag because boring flags still lie when packaging gets involved in the runtime. The source tree said 0.4.2, my editor confirmed the assignment, and a direct import inside the repo printed the new value. The installed console script, though, kept answering 0.1.0 as if the edit had never existed on disk. Was the suite green because pytest imported the tree, while users imported leftover metadata?

That last question sat with me longer than I want to admit after the first evening. I had rebuilt a wheel, then kept hacking the checkout like the previous install could not possibly still win. Field notes from those forty-eight hours follow, including the false fix an assistant reached for on a clean box.

Hours 0–12: I blamed bytecode, then I blamed the wrong interpreter

My first theory was stale __pycache__, because that theory is cheap and sometimes accidentally true. I deleted every .pyc I could find, reran the module, and still watched 0.1.0 come back from the generated entry point. Next I reinstalled with pip install -e . in one terminal and forgot the failing terminal was pinned to a different interpreter. Have you printed sys.executable in both shells before accusing packaging of being haunted?

Commands I actually ran, in roughly this embarrassed order:

find . -name '__pycache__' -type d -prune -exec rm -rf {} +
python -c "import demo_cli, inspect; print(demo_cli.__version__); print(inspect.getfile(demo_cli))"
python -m pip show demo-cli
which demo-cli
head -n 1 "$(which demo-cli)"
Enter fullscreen mode Exit fullscreen mode

The shebang on demo-cli pointed at a virtualenv I had not activated in the failing shell. After I activated it, pip show still listed 0.1.0 even though demo_cli.__version__ printed 0.4.2 from the same interpreter. That split is the whole story, and I almost filed it as an import shadow again.

The smallest tree that kept lying

I needed a checkout small enough to reinstall without leftover folklore from a larger app. The layout below is the reconstruction I kept, not a screenshot from some private monolith.

demo-cli/
  pyproject.toml
  src/demo_cli/__init__.py
  src/demo_cli/__main__.py
  tests/test_version_channels.py
  tools/where_is_this_package.py
Enter fullscreen mode Exit fullscreen mode

pyproject.toml declared a console script and a version that I later bumped by hand:

[project]
name = "demo-cli"
version = "0.4.2"
requires-python = ">=3.11"

[project.scripts]
demo-cli = "demo_cli.__main__:main"

[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"

[tool.setuptools.packages.find]
where = ["src"]
Enter fullscreen mode Exit fullscreen mode

And the package exposed both a module attribute and a flag that people actually paste into tickets:

# src/demo_cli/__init__.py
__version__ = "0.4.2"
Enter fullscreen mode Exit fullscreen mode
# src/demo_cli/__main__.py
from demo_cli import __version__


def main() -> None:
    print(__version__)


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

If you install that tree once as a regular wheel, then edit __init__.py without reinstalling, you get two truths. Which truth should a support inbox believe when someone pastes --version?

Hours 12–24: the assistant patched the symptom

I wanted a second machine that did not inherit my laptop's path experiments and half-activated environments. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I used MonkeyCode because free model access and a free server gave me a clean shell without borrowing another laptop. The assistant read pyproject.toml, saw the bump, and "fixed" the CLI by hardcoding a string inside main().

That patch made the local flag print 0.4.2 and made me feel clever for about twenty minutes. Then I ran importlib.metadata.version("demo-cli") and got 0.1.0 again, which is what installers, plugin loaders, and some tracers actually trust. If metadata and the module disagree, are you debugging product logic, or are you debugging an install you forgot to repeat?

I threw the hardcoded string away before it could rot in review. Hardcoding a version in main() hides the packaging bug from the next person, including future me on a Monday.

The artifact: ask both sources, then fail loudly

I now keep a tiny inspector next to the tests so the two version channels cannot drift in silence. It is ordinary Python, and it does not care which editor or model produced the last commit.

# tools/where_is_this_package.py
from __future__ import annotations

import importlib
import importlib.metadata
import inspect
import json
import sys
from pathlib import Path


def inspect_distribution(dist_name: str, import_name: str) -> dict:
    module = importlib.import_module(import_name)
    module_file = Path(inspect.getfile(module)).resolve()
    dist = importlib.metadata.distribution(dist_name)
    module_version = getattr(module, "__version__", None)
    return {
        "executable": sys.executable,
        "cwd": str(Path.cwd()),
        "import_name": import_name,
        "module_file": str(module_file),
        "module_version": module_version,
        "dist_name": dist_name,
        "dist_version": dist.version,
        "metadata_keys": list(dist.metadata.keys())[:12],
        "direct_url": _direct_url(dist),
        "versions_match": module_version == dist.version,
    }


def _direct_url(dist: importlib.metadata.Distribution) -> str | None:
    raw = dist.read_text("direct_url.json")
    return raw.strip() if raw else None


if __name__ == "__main__":
    dist_name = sys.argv[1] if len(sys.argv) > 1 else "demo-cli"
    import_name = sys.argv[2] if len(sys.argv) > 2 else "demo_cli"
    print(json.dumps(inspect_distribution(dist_name, import_name), indent=2))
Enter fullscreen mode Exit fullscreen mode

Run it twice: once through the interpreter you use for tests, and once through the shebang of the console script. If those JSON blobs disagree, stop debugging product logic and start debugging the install.

python tools/where_is_this_package.py demo-cli demo_cli
# Compare against the interpreter baked into the generated wrapper.
PY=$(head -n 1 "$(command -v demo-cli)" | tr -d '#!')
"$PY" tools/where_is_this_package.py demo-cli demo_cli
python -c "from importlib.metadata import version; print(version('demo-cli'))"
Enter fullscreen mode Exit fullscreen mode

I also added one pytest that refuses a green suite when the two strings drift. It is not clever, and that is exactly why it belongs in CI.

# tests/test_version_channels.py
import importlib.metadata

import demo_cli


def test_module_version_matches_installed_metadata() -> None:
    installed = importlib.metadata.version("demo-cli")
    assert demo_cli.__version__ == installed, (
        f"module {demo_cli.__version__!r} != dist-info {installed!r}; "
        "reinstall the project before trusting --version"
    )
Enter fullscreen mode Exit fullscreen mode

Decision table I wish I had on hour three

What I observed What it usually meant What I do next
Module file lives under the checkout, metadata still prints the old version A previous non-editable install left dist-info that nobody refreshed Reinstall, then rerun the inspector before editing more code
Module file lives under site-packages, and both versions are old The process never imported the tree I was editing Check the wrapper shebang and recreate the virtualenv
Versions match locally, then drift on a clean shell Laptop PYTHONPATH or an editable install was covering the real package Reproduce on a machine that never saw the checkout on sys.path
--version is correct, importlib.metadata.version() is not Someone hardcoded the flag and left metadata stale Delete the hardcoded string and make the test fail on drift
direct_url.json points at a file URL you do not remember An old editable install is still registered pip uninstall first, then install once with the intended mode

Would you have reached for that table, or would you have kept deleting __pycache__ like I did?

Hours 24–48: what actually broke on the clean shell

The console script had been generated from an earlier pip install . that copied files into site-packages. Editing src/demo_cli/__init__.py never refreshed demo_cli-0.1.0.dist-info/METADATA, and importlib.metadata.version() reads that file, not the buffer in your editor. An editable install would have linked the tree; the leftover non-editable install kept winning because it was already on sys.path.

This is not the same failure as a same-named folder shadowing an import, even though the symptoms rhyme if you only print __version__. One channel is a Python attribute you can assign in source. The other channel is packaging metadata that exists only after a build or an install step.

On the free server, there was no leftover checkout sitting at sys.path[0], so the assistant only saw site-packages. That cleanliness was useful, even when the first suggested patch was wrong. Locally I was gaslit by an import that looked editable and a metadata record that was still a wheel from last week.

What I would repeat, in this order:

  • Print sys.executable, the module file, and importlib.metadata.version() before changing another line of application code.
  • Uninstall until pip show fails, then install once in the mode you actually meant to test.
  • Run the inspector through the console-script shebang, not only through the interpreter you happen to have activated.
  • Keep a test that fails when the module attribute and the dist-info version drift apart.
  • Use a clean shell when the laptop has too much packaging folklore in the environment.

Limitations, and who should not copy this

This workflow will not save you if the version lives only in a compiled extension, a generated header, or a git tag that never enters pyproject.toml. It also will not catch a private index serving a different artifact than the tree you think you installed. If you vendor a copy of the package inside another repo, metadata on the outer distribution can still look consistent while the inner copy drifts.

Do not treat a free server as a replica of production, because it is only a clean interpreter plus a shell you can throw away. Do not let an assistant hardcode --version just to make a demo screenshot look right. And do not skip the uninstall step; two overlapping installs will happily keep both of your "fixes."

I would still use a clean box the next time a flag and a library disagree about a constant. If you want that box in the same loop as the model that is reading the traceback, MonkeyCode's free server option was enough for this reconstruction. The useful part remains the inspector and the failing test, even if you run them on any other empty virtualenv.

Top comments (0)