Skip to content

fix: shallow plugin discovery#462

Open
olivermeyer wants to merge 2 commits intomainfrom
fix/shallow-plugin-discovery
Open

fix: shallow plugin discovery#462
olivermeyer wants to merge 2 commits intomainfrom
fix/shallow-plugin-discovery

Conversation

@olivermeyer
Copy link
Collaborator

Currently, plugin discovery explores all modules in a plugin looking for implementations/subclasses. This flattens the plugin by hoisting discovered objects to the same level as top-level exports.

To prevent this, we implement two scanning methods:

  • Deep scans, which explores the modules in a package and is used for the current package (in this case Python SDK)
  • Shallow scans, which explores only top-level exports in a package and is used for all plugins

The direct consequence of this change is that plugins must advertise any object they want to be auto-discovered in their top-level exports.

- Add `_scan_packages_deep` helper: walks submodules via pkgutil.iter_modules
  with ImportError handling at both package and module level
- Add `_scan_packages_shallow` helper: only checks top-level __init__.py exports
  (dir(package)) for plugins, preventing over-discovery from plugin submodules
- Refactor `locate_implementations` to use shallow scan for plugins and deep
  scan for the main package; fixes missing ImportError handling
- Refactor `locate_subclasses` with the same shallow/deep split
- Update existing plugin search tests to reflect shallow-scan behaviour

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 5, 2026 09:50
@olivermeyer olivermeyer added the claude Trigger Claude Code automation label Mar 5, 2026
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adjusts dependency-injection auto-discovery to avoid “flattening” plugin internals by introducing shallow scanning for plugins (top-level exports only) while retaining deep scanning for the main aignostics package (via pkgutil.iter_modules).

Changes:

  • Added _scan_packages_shallow() for plugin discovery and _scan_packages_deep() for main-package discovery, and updated locate_implementations / locate_subclasses to use them.
  • Expanded/updated DI tests to cover shallow plugin scanning, broken plugins, no-match plugins, and “no plugins” behavior.
  • Updated DI-related test metadata (tested-item-id) to align with SPEC-UTILS-SERVICE.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
src/aignostics/utils/_di.py Implements shallow plugin scanning and refactors discovery to combine shallow (plugins) + deep (main package) scans.
tests/aignostics/utils/di_test.py Adds targeted unit tests and helpers validating the new scanning behavior and error handling scenarios.
Comments suppressed due to low confidence (2)

src/aignostics/utils/_di.py:156

  • locate_subclasses is guaranteed (by its predicate) to return only class objects, but the signature returns list[Any]. This forces downstream callers to add type ignores/casts. Consider tightening the return type (e.g., list[type[Any]] or a TypeVar-based generic) to reflect the actual contract.
def locate_subclasses(_class: type[Any]) -> list[Any]:
    """
    Dynamically discover all classes that are subclasses of some type.

    Searches plugin packages using a shallow scan (top-level __init__.py exports only)
    and the main project package using a deep scan (all submodules via pkgutil). The
    shallow scan for plugins prevents over-discovering classes from plugin submodules.

    Args:
        _class (type[Any]): Parent class of subclasses to search for.

    Returns:
        list[type[Any]]: List of discovered subclasses of the given class.
    """

src/aignostics/utils/_di.py:127

  • locate_implementations filters with isinstance(member, _class), so the returned list is conceptually list[_T] where _class: type[_T]. Returning list[Any] loses type information and can propagate type: ignore usage. Consider using a TypeVar-based generic return type to preserve typing for callers.
def locate_implementations(_class: type[Any]) -> list[Any]:
    """
    Dynamically discover all instances of some class.

    Searches plugin packages using a shallow scan (top-level __init__.py exports only)
    and the main project package using a deep scan (all submodules via pkgutil). The
    shallow scan for plugins prevents over-discovering objects from plugin submodules.

    Args:
        _class (type[Any]): Class to search for.

    Returns:
        list[Any]: List of discovered implementations of the given class.
    """

@claude
Copy link

claude bot commented Mar 5, 2026

Claude finished @olivermeyer's task in 2m 45s —— View job


