diff --git a/pkgs/clan-cli/clan_lib/import_utils/__init__.py b/pkgs/clan-cli/clan_lib/import_utils/__init__.py new file mode 100644 index 000000000..8fe60a716 --- /dev/null +++ b/pkgs/clan-cli/clan_lib/import_utils/__init__.py @@ -0,0 +1,82 @@ +import contextlib +import importlib +import inspect +from dataclasses import dataclass +from pathlib import Path +from typing import Any, TypeVar, cast + +T = TypeVar("T") + + +@dataclass(frozen=True) +class ClassSource: + module_name: str + file_path: Path + line_number: int | None = None + + +def import_with_source[T]( + module_name: str, + class_name: str, + base_class: type[T], + *args: Any, + **kwargs: Any, +) -> T: + """ + Import a class from a module and instantiate it with source information. + + This function dynamically imports a class and adds source location metadata + that can be used for debugging. The instantiated object will have VSCode-clickable + paths in its string representation. + + Args: + module_name: The fully qualified module name to import + class_name: The name of the class to import from the module + base_class: The base class type for type checking + *args: Additional positional arguments to pass to the class constructor + **kwargs: Additional keyword arguments to pass to the class constructor + + Returns: + An instance of the imported class with source information + + Example: + >>> from .network import NetworkTechnologyBase, ClassSource + >>> tech = import_with_source( + ... "clan_lib.network.tor", + ... "NetworkTechnology", + ... NetworkTechnologyBase + ... ) + >>> print(tech) # Outputs: ~/Projects/clan-core/.../tor.py:7 + """ + # Import the module + module = importlib.import_module(module_name) + + # Get the class from the module + cls = getattr(module, class_name) + + # Get the line number of the class definition + line_number = None + with contextlib.suppress(Exception): + line_number = inspect.getsourcelines(cls)[1] + + # Get the file path + file_path_str = module.__file__ + assert file_path_str is not None, f"Module {module_name} file path cannot be None" + + # Make the path relative to home for better readability + try: + file_path = Path(file_path_str).relative_to(Path.home()) + file_path = Path("~", file_path) + except ValueError: + # If not under home directory, use absolute path + file_path = Path(file_path_str) + + # Create source information + source = ClassSource( + module_name=module_name, + file_path=file_path, + line_number=line_number, + ) + + # Instantiate the class with source information + return cast(T, cls(source, *args, **kwargs)) diff --git a/pkgs/clan-cli/clan_lib/import_utils/import_utils_test.py b/pkgs/clan-cli/clan_lib/import_utils/import_utils_test.py new file mode 100644 index 000000000..e50e84c19 --- /dev/null +++ b/pkgs/clan-cli/clan_lib/import_utils/import_utils_test.py @@ -0,0 +1,141 @@ +import tempfile +from pathlib import Path +from textwrap import dedent +from typing import Any, cast + +import pytest + +from clan_lib.import_utils import import_with_source +from clan_lib.network.network import NetworkTechnologyBase + + +def test_import_with_source(tmp_path: Path) -> None: + """Test importing a class with source information.""" + # Create a temporary module file + module_dir = tmp_path / "test_module" + module_dir.mkdir() + + # Create __init__.py to make it a package + (module_dir / "__init__.py").write_text("") + + # Create a test module with a NetworkTechnology class + test_module_path = module_dir / "test_tech.py" + test_module_path.write_text( + dedent(""" + from clan_lib.network.network import NetworkTechnologyBase + + class NetworkTechnology(NetworkTechnologyBase): + def __init__(self, source): + super().__init__(source) + self.test_value = "test" + + def is_running(self) -> bool: + return True + """) + ) + + # Add the temp directory to sys.path + import sys + + sys.path.insert(0, str(tmp_path)) + + try: + # Import the class using import_with_source + instance = import_with_source( + "test_module.test_tech", + "NetworkTechnology", + cast(Any, NetworkTechnologyBase), + ) + + # Verify the instance is created correctly + assert isinstance(instance, NetworkTechnologyBase) + assert instance.is_running() is True + assert hasattr(instance, "test_value") + assert instance.test_value == "test" + + # Verify source information + assert instance.source.module_name == "test_module.test_tech" + assert instance.source.file_path.name == "test_tech.py" + assert instance.source.line_number == 4 # Line where class is defined + + # Test string representations + str_repr = str(instance) + assert "test_tech.py:" in str_repr + assert str(instance.source.line_number) in str_repr + + repr_repr = repr(instance) + assert "NetworkTechnology" in repr_repr + assert "test_tech.py:" in repr_repr + + finally: + # Clean up sys.path + sys.path.remove(str(tmp_path)) + + +def test_import_with_source_with_args() -> None: + """Test importing a class with additional constructor arguments.""" + # Create a temporary test file + with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f: + f.write( + dedent(""" + from clan_lib.network.network import NetworkTechnologyBase + + class NetworkTechnology(NetworkTechnologyBase): + def __init__(self, source, extra_arg, keyword_arg=None): + super().__init__(source) + self.extra_arg = extra_arg + self.keyword_arg = keyword_arg + + def is_running(self) -> bool: + return False + """) + ) + temp_file = Path(f.name) + + # Import module dynamically + import importlib.util + import sys + + spec = importlib.util.spec_from_file_location("temp_module", temp_file) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules["temp_module"] = module + spec.loader.exec_module(module) + + try: + # Import with additional arguments + instance = import_with_source( + "temp_module", + "NetworkTechnology", + cast(Any, NetworkTechnologyBase), + "extra_value", + keyword_arg="keyword_value", + ) + + # Verify arguments were passed correctly + assert instance.extra_arg == "extra_value" # type: ignore[attr-defined] + assert instance.keyword_arg == "keyword_value" # type: ignore[attr-defined] + + finally: + # Clean up + del sys.modules["temp_module"] + temp_file.unlink() + + +def test_import_with_source_module_not_found() -> None: + """Test error handling when module is not found.""" + with pytest.raises(ModuleNotFoundError): + import_with_source( + "non_existent_module", "SomeClass", cast(Any, NetworkTechnologyBase) + ) + + +def test_import_with_source_class_not_found() -> None: + """Test error handling when class is not found in module.""" + with pytest.raises(AttributeError): + import_with_source( + "clan_lib.network.network", + "NonExistentClass", + cast(Any, NetworkTechnologyBase), + )