Skip to content

Name the installable package in missing dependency errors - #862

Merged
d-chambers merged 5 commits into
devfrom
install_name_messages
Aug 11, 2026
Merged

Name the installable package in missing dependency errors#862
d-chambers merged 5 commits into
devfrom
install_name_messages

Conversation

@d-chambers

@d-chambers d-chambers commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Description

Missing optional dependency messages reported the import name, which is not always something you can install. Scanning a directory of Sintela protobuf files reported:

MissingOptionalDependencyError: DASCore found files that can be read if additional
packages are installed. The needed packages and the found number of files are:
{'google.protobuf.descriptor_pb2': 10308}

Helpful that it found them, but nothing there says the package to install is protobuf.

Implementation notes:

  • _INSTALL_NAMES in dascore.utils.misc maps import names which differ from their distribution (google.protobuf -> protobuf, yaml -> pyyaml); submodules resolve to their parent. _get_install_message renders the pip/uv commands and is also used by optional_import and the numba JIT error.
  • MissingOptionalDependencyError carries an install_name, so dc.scan aggregates on the installable name rather than re-parsing the message. It defaults on the class so subclasses which skip the init still have it.
  • Install commands are only built from structured data or the legacy "<module> is not installed" message form, so an arbitrary message from a third party FiberIO can't turn into pip install <first word>.

User-facing changes

  • Missing optional dependency errors now name the package to install and give the command to install it. Scanning files whose reader needs an uninstalled package reports protobuf (10308 files) and Install with `pip install protobuf` or `uv pip install protobuf` instead of the import name google.protobuf.descriptor_pb2, which is not something you can install.
  • An ImportError raised inside an installed package is no longer reported as a missing install, since installing the package again wouldn't help. Those report could not be imported (<original error>) with the original error as the cause, and no install advice.

Breaking changes

None.

Changelog

  • changed: missing optional dependency messages name the package to install and give the install command, and MissingOptionalDependencyError carries an install_name. An ImportError raised inside an installed package is reported as a failed import rather than a missing install.

Checklist

I have (if applicable):

  • referenced the GitHub issue this PR closes.
  • summarized user-facing and breaking changes above, since release notes are drafted from PRs.
  • documented the new feature with docstrings and/or appropriate doc page.
  • included tests. See testing guidelines.
  • added the "ready_for_review" tag once the PR is ready to be reviewed.

Summary by CodeRabbit

  • Bug Fixes
    • Improved optional-dependency errors with clearer installation guidance.
    • Correctly distinguishes missing packages from import failures within installed packages.
    • Provides accurate package names and install commands when dependencies are identifiable.
    • Improved reporting of optional-dependency scan results with sorted package file counts.
  • Documentation
    • Updated the API changelog with dependency guidance, configuration updates, expanded I/O capabilities, and public API changes.
  • Tests
    • Expanded coverage for dependency detection, installation guidance, and import failure handling.

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0985aa61-1bd6-417a-bb7b-c911805b2c78

📥 Commits

Reviewing files that changed from the base of the PR and between 3053d25 and 1b36041.

📒 Files selected for processing (1)
  • tests/test_utils/test_misc.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/test_utils/test_misc.py

📝 Walkthrough

Walkthrough

The change adds install-name metadata to optional dependency errors, distinguishes missing modules from internal import failures, formats conditional pip and uv installation guidance, and normalizes dependency reporting in IO scans.

Changes

Optional dependency diagnostics

Layer / File(s) Summary
Dependency error contract and import handling
dascore/exceptions.py, dascore/utils/misc.py, tests/test_utils/test_misc.py
MissingOptionalDependencyError stores an optional install name. optional_import resolves distribution names, formats installation guidance, and preserves internal import failures. Tests cover renamed packages and failure cases.
IO scan normalization and reporting
dascore/io/core.py, tests/test_io/test_io_core.py
IO scans derive normalized install names from exception metadata, dependency names, or legacy messages. Reports now show sorted file counts and conditional installation commands.
Shared install guidance
dascore/utils/jit.py, docs/changelog.qmd
The missing-numba error uses the shared installation-message helper. The changelog records the dependency diagnostic changes.