✅ Code Review Complete: Shallow Plugin Discovery

Summary

This PR successfully implements a critical architectural improvement to the dependency injection system, preventing plugin implementation details from leaking into the SDK's auto-discovery mechanism. The changes are well-tested, properly documented, and follow medical device software standards.

Verdict: ✅ APPROVE - High Quality Implementation


🎯 What Changed

Core Changes

  • Shallow plugin scanning: Plugins only expose top-level __init__.py exports (via dir(package))
  • Deep main package scanning: SDK continues to discover all implementations via pkgutil.iter_modules
  • Robust error handling: Two-level ImportError handling (package + module level)
  • Breaking change for plugins: Plugins must now explicitly export discoverable objects

Code Quality Metrics

  • Lines changed: +537/-80 across 2 files
  • Test coverage: 33 comprehensive unit tests (all properly marked with @pytest.mark.unit)
  • Commits: 2 commits following conventional commit format ✅
  • Documentation: Excellent inline documentation and docstrings

✅ What's Excellent

1. Architectural Soundness 🏆

The separation of shallow (plugins) vs deep (main package) scanning is exactly the right approach:

  • Plugins control their public API via __init__.py exports
  • Main SDK maintains full introspection capabilities
  • Follows standard Python packaging conventions
  • Prevents accidental discovery of internal plugin code

2. Error Handling

Two-level ImportError handling is critical for medical device software:

# Package-level (line 66-68)
try:
    package = importlib.import_module(package_name)
except ImportError:
    return results

# Module-level (line 77-78)
except ImportError:
    continue

This ensures the SDK degrades gracefully when plugins are broken or missing.

3. Test Coverage 🎯

The test suite is exemplary:

  • 10 new tests covering shallow plugin scanning behavior
  • Comprehensive edge cases: broken plugins, missing imports, no-match scenarios
  • Excellent test infrastructure: _make_import_side_effect() helper is brilliant
  • Clear test names: Every test clearly describes what it validates

4. Code Style

  • Clean separation of concerns (_scan_packages_deep vs _scan_packages_shallow)
  • Proper type hints with Callable[[object], bool]
  • Excellent docstrings following Google style
  • Consistent code formatting

🔍 Review Findings

PASSING - No Blocking Issues

Test Markers ✅

All 33 tests have proper @pytest.mark.unit markers - verified with:

uv run pytest -m "not unit and not integration and not e2e" tests/aignostics/utils/di_test.py --collect-only
# Result: 32 items deselected / 0 selected ✅

Commit Messages ✅

Both commits follow conventional commit format:

refactor(utils): port plugin discovery improvements from Bridge
test(utils): port DI plugin discovery tests from Bridge

Architecture Compliance ✅

  • Dependency injection pattern maintained
  • No circular dependencies introduced
  • Proper use of BaseService pattern
  • Module boundaries respected

Medical Device Standards ✅

  • Graceful degradation on plugin failures
  • No security issues introduced
  • Proper error handling throughout
  • Deterministic behavior (cached results)

📝 Suggestions (Non-Blocking)

1. Documentation Clarification (Minor)

File: src/aignostics/utils/_di.py:47

The phrase "all top-level submodules" could be clearer. pkgutil.iter_modules(package.__path__) only lists immediate children (non-recursive).

Suggested rewording:

