clan-lib: Init import_utils to add debug information to dynamically imported modules
This commit is contained in:
82
pkgs/clan-cli/clan_lib/import_utils/__init__.py
Normal file
82
pkgs/clan-cli/clan_lib/import_utils/__init__.py
Normal file
@@ -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))
|
||||
141
pkgs/clan-cli/clan_lib/import_utils/import_utils_test.py
Normal file
141
pkgs/clan-cli/clan_lib/import_utils/import_utils_test.py
Normal file
@@ -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),
|
||||
)
|
||||
Reference in New Issue
Block a user