Possibly related PRs

  • DASDAE/dascore#797: Both changes modify optional_import to improve missing-dependency handling and typing.

Suggested labels: patch, documentation

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: reporting installable package names in missing dependency errors.
Description check ✅ Passed The description explains the problem, implementation, user-facing changes, breaking changes, and testing status with the required sections.
Docstring Coverage ✅ Passed Docstring coverage is 81.48% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch install_name_messages

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
dascore/utils/misc.py (1)

534-552: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add explicit exception chaining.

Line 552 creates a replacement exception but does not explicitly identify ex as its cause. Raise MissingOptionalDependencyError with from ex. This resolves Ruff B904 and retains the failed import in the traceback.

Proposed fix
-            raise MissingOptionalDependencyError(msg, install_name=install_name)
+            raise MissingOptionalDependencyError(
+                msg, install_name=install_name
+            ) from ex
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dascore/utils/misc.py` around lines 534 - 552, Update the on_missing ==
"raise" branch in the ImportError handler to raise
MissingOptionalDependencyError with the caught ex explicitly chained as its
cause, preserving the existing message and install_name arguments.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@dascore/io/core.py`:
- Line 1446: Update the directory-scan path around fiber_io.scan so
MissingOptionalDependencyError is caught and handled like the existing
optional-dependency path, incrementing missing_optional_deps via
_get_missing_install_name(ex) and continuing the scan instead of aborting
dc.scan.

---

Nitpick comments:
In `@dascore/utils/misc.py`:
- Around line 534-552: Update the on_missing == "raise" branch in the
ImportError handler to raise MissingOptionalDependencyError with the caught ex
explicitly chained as its cause, preserving the existing message and
install_name arguments.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0dd6ab42-c5d1-496e-b292-eeb134c2260a

📥 Commits

Reviewing files that changed from the base of the PR and between 6b875e5 and 4e06c83.

📒 Files selected for processing (7)
  • dascore/exceptions.py
  • dascore/io/core.py
  • dascore/utils/jit.py
  • dascore/utils/misc.py
  • docs/changelog.qmd
  • tests/test_io/test_io_core.py
  • tests/test_utils/test_misc.py

