clan-lib: Init import_utils to add debug information to dynamically imported modules

This commit is contained in:
Qubasa
2025-07-18 13:47:13 +07:00
parent 137aa71529
commit 89f0e90910
2 changed files with 223 additions and 0 deletions

View 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))

View 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),
)