From dd0d9500b70469d5e6e1d42e0b38dc9c1abc37aa Mon Sep 17 00:00:00 2001 From: Alexander Egorov Date: Mon, 12 Oct 2026 19:02:57 +0200 Subject: [PATCH] feat(application): route and class discovery accept a single module find_package_routes and get_all_classes took only a package: import_recursively read __path__, which a module lacks. A module name now yields that module, so a flat project can scan myapp.api without moving it into a subpackage. A class counts as defined in the scanned package or module only when its module is that name or below it. The plain prefix check also matched a sibling such as myapp.api_utils. Co-Authored-By: Claude Opus 5.5 --- tests/application/discovery/__init__.py | 0 tests/application/discovery/sample.py | 5 +++++ tests/application/discovery/sample_helpers.py | 2 ++ tests/application/test_module_discovery.py | 18 ++++++++++++++++++ tests/web/test_autodiscovery.py | 8 ++++++++ winter/application/module_discovery.py | 7 ++++++- winter/web/autodiscovery.py | 1 + 7 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 tests/application/discovery/__init__.py create mode 100644 tests/application/discovery/sample.py create mode 100644 tests/application/discovery/sample_helpers.py diff --git a/tests/application/discovery/__init__.py b/tests/application/discovery/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/application/discovery/sample.py b/tests/application/discovery/sample.py new file mode 100644 index 00000000..947a9b0f --- /dev/null +++ b/tests/application/discovery/sample.py @@ -0,0 +1,5 @@ +from .sample_helpers import SampleHelper + + +class Sample: + helper = SampleHelper diff --git a/tests/application/discovery/sample_helpers.py b/tests/application/discovery/sample_helpers.py new file mode 100644 index 00000000..da45ac6c --- /dev/null +++ b/tests/application/discovery/sample_helpers.py @@ -0,0 +1,2 @@ +class SampleHelper: + pass diff --git a/tests/application/test_module_discovery.py b/tests/application/test_module_discovery.py index dfb48e0d..611accbf 100644 --- a/tests/application/test_module_discovery.py +++ b/tests/application/test_module_discovery.py @@ -30,3 +30,21 @@ def test_all_subclasses_with_exception(reference_type): reference_type.side_effect = TypeError exception_subclasses = list(get_all_subclasses(Exception)) assert not exception_subclasses + + +def test_get_all_classes_of_module(): + from tests.application.discovery.sample import Sample + + found_classes = list(get_all_classes('tests.application.discovery.sample')) + + # SampleHelper is imported into the module but defined in a module whose name only starts the same + assert found_classes == [('Sample', Sample)] + + +def test_get_all_classes_of_package(): + from tests.application.discovery.sample import Sample + from tests.application.discovery.sample_helpers import SampleHelper + + found_classes = list(get_all_classes('tests.application.discovery')) + + assert sorted(found_classes) == [('Sample', Sample), ('SampleHelper', SampleHelper)] diff --git a/tests/web/test_autodiscovery.py b/tests/web/test_autodiscovery.py index 6ba0147f..43395824 100644 --- a/tests/web/test_autodiscovery.py +++ b/tests/web/test_autodiscovery.py @@ -7,3 +7,11 @@ def test_find_package_routes(): # Assert assert [route.url_path for route in routes] == ['api_1/', 'api_2/', 'api_2/', 'api_3/'] + + +def test_find_module_routes(): + # Act + routes = winter.web.find_package_routes('tests.api.package.api_2') + + # Assert + assert [route.url_path for route in routes] == ['api_2/', 'api_2/'] diff --git a/winter/application/module_discovery.py b/winter/application/module_discovery.py index b139d708..8530d294 100644 --- a/winter/application/module_discovery.py +++ b/winter/application/module_discovery.py @@ -9,13 +9,14 @@ def get_all_classes(package: str) -> Generator[Tuple[str, Type], None, None]: + """Classes defined in the modules of a package, or in a single module.""" classes_set = set() for module in import_recursively(package): try: for class_name, class_ in inspect.getmembers(module, inspect.isclass): if class_ in classes_set: continue - if not class_.__module__.startswith(package): + if class_.__module__ != package and not class_.__module__.startswith(package + '.'): continue yield class_name, class_ classes_set.add(class_) @@ -41,6 +42,10 @@ def get_all_subclasses(supertype: Type) -> Generator[Tuple[str, Type], None, Non def import_recursively(package: str) -> Generator[ModuleType, None, None]: + """Imports and yields the modules of a package and its subpackages, or a single module itself.""" package = importlib.import_module(package) + if not hasattr(package, '__path__'): + yield package + return for loader, name, is_pkg in pkgutil.walk_packages(package.__path__, package.__name__ + '.'): yield importlib.import_module(name) diff --git a/winter/web/autodiscovery.py b/winter/web/autodiscovery.py index 3c7e7cb2..70ff29ce 100644 --- a/winter/web/autodiscovery.py +++ b/winter/web/autodiscovery.py @@ -8,6 +8,7 @@ def find_package_routes(package_name: str) -> List[Route]: + """Routes of the API classes in a package and its subpackages, or in a single module.""" import_recursively(package_name) routes = []