Comment thread dascore/io/core.py
source = fiber_io.scan(resource, **scan_kwargs)
except MissingOptionalDependencyError as ex:
missing_optional_deps[ex.msg.split(" ")[0]] += 1
missing_optional_deps[_get_missing_install_name(ex)] += 1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find directory FiberIO implementations and inspect their scan methods.
ast-grep outline dascore/io --items all --type class,function
rg -n -P -C 8 --glob '*.py' 'input_type\s*=\s*["'\'']directory["'\'']|def\s+scan\s*\(' dascore/io

# Find directory scan paths that use optional imports or raise missing-dependency errors.
rg -n -P -C 6 --glob '*.py' '\boptional_import\s*\(|\bMissingOptionalDependencyError\b' dascore/io

Repository: DASDAE/dascore

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- scan dispatch ---'
cat -n dascore/io/core.py | sed -n '1385,1460p'

printf '%s\n' '--- directory FiberIO classes ---'
python3 - <<'PY'
import ast
from pathlib import Path

for path in Path("dascore/io").rglob("*.py"):
    try:
        tree = ast.parse(path.read_text())
    except Exception:
        continue
    for node in ast.walk(tree):
        if not isinstance(node, ast.ClassDef):
            continue
        input_type = None
        bases = []
        for base in node.bases:
            if isinstance(base, ast.Name):
                bases.append(base.id)
            elif isinstance(base, ast.Attribute):
                bases.append(base.attr)
        for stmt in node.body:
            if isinstance(stmt, ast.Assign):
                for target in stmt.targets:
                    if isinstance(target, ast.Name) and target.id == "input_type":
                        if isinstance(stmt.value, ast.Constant):
                            input_type = stmt.value.value
        if input_type == "directory":
            scan = next(
                (x for x in node.body if isinstance(x, (ast.FunctionDef, ast.AsyncFunctionDef))
                 and x.name == "scan"),
                None,
            )
            print(path, node.name, "bases=", bases,
                  "scan_lines=", (scan.lineno, scan.end_lineno) if scan else None)
PY

printf '%s\n' '--- XMLBinary imports and scan helpers ---'
rg -n -C 8 --glob '*.py' \
  'optional_import|MissingOptionalDependencyError|def (_read_xml_metadata|_paths_to_scan_patches|scan)' \
  dascore/io/xml_binary dascore/core dascore/utils.py dascore/exceptions.py 2>/dev/null || true

printf '%s\n' '--- all directory declarations and exception raises ---'
rg -n -C 4 --glob '*.py' \
  'input_type\s*=\s*["'\'']directory["'\'']|raise\s+MissingOptionalDependencyError|MissingOptionalDependencyError\s*=' \
  dascore

Repository: DASDAE/dascore

Length of output: 13842


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- scan dispatch ---'
cat -n dascore/io/core.py | sed -n '1385,1460p'

printf '%s\n' '--- directory FiberIO classes ---'
python3 - <<'PY'
import ast
from pathlib import Path

for path in Path("dascore/io").rglob("*.py"):
    tree = ast.parse(path.read_text())
    for node in ast.walk(tree):
        if not isinstance(node, ast.ClassDef):
            continue
        input_type = None
        bases = []
        for base in node.bases:
            bases.append(base.id if isinstance(base, ast.Name) else getattr(base, "attr", None))
        for stmt in node.body:
            if isinstance(stmt, ast.Assign):
                for target in stmt.targets:
                    if isinstance(target, ast.Name) and target.id == "input_type":
                        if isinstance(stmt.value, ast.Constant):
                            input_type = stmt.value.value
        if input_type == "directory":
            scan = next(
                (x for x in node.body if isinstance(x, ast.FunctionDef) and x.name == "scan"),
                None,
            )
            print(path, node.name, bases,
                  (scan.lineno, scan.end_lineno) if scan else None)
PY

printf '%s\n' '--- XMLBinary dependency paths ---'
rg -n -C 8 --glob '*.py' \
  'optional_import|MissingOptionalDependencyError|def (_read_xml_metadata|_paths_to_scan_patches|scan)' \
  dascore/io/xml_binary dascore 2>/dev/null | head -300

printf '%s\n' '--- directory declarations and dependency exceptions ---'
rg -n -C 4 --glob '*.py' \
  'input_type\s*=\s*["'\'']directory["'\'']|raise\s+MissingOptionalDependencyError|MissingOptionalDependencyError\s*=' \
  dascore

Repository: DASDAE/dascore

Length of output: 23056


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- XMLBinary modules ---'
cat -n dascore/io/xml_binary/core.py | sed -n '1,90p'
cat -n dascore/io/xml_binary/utils.py | sed -n '1,270p'

printf '%s\n' '--- XML conversion definition and imports ---'
rg -n -C 8 --glob '*.py' \
  'xml_to_dict|def\s+xml_to_dict|from .*xml|import .*xml|optional_import' \
  dascore

printf '%s\n' '--- FiberIO registry and directory resolution ---'
rg -n -C 8 --glob '*.py' \
  'register|FIBER|fiber_io|input_type|is_directory_format|_get_fiber_io_and_req_type' \
  dascore/io/__init__.py dascore/io/core.py dascore 2>/dev/null | head -500

Repository: DASDAE/dascore

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- io package registration ---'
wc -l dascore/io/__init__.py
cat -n dascore/io/__init__.py | sed -n '1,240p'

printf '%s\n' '--- manager and FiberIO lookup definitions ---'
ast-grep outline dascore/io/core.py --items all --type class,function | sed -n '1,180p'
rg -n -C 12 \
  'class _FiberIOManager|class FiberIO|def _get_fiber_io_and_req_type|fiber_io_classes|FIBER_IO|entry_points|importlib.metadata|register' \
  dascore/io/core.py dascore/io/__init__.py

Repository: DASDAE/dascore

Length of output: 18299


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import ast
from pathlib import Path

tree = ast.parse(Path("dascore/io/core.py").read_text())

iter_scan = next(
    node for node in ast.walk(tree)
    if isinstance(node, ast.FunctionDef) and node.name == "_iter_scan_results"
)

directory_if = next(
    node for node in ast.walk(iter_scan)
    if isinstance(node, ast.If)
    and isinstance(node.test, ast.Compare)
    and any(
        isinstance(part, ast.Constant) and part.value == "directory"
        for part in [node.test.left, *node.test.comparators]
    )
)
directory_scan = next(
    node for node in ast.walk(directory_if)
    if isinstance(node, ast.Assign)
    and isinstance(node.value, ast.Call)
    and isinstance(node.value.func, ast.Attribute)
    and node.value.func.attr == "scan"
)

dependency_handler = next(
    node for node in ast.walk(iter_scan)
    if isinstance(node, ast.ExceptHandler)
    and isinstance(node.type, ast.Name)
    and node.type.id == "MissingOptionalDependencyError"
)
non_directory_try = next(
    node for node in ast.walk(iter_scan)
    if isinstance(node, ast.Try)
    and dependency_handler in node.handlers
)

def contains(node, target):
    return any(child is target for child in ast.walk(node))

print("directory_scan_line:", directory_scan.lineno)
print("directory_scan_inside_dependency_try:", contains(non_directory_try, directory_scan))
print("plugin_entry_point:", "dascore.fiber_io" in Path("dascore/io/core.py").read_text())
print("registers_by_input_type:", any(
    isinstance(node, ast.Call)
    and isinstance(node.func, ast.Attribute)
    and node.func.attr == "setdefault"
    and any(isinstance(arg, ast.Attribute) and arg.attr == "input_type" for arg in node.args)
    for node in ast.walk(tree)
))
PY

Repository: DASDAE/dascore

Length of output: 277


Handle missing optional dependencies for directory scans.

fiber_io.scan at line 1438 runs outside the MissingOptionalDependencyError handler. A directory FiberIO plugin that requires an optional package can abort dc.scan instead of aggregating the dependency and continuing. Add equivalent handling for directory scans.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dascore/io/core.py` at line 1446, Update the directory-scan path around
fiber_io.scan so MissingOptionalDependencyError is caught and handled like the
existing optional-dependency path, incrementing missing_optional_deps via
_get_missing_install_name(ex) and continuing the scan instead of aborting
dc.scan.

@codecov

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (6b875e5) to head (1b36041).

Additional details and impacted files
@@            Coverage Diff            @@
##               dev      #862   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files          176       176           
  Lines        19276     19313   +37     
=========================================
+ Hits         19276     19313   +37     
Flag Coverage Δ
network 48.08% <25.00%> (-0.04%) ⬇️
unittests 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@d-chambers

Copy link
Copy Markdown
Contributor Author

@codex review

@d-chambers d-chambers left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tests in this PR seem like they're a bit overkill. Let's remove some of them, just keep what we need for coverage and really essential functionality.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4e06c83d2a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread dascore/utils/misc.py Outdated
Comment on lines +472 to +474
if not (failed := error.name or ""):
return False
return import_name == failed or import_name.startswith(f"{failed}.")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Distinguish import-from failures from missing parent modules

When an installed submodule executes something like from package import missing_symbol, Python raises an ImportError whose name is package; this prefix check therefore returns true for optional_import("package.submodule") even though the requested module exists. The caller is consequently told to reinstall package, contrary to the intended handling of failures inside installed packages. Account for import-from errors, such as by checking the exception type and path, rather than treating every named parent as missing.

Useful? React with 👍 / 👎.

Comment thread dascore/io/core.py
Comment on lines 1445 to 1447
except MissingOptionalDependencyError as ex:
missing_optional_deps[ex.msg.split(" ")[0]] += 1
missing_optional_deps[_get_missing_install_name(ex)] += 1
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve internal import diagnostics during scans

When optional_import correctly identifies an ImportError from inside an installed package, it raises MissingOptionalDependencyError with both install_name and name unset. This aggregation then stores the failure under an empty key and discards its original diagnostic, so dc.scan eventually reports unknown (...) and still claims that additional packages need installing instead of showing the actionable could not be imported (<original error>) message. Preserve these unidentifiable exceptions separately or surface their original diagnostic rather than aggregating them as missing installs.

Useful? React with 👍 / 👎.

Optional dependency errors reported the import name, which is not always
something that can be installed. Scanning a directory of Sintela protobuf
files reported {'google.protobuf.descriptor_pb2': 10308}, leaving no
indication that protobuf is the package to install.

Missing dependency messages now name the package and give the pip and uv
commands to install it, and MissingOptionalDependencyError carries the
install name so scan can aggregate on it.

An ImportError raised inside an installed package is no longer reported
as a missing install, since installing the package again wouldn't help.
Consolidate the install name tests down to what covers the behavior,
and make the failed import the explicit cause of the raised error.
@d-chambers
d-chambers force-pushed the install_name_messages branch from 4e06c83 to 0a4cf24 Compare August 11, 2026 12:58

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
tests/test_utils/test_misc.py (1)

465-486: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Cover the full internal-import failure contract.

This test exercises only on_missing="raise" and verifies the source errors only through str(error). Add an on_missing="ignore" case that returns None, and assert that MissingOptionalDependencyError retains the corresponding ModuleNotFoundError or ImportError through its public cause or attribute. A regression in either contract can otherwise pass this test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_utils/test_misc.py` around lines 465 - 486, Extend
test_broken_install_gives_no_install_advice to cover both on_missing="raise" and
on_missing="ignore": assert the ignore path returns None, and for the raise path
verify MissingOptionalDependencyError preserves the original ModuleNotFoundError
or ImportError via its public cause or error attribute, not only through the
rendered message.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@tests/test_utils/test_misc.py`:
- Around line 465-486: Extend test_broken_install_gives_no_install_advice to
cover both on_missing="raise" and on_missing="ignore": assert the ignore path
returns None, and for the raise path verify MissingOptionalDependencyError
preserves the original ModuleNotFoundError or ImportError via its public cause
or error attribute, not only through the rendered message.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0ceb9130-75f6-4ae4-94a5-d7cb8e01d903

📥 Commits

Reviewing files that changed from the base of the PR and between 4e06c83 and 0a4cf24.

📒 Files selected for processing (3)
  • dascore/utils/misc.py
  • tests/test_io/test_io_core.py
  • tests/test_utils/test_misc.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • dascore/utils/misc.py
  • tests/test_io/test_io_core.py

@coderabbitai coderabbitai Bot added documentation Improvements or additions to documentation patch related to Patch class labels Aug 11, 2026
@d-chambers d-chambers added the ready_for_review PR is ready for review label Aug 11, 2026
@d-chambers
d-chambers merged commit 29472a3 into dev Aug 11, 2026
34 checks passed
@d-chambers
d-chambers deleted the install_name_messages branch August 11, 2026 14:20
@github-actions

Copy link
Copy Markdown

✅ Documentation built:
👉 Download
Note: You must be logged in to github and a DASDAE member to access the link.

@d-chambers d-chambers removed the ready_for_review PR is ready for review label Aug 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation patch related to Patch class

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant