From 964c1fde39b83daa66ce9f69a95ecac38ac51e80 Mon Sep 17 00:00:00 2001 From: Ezeudoh Tochukwu Date: Sun, 16 Nov 2025 22:23:54 +0100 Subject: [PATCH 1/3] feat: implement recursive forward ref resolution in test module dependencies - Make _resolve_forward_refs recursive to traverse entire module dependency tree - Use resolved.extend() to capture nested dependencies from recursive calls - Add ModuleSetup instance handling in forward ref resolution - Update tests to reflect recursive resolution behavior This ensures ForwardRefModule instances are resolved at all dependency levels, not just at the top level, providing complete module dependency resolution for testing scenarios. --- ellar/testing/dependency_analyzer.py | 21 ++-- ellar/testing/module.py | 41 ++++--- tests/test_testing_dependency_resolution.py | 122 +++++++++++++++++++- 3 files changed, 160 insertions(+), 24 deletions(-) diff --git a/ellar/testing/dependency_analyzer.py b/ellar/testing/dependency_analyzer.py index 67c04a09..6746f41e 100644 --- a/ellar/testing/dependency_analyzer.py +++ b/ellar/testing/dependency_analyzer.py @@ -12,7 +12,7 @@ if t.TYPE_CHECKING: # pragma: no cover from ellar.common import ControllerBase - from ellar.core import ForwardRefModule, ModuleBase + from ellar.core import ForwardRefModule, ModuleBase, ModuleSetup from ellar.di import ModuleTreeManager @@ -60,6 +60,13 @@ def __init__(self, application_module: t.Union[t.Type["ModuleBase"], str]): self._module_tree = self._build_module_tree() + def get_application_module_providers(self) -> t.List[t.Type]: + """Get all provider types from the ApplicationModule tree""" + mod_data = self._module_tree.get_app_module() + if mod_data: + return list(mod_data.providers.values()) + return [] + def _build_module_tree(self) -> "ModuleTreeManager": """Build complete module tree for ApplicationModule""" from ellar.app import AppFactory @@ -164,7 +171,7 @@ def collect_dependencies(mod: t.Type["ModuleBase"]) -> None: def resolve_forward_ref( self, forward_ref: "ForwardRefModule" - ) -> t.Optional[t.Type["ModuleBase"]]: + ) -> t.Optional["ModuleSetup"]: """ Resolve a ForwardRefModule to its actual module from ApplicationModule tree @@ -181,7 +188,7 @@ def resolve_forward_ref( filter_item=lambda data: True, find_predicate=lambda data: data.name == forward_ref.module_name, ) - return t.cast(t.Type["ModuleBase"], result.value.module) if result else None + return t.cast("ModuleSetup", result.value) if result else None elif hasattr(forward_ref, "module") and forward_ref.module: # Module can be a Type or a string import path @@ -197,12 +204,8 @@ def resolve_forward_ref( # Search for this module type in the tree module_data = self._module_tree.get_module(module_cls) - return ( - t.cast(t.Type["ModuleBase"], module_data.value.module) - if module_data - else None - ) - + if module_data: + return t.cast("ModuleSetup", module_data.value) return None diff --git a/ellar/testing/module.py b/ellar/testing/module.py index f2b36f2b..ff1cc4de 100644 --- a/ellar/testing/module.py +++ b/ellar/testing/module.py @@ -11,7 +11,7 @@ constants, ) from ellar.common.types import T -from ellar.core import ModuleBase +from ellar.core import ModuleBase, ModuleSetup from ellar.core.routing import EllarControllerMount from ellar.di import ProviderConfig from ellar.reflect import reflect @@ -166,16 +166,12 @@ def create_test_module( app_analyzer = ApplicationModuleDependencyAnalyzer(application_module) controller_analyzer = ControllerDependencyAnalyzer() - # 1. Resolve ForwardRefs in registered modules - resolved_modules = cls._resolve_forward_refs(modules_list, app_analyzer) - modules_list = resolved_modules - # 2. Analyze controllers and find required modules (with recursive dependencies) required_modules = cls._analyze_and_resolve_controller_dependencies( controllers, controller_analyzer, app_analyzer ) - # 3. Add required modules that aren't already registered + # 2. Add required modules that aren't already registered # Use type comparison to avoid duplicates existing_module_types = { m if isinstance(m, type) else m.module if hasattr(m, "module") else m @@ -186,6 +182,15 @@ def create_test_module( modules_list.append(required_module) existing_module_types.add(required_module) + # 4. Resolve ForwardRefs in registered modules + resolved_modules = cls._resolve_forward_refs(modules_list, app_analyzer) + modules_list.extend(resolved_modules) + + providers = list(providers) + # 5. Add application module providers, since this is the root module + # and it will be used to resolve dependencies + providers.extend(app_analyzer.get_application_module_providers()) + # Create the module with complete dependency list module = Module( modules=modules_list, @@ -229,20 +234,30 @@ def _resolve_forward_refs( modules: t.List[t.Any], app_analyzer: "ApplicationModuleDependencyAnalyzer", ) -> t.List[t.Any]: - """Resolve ForwardRefModule instances from ApplicationModule""" + """Resolve ForwardRefModule instances from ApplicationModule recursively""" from ellar.core import ForwardRefModule resolved = [] for module in modules: + # Resolve current module if it's a ForwardRefModule if isinstance(module, ForwardRefModule): actual_module = app_analyzer.resolve_forward_ref(module) - if actual_module: - resolved.append(actual_module) - else: - # Keep original if can't resolve (might be test-specific) - resolved.append(module) + current_module = actual_module.module + resolved.append(actual_module) + elif isinstance(module, ModuleSetup): + current_module = module.module else: - resolved.append(module) + current_module = module + + # Recursively resolve forward refs in module's dependencies + registered_modules = ( + reflect.get_metadata(constants.MODULE_METADATA.MODULES, current_module) + or [] + ) + if registered_modules: + resolved.extend( + cls._resolve_forward_refs(registered_modules, app_analyzer) + ) return resolved diff --git a/tests/test_testing_dependency_resolution.py b/tests/test_testing_dependency_resolution.py index d639084d..730707af 100644 --- a/tests/test_testing_dependency_resolution.py +++ b/tests/test_testing_dependency_resolution.py @@ -276,6 +276,31 @@ def test_application_module_analyzer_get_module_dependencies_none(): assert len(dependencies) == 0 +def test_application_module_analyzer_get_application_module_providers(): + """Test getting providers from ApplicationModule""" + from ellar.di import ProviderConfig + + @injectable + class AppLevelService: + pass + + @Module( + name="TestAppModuleWithProviders", + modules=[AuthModule], + providers=[ProviderConfig(AppLevelService, use_class=AppLevelService)], + ) + class TestAppModuleWithProviders(ModuleBase): + pass + + analyzer = ApplicationModuleDependencyAnalyzer(TestAppModuleWithProviders) + providers = analyzer.get_application_module_providers() + + # Should include AppLevelService + assert AppLevelService in providers or any( + hasattr(p, "get_type") and p.get_type() == AppLevelService for p in providers + ) + + # ============================================================================ # Unit Tests: ForwardRefModule Resolution # ============================================================================ @@ -283,6 +308,7 @@ def test_application_module_analyzer_get_module_dependencies_none(): def test_forward_ref_resolution_by_type(): """Test resolving ForwardRefModule by type""" + from ellar.core.modules import ModuleSetup # Need to have DatabaseModule actually registered in the application tree @Module( @@ -298,7 +324,9 @@ class ForwardRefTestModule(ModuleBase): forward_ref = ForwardRefModule(module=DatabaseModule) resolved = analyzer.resolve_forward_ref(forward_ref) - assert resolved == DatabaseModule + # Should return a ModuleSetup instance + assert isinstance(resolved, ModuleSetup) + assert resolved.module == DatabaseModule def test_forward_ref_resolution_by_name(): @@ -318,6 +346,7 @@ class ForwardRefTestModule2(ModuleBase): forward_ref = ForwardRefModule(module_name="DatabaseModule") resolved = analyzer.resolve_forward_ref(forward_ref) + # When resolving by name, it returns the module type directly assert resolved == DatabaseModule @@ -336,6 +365,52 @@ class ForwardRefTestModule3(ModuleBase): assert resolved is None +def test_resolve_forward_refs_handles_module_setup(): + """Test that _resolve_forward_refs properly handles ModuleSetup instances""" + from ellar.testing.module import Test + + @Module(name="TestModuleForSetup", modules=[AuthModule, DatabaseModule]) + class TestModuleForSetup(ModuleBase): + pass + + analyzer = ApplicationModuleDependencyAnalyzer(TestModuleForSetup) + + # Pass ForwardRefModule instances that will be resolved + forward_ref_auth = ForwardRefModule(module=AuthModule) + forward_ref_db = ForwardRefModule(module=DatabaseModule) + + modules = [forward_ref_auth, forward_ref_db] + resolved = Test._resolve_forward_refs(modules, analyzer) + + # Should resolve both ForwardRefModules (and potentially their dependencies) + assert len(resolved) >= 2 + + +def test_resolve_forward_refs_recursive_extension(): + """Test that _resolve_forward_refs recursively extends with nested modules""" + from ellar.testing.module import Test + + # DatabaseModule has LoggingModule as dependency + @Module( + name="TestModuleForRecursive", + modules=[DatabaseModule, AuthModule], + ) + class TestModuleForRecursive(ModuleBase): + pass + + analyzer = ApplicationModuleDependencyAnalyzer(TestModuleForRecursive) + + # Start with ForwardRefModule to DatabaseModule (which has LoggingModule as dependency) + forward_ref_db = ForwardRefModule(module=DatabaseModule) + modules = [forward_ref_db] + resolved = Test._resolve_forward_refs(modules, analyzer) + + # Should return resolved DatabaseModule (and potentially nested dependencies) + # The exact count depends on whether DatabaseModule's LoggingModule dependency + # has any ForwardRefModules in its metadata + assert len(resolved) >= 1 + + # ============================================================================ # Integration Tests: Test.create_test_module() # ============================================================================ @@ -469,7 +544,7 @@ class TestAppWithForwardRef(ModuleBase): tm = Test.create_test_module( controllers=[UserController], - modules=[ModuleWithForwardRef], # Contains ForwardRef to AuthModule + # Don't manually add ForwardRef module - let auto-resolution handle it application_module=TestAppWithForwardRef, ) @@ -622,6 +697,49 @@ def test_create_test_module_with_import_string_application_module(reflect_contex assert isinstance(controller.auth_service, IAuthService) +def test_create_test_module_includes_application_module_providers(reflect_context): + """Test that test module includes providers from ApplicationModule""" + + @injectable + class AppLevelService: + def get_value(self): + return "app_level" + + @Module( + name="AppModuleWithProviders", + modules=[AuthModule], + providers=[ProviderConfig(AppLevelService, use_class=AppLevelService)], + ) + class AppModuleWithProviders(ModuleBase): + pass + + @Controller() + class ControllerUsingAppService: + def __init__(self, app_service: AppLevelService): + self.app_service = app_service + + @get("/test") + def test_endpoint(self): + return {"value": self.app_service.get_value()} + + tm = Test.create_test_module( + controllers=[ControllerUsingAppService], + application_module=AppModuleWithProviders, + ) + + tm.create_application() + + # Should be able to get the app-level service + app_service = tm.get(AppLevelService) + assert app_service is not None + assert app_service.get_value() == "app_level" + + # Controller should also work + controller = tm.get(ControllerUsingAppService) + assert controller is not None + assert isinstance(controller.app_service, AppLevelService) + + def test_application_module_analyzer_with_import_string(): """Test that ApplicationModuleDependencyAnalyzer accepts import strings""" import_string = "tests.test_testing_dependency_resolution:ApplicationModule" From 039dce13b359ff36fec40cd44bed7d6bc2a2f995 Mon Sep 17 00:00:00 2001 From: Ezeudoh Tochukwu Date: Sun, 16 Nov 2025 22:33:37 +0100 Subject: [PATCH 2/3] fixed failing tests --- tests/test_testing_dependency_resolution.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_testing_dependency_resolution.py b/tests/test_testing_dependency_resolution.py index 730707af..f4c9f1f7 100644 --- a/tests/test_testing_dependency_resolution.py +++ b/tests/test_testing_dependency_resolution.py @@ -347,7 +347,7 @@ class ForwardRefTestModule2(ModuleBase): resolved = analyzer.resolve_forward_ref(forward_ref) # When resolving by name, it returns the module type directly - assert resolved == DatabaseModule + assert resolved.module == DatabaseModule def test_forward_ref_resolution_not_found(): From a486a83782793779c3fd50b54db075ff65ad3782 Mon Sep 17 00:00:00 2001 From: Ezeudoh Tochukwu Date: Sun, 16 Nov 2025 22:35:50 +0100 Subject: [PATCH 3/3] 0.9.3 --- ellar/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ellar/__init__.py b/ellar/__init__.py index 670bbc71..6d85bffa 100644 --- a/ellar/__init__.py +++ b/ellar/__init__.py @@ -1,3 +1,3 @@ """Ellar - Python ASGI web framework for building fast, efficient, and scalable RESTful APIs and server-side applications.""" -__version__ = "0.9.2" +__version__ = "0.9.3"