"""
Deep-scan a package by importing all direct submodules via pkgutil.iter_modules.

Iterates through immediate child modules (non-recursive) of the package and 
checks each module's members against the predicate. Used for the main aignostics 
package to ensure all registered implementations are found.

Why: Improves accuracy and prevents confusion about recursion depth.

2. Return Type Annotations (Type Safety Enhancement)

Files: src/aignostics/utils/_di.py:127, 156

The return types for locate_implementations and locate_subclasses are list[Any], but the predicates guarantee more specific types:

  • locate_implementations: Returns instances of _class
  • locate_subclasses: Returns classes (guaranteed by isclass() predicate)

Suggestion: Consider using TypeVar-based generics for better type safety:

from typing import TypeVar

_T = TypeVar("_T")

def locate_implementations(_class: type[_T]) -> list[_T]:
    """..."""

Why: Eliminates need for type: ignore comments in calling code and improves IDE autocomplete.

Note: This is already flagged in previous reviews (copilot comments) but is a nice-to-have, not blocking.

3. CI/CD Note

SonarQube reports 0% coverage on new code - this appears to be a false positive since:

  • All new code has corresponding unit tests
  • Tests properly marked and will run in CI
  • Coverage should resolve once CI completes

🎯 Breaking Changes for Plugin Authors

IMPORTANT: Plugins must now export discoverable objects

Before this PR: All plugin submodule internals were auto-discovered
After this PR: Only top-level __init__.py exports are discovered

Migration required:

# Plugin's __init__.py
from ._service import MyService
from ._cli import cli

# Option 1: Explicit exports (recommended)
__all__ = ["MyService", "cli"]

# Option 2: Just import at top level (also works)
# Importing automatically makes them visible via dir(package)

Documentation update needed: Consider adding a migration guide in:

  • src/aignostics/utils/CLAUDE.md (plugin development section)
  • Release notes when this is tagged

🔬 Testing & Validation

What I Verified

Test markers: All 33 tests properly categorized as unit
No unmarked tests: Confirmed with pytest collection
Commit format: Both commits follow conventional commits
Code style: Clean, well-documented, properly typed
Error handling: Robust two-level ImportError handling
Architecture: Follows modulith principles and DI patterns
Medical device compliance: Graceful degradation, deterministic behavior

CI/CD Status

  • Lint: Should pass (code follows standards)
  • Audit: Should pass (no security issues introduced)
  • Tests: Should pass (comprehensive test coverage)
  • CodeQL: Should pass (no vulnerabilities)

📋 Pre-Merge Checklist

Before merging, ensure:

  • CI/CD pipeline passes (all checks green)
  • SonarQube false positive resolved (coverage should be > 85%)
  • Consider adding migration guide for plugin authors
  • Verify no external plugins are broken (if any exist)
  • Update CHANGELOG.md with breaking change note

🏆 Conclusion

This is high-quality work that significantly improves the SDK's plugin architecture. The implementation is:

  • ✅ Architecturally sound
  • ✅ Thoroughly tested
  • ✅ Properly documented
  • ✅ Compliant with medical device software standards
  • ✅ Following all repository conventions

The only blocking item is waiting for CI/CD to pass. The suggestions above are non-blocking improvements for future consideration.

Recommendation: ✅ APPROVE once CI passes


Great work on this refactor! The shallow vs deep scanning distinction is exactly the right design pattern, and the test infrastructure (_make_import_side_effect() helper) is particularly well done. 🎉


| Branch: fix/shallow-plugin-discovery

@olivermeyer olivermeyer force-pushed the fix/shallow-plugin-discovery branch from 20b40e7 to d91965d Compare March 5, 2026 10:15
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

@olivermeyer
Copy link
Collaborator Author

Audit workflow was fixed in #460

@olivermeyer olivermeyer force-pushed the fix/shallow-plugin-discovery branch from d91965d to 57ecad1 Compare March 5, 2026 10:30
@codecov
Copy link

codecov bot commented Mar 5, 2026

Codecov Report

❌ Patch coverage is 94.87179% with 2 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/aignostics/utils/_di.py 94.87% 2 Missing ⚠️
Files with missing lines Coverage Δ
src/aignostics/utils/_di.py 92.06% <94.87%> (-2.39%) ⬇️

... and 6 files with indirect coverage changes

Add helpers (_mock_package, _make_import_side_effect, context managers
for broken/no-match/no-plugin scenarios), module-level di_module import,
PLUGIN/MYMODULE constants, and 10 new tests covering shallow plugin
scanning, broken plugin handling, and deep main-package scanning for
both locate_implementations and locate_subclasses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 5, 2026 10:42
@olivermeyer olivermeyer force-pushed the fix/shallow-plugin-discovery branch from 57ecad1 to d36036b Compare March 5, 2026 10:42
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

@sonarqubecloud
Copy link

sonarqubecloud bot commented Mar 5, 2026

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

Labels

claude Trigger Claude Code automation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants