Skip to content

feat(doctor): report a leftover GUI OpenCV after an upgrade - #584

Open
chinmayajha wants to merge 2 commits into
mozarkai:mainfrom
chinmayajha:fix/doctor-detect-dual-opencv
Open

chinmayajha wants to merge 2 commits into
mozarkai:mainfrom
chinmayajha:fix/doctor-detect-dual-opencv

Conversation

@chinmayajha

@chinmayajha chinmayajha commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator

Closes #579

The failure

opencv-python and opencv-python-headless are two distributions that ship the same cv2 module. When #577 moved the dependency to the headless build, that fixed fresh installs on minimal Linux images and nothing else: pip does not uninstall opencv-python just because the new metadata names a different distribution. An upgraded environment ends up holding both, one import name is provided twice, and which one wins depends on install order.

Observed while testing #577 — force-reinstalling over an environment that already had opencv-python left the GUI build in place, and optics --version kept failing on libxcb.so.1 exactly as before the fix.

The fix

A new opencv row in optics doctor's Core section, fired when both distributions resolve through importlib.metadata:

  ⚠️ opencv            opencv-python and opencv-python-headless are both installed;
                       which one provides cv2 depends on install order
      → pip uninstall -y opencv-python && pip install --force-reinstall opencv-python-headless==4.11.0.86

Release notes were the alternative. They are read once, and this failure surfaces much later as a mysterious import error — doctor is where a user already goes when the CLI misbehaves.

Why the repair reinstalls

Uninstalling alone is not enough, and would be actively harmful. The two wheels do not merely share an import name — they write the same cv2/ files, and whichever installed second overwrote the first's. pip uninstall opencv-python walks its RECORD and deletes those files, leaving opencv-python-headless installed according to its metadata and unimportable in fact. So the hint lays the survivor back down, pinned to the version already recorded so the repair re-resolves nothing and a lock-managed project stays on its lock.

Severity: warn, not fail

Doctor's module docstring is explicit that machine checks report warnings so a fresh machine gets a readable to-do list, and reserves failures for the project-config rows that run_doctor(check=True) turns into a non-zero exit. Two further reasons this row belongs on the warning side:

  • On any machine where doctor runs at all, cv2 already imported — the CLI's startup guard would have aborted otherwise. The row is a latent portability problem (it bites when the same environment is moved to a minimal image), not something blocking the run in front of the user.
  • A fail would make optics doctor --check exit non-zero in CI for a condition that is not breaking anything on that runner.

The row is deliberately not added to _mandatory_hints, for the same reason: that list is the "before your first real run" call-to-action for the enabled driver's own must-haves, and a dual OpenCV install does not block the first run on the machine reporting it.

Why the hint is environment-aware

A bare pip uninstall is wrong advice in environments this project already classifies, so helper/environment.py grows a replace_command() beside plan_install() and project_add_command() — the module CLAUDE.md designates for "where an install is allowed to write", and the one abort.reinstall_guidance() already consumes for this class of advice:

  • tool env (pipx / uv tool) — its manager rebuilds the whole environment from current metadata, which drops the stale distribution and writes the survivor fresh in one command, and is the only form that survives the next upgrade. → pipx reinstall optics-framework / uv tool install --reinstall optics-framework
  • any env without pip (uv-created venvs included) — nothing to run. → uv pip uninstall --python <interpreter> … && uv pip install --python <interpreter> --reinstall …
  • everything else, including lock-managed projects → the plain pip pair

A lock-managed environment gets no case of its own, unlike plan_install: removing a distribution the lockfile does not list, and restoring the exact version it does, converges on the lock rather than drifting behind the manager's back, so there is nothing for the next sync to undo.

check_core() now calls detect() once and passes the result to both describe() and the new row, instead of detecting twice.

Known blind spot

The row cannot fire in the fully-broken state. If the GUI cv2 fails to import, helper/cli.py's import guard aborts before DoctorCommand exists, and the user gets the generic startup panel from abort.reinstall_guidance(). That is by design here: the row is preventive, and catches the dual install on the machine where the environment is built — which still imports cv2 fine — before it is shipped to an image that does not. Making the startup guard itself name the duplicate distribution is a change to abort.py and out of scope for this one.

Stragglers

Checked, none found. pyproject.toml and poetry.lock name only opencv-python-headless; ALL_ENGINES in helper/setup.py has no OpenCV entry (it is a core dependency, not an installable extra); the opencv mentions in helper/live.py are comments about C-extension stderr behaviour and the one in docs/usage/REST_API_usage.md is an image_detection engine key, neither of which is the distribution name. #577 touched no docs, so there is nothing stale to correct.

Testing

  • TestOpenCvRow in tests/units/helpers/test_doctor.py — both installed fires the row with the repair command and the recorded headless pin; headless-only, GUI-only and neither-installed are all silent; missing metadata leaves the other Core rows intact; the hint follows the install environment. importlib.metadata and detect() are both mocked, so nothing depends on the machine running the suite.
  • TestReplaceCommand in tests/units/helpers/test_environment.py — the full decision table on constructed Environment values, including the no-pip-and-no-uv corner and a guard that the survivor is always reinstalled.
  • Full suite green: 1371 passed, 2 xfailed.
  • pre-commit run --files <changed> clean (ruff, bandit, whitespace, EOF, gitleaks).

Switching the dependency to opencv-python-headless fixed fresh installs on
minimal Linux images, but pip does not uninstall opencv-python just because
the new metadata names a different distribution. Both ship the same cv2
module, so an upgraded environment carries two distributions for one import
name and which one wins depends on install order -- observed while testing
the switch: a force-reinstall left the GUI build in place and
`optics --version` kept failing on libxcb.so.1.

Doctor is where a user goes when the CLI misbehaves, so it now says so:
resolving both distributions through importlib.metadata is cheap and
unambiguous, and the row carries the command that removes the GUI build.

The row is a warning, not a failure. On any machine where doctor runs at all
the import already succeeded, so this is a latent portability problem rather
than something blocking the run in front of the user -- and doctor reserves
failures for the project-config rows that `--check` gates CI on.

The remedy is environment-aware for the same reason installs are: a bare
`pip uninstall` edits the pip on PATH, which in a pipx environment is some
other environment entirely, and in a lock-managed one is undone by the next
sync. removal_command() mirrors plan_install()'s ownership rules -- prune
through the manager for a project environment, name the interpreter for a
tool environment, plain pip everywhere else.
Comment thread optics_framework/helper/doctor.py Outdated
Comment thread optics_framework/helper/doctor.py Outdated
Comment thread optics_framework/helper/environment.py Outdated
The removal hint stopped at `pip uninstall -y opencv-python`, which is
harmful advice in exactly the environment the row fires in. The two
distributions do not merely share an import name -- they write the same
cv2 files, and the second install overwrote the first's. Uninstalling one
therefore deletes files the survivor's metadata still claims, turning an
ambiguous install into one where cv2 no longer imports at all.

The hint now reinstalls the headless build after removing the GUI one,
pinned to the version already recorded so the repair re-resolves nothing.
A tool environment is rebuilt through its manager instead, which does both
halves in one command and cannot leave a half-removed cv2 behind.

That also drops the lock-managed special case. Pruning was the wrong
instrument twice over: it leaves the survivor's files deleted just the same,
and the objection it was answering does not apply here -- removing a
distribution the lockfile does not list, and restoring the version it does,
converges on the lock rather than drifting behind the manager's back.
@sonarqubecloud

Copy link
Copy Markdown

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Upgrading to the headless OpenCV leaves the GUI build installed

1 participant