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" 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..f4c9f1f7 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,7 +346,8 @@ class ForwardRefTestModule2(ModuleBase): forward_ref = ForwardRefModule(module_name="DatabaseModule") resolved = analyzer.resolve_forward_ref(forward_ref) - assert resolved == DatabaseModule + # When resolving by name, it returns the module type directly + assert resolved.module == DatabaseModule def test_forward_ref_resolution_not_found(): @@ -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"