diff --git a/.github/workflows/run_tests.yaml b/.github/workflows/run_tests.yaml index b0a41ff..6ebae34 100644 --- a/.github/workflows/run_tests.yaml +++ b/.github/workflows/run_tests.yaml @@ -11,7 +11,7 @@ jobs: strategy: matrix: os: [ "ubuntu-latest", "macos-latest", "windows-latest" ] - python-version: [ "3.8", "3.9", "3.10"] + python-version: [ "3.8", "3.9", "3.10", "3.11"] defaults: run: shell: bash @@ -21,10 +21,10 @@ jobs: # check-out repo and set-up python #---------------------------------------------- - name: Check out repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up python ${{ matrix.python-version }} id: setup-python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} #---------------------------------------------- @@ -33,6 +33,7 @@ jobs: - name: Install Poetry uses: snok/install-poetry@v1 with: + version: 1.8.3 virtualenvs-create: true virtualenvs-in-project: true #---------------------------------------------- diff --git a/my/plugins/tests/test_discover_and_load.py b/my/plugins/tests/test_discover_and_load.py index 5a6b675..3d47db3 100644 --- a/my/plugins/tests/test_discover_and_load.py +++ b/my/plugins/tests/test_discover_and_load.py @@ -1,6 +1,9 @@ import argparse +import importlib +import subprocess import sys from dataclasses import dataclass +from pathlib import Path from typing import Any, Dict, List, Set import pytest @@ -82,3 +85,132 @@ class A(Command): assert "test_discover_and_load" in p.plugins assert p.plugins["test_discover_and_load"].commands.as_list() == [("Printer", A)] + + +@pytest.fixture +def example_plugin_installed(): + """Fixture that installs the example plugin and cleans it up after the test.""" + # Get the path to the example plugin + test_dir = Path(__file__).parent + repo_root = test_dir.parent.parent.parent + example_plugin_dir = repo_root / "examples" / "basic" + + # Verify the example plugin directory exists + assert example_plugin_dir.exists(), f"Example plugin not found at {example_plugin_dir}" + + # Install the plugin in editable mode + result = subprocess.run( + [sys.executable, "-m", "pip", "install", "-e", str(example_plugin_dir)], + capture_output=True, + text=True + ) + + if result.returncode != 0: + pytest.fail(f"Failed to install example plugin: {result.stderr}") + + # For editable installs, we need to add the package path to sys.path + # so it can be imported in the same Python process + original_sys_path = sys.path.copy() + if example_plugin_dir not in sys.path: + sys.path.insert(0, str(example_plugin_dir)) + + # Invalidate Python's import caches to make the newly installed package discoverable + importlib.invalidate_caches() + + # Invalidate the entry points cache to force discovery of the newly installed plugin + if sys.version_info < (3, 10): + import importlib_metadata as im + try: + im.distributions.cache_clear() + except AttributeError: + pass + else: + import importlib.metadata as im + try: + im.distributions.cache_clear() + except AttributeError: + pass + + yield example_plugin_dir + + # Restore sys.path + sys.path = original_sys_path + + # Cleanup: uninstall the plugin + subprocess.run( + [sys.executable, "-m", "pip", "uninstall", "-y", "yeti-example"], + capture_output=True, + text=True + ) + + # Invalidate cache again after uninstall + importlib.invalidate_caches() + if sys.version_info < (3, 10): + import importlib_metadata as im + try: + im.distributions.cache_clear() + except AttributeError: + pass + else: + import importlib.metadata as im + try: + im.distributions.cache_clear() + except AttributeError: + pass + + +def test_example_plugin_discovery(example_plugin_installed): + """Test that a new plugin (like examples/basic) is discovered by the launcher.""" + # Create a fresh PluginLoader to discover all installed plugins + loader = PluginLoader() + + # Force fresh discovery by getting entry points from distributions + # This bypasses any caching issues + if sys.version_info < (3, 10): + from importlib_metadata import distributions + else: + from importlib.metadata import distributions + + # Manually discover entry points from all distributions + for dist in distributions(): + if dist.entry_points: + for ep in dist.entry_points: + if ep.group == "my.plugins.command": + loader.load_command(ep) + elif ep.group == "my.plugins.process": + loader.load_process(ep) + elif ep.group == "my.plugins.registry": + loader.load_registry(ep) + + # Verify that the yeti_example plugin was discovered + assert "yeti_example" in loader.plugins, ( + f"Plugin 'yeti_example' not found. Available plugins: {list(loader.plugins.keys())}" + ) + + plugin = loader.plugins["yeti_example"] + + # Verify the commands are discovered + # Commands are registered with the entry points and should include "notes.new" and "notes.list" + command_list = plugin.commands.as_list() + command_names = [name for name, _ in command_list] + + assert "notes.new" in command_names or "notes__new" in command_names, ( + f"Command 'notes.new' not found in plugin. Available commands: {command_names}" + ) + assert "notes.list" in command_names or "notes__list" in command_names, ( + f"Command 'notes.list' not found in plugin. Available commands: {command_names}" + ) + + # Verify the processes are discovered (from the registry) + # The registry in processes.py should register processes with the same names + process_list = plugin.processes.as_list() + process_names = [name for name, _ in process_list] + + # The processes.py registry adds processes at "notes.new" and "notes.list" paths + assert len(process_list) > 0, "No processes found in the plugin" + + # Check that we can access the processes via the hierarchical structure + # The registry should expose processes under "notes" + assert hasattr(plugin.processes, "notes") or any("notes" in str(name) for name in process_names), ( + f"Process hierarchy 'notes' not found. Available processes: {process_names}" + )