diff --git a/fastloop/fastloop.py b/fastloop/fastloop.py index 79023e0..4515485 100644 --- a/fastloop/fastloop.py +++ b/fastloop/fastloop.py @@ -150,12 +150,16 @@ def run( config.graceful_timeout = shutdown_timeout config.debug = debug + # For debug/reload mode, we need an application path for hypercorn to reload + application_path = None if config.debug: config.use_reloader = True - if not hasattr(config, "application_path"): - config.application_path = infer_application_path(self) + application_path = infer_application_path(self) + if application_path: + config.application_path = application_path - if not hasattr(config, "application_path"): + # Use direct serve if no valid application_path (works without reload) + if not application_path: asyncio.run(hypercorn.asyncio.serve(self, config)) return diff --git a/fastloop/utils.py b/fastloop/utils.py index ccf5522..d69502b 100644 --- a/fastloop/utils.py +++ b/fastloop/utils.py @@ -47,8 +47,8 @@ def infer_application_path(app_instance: Any, fallback_var: str = "app") -> str """ Infer the application path for Hypercorn reload support. - Try (1) to locate the app in its defining module and use 'module:var', - else (2) derive module from sys.argv[0] and use 'module:fallback_var'. + Searches loaded modules to find where app_instance is stored as a variable, + then falls back to argv-based inference. Args: app_instance: The FastLoop/FastAPI application instance @@ -57,31 +57,47 @@ def infer_application_path(app_instance: Any, fallback_var: str = "app") -> str Returns: Application path string like "module.path:app" or None if cannot be determined """ - # (1) Introspect app if it has an 'app' attribute (e.g., FastLoop wrapping FastAPI) - # Note: We don't fall back to app_instance here because app_instance.__module__ - # would be "fastloop.fastloop" (where the class is defined), not the user's module. - # If there's no 'app' attribute, we fall through to argv-based inference. - app = getattr(app_instance, "app", None) - if app is not None and getattr(app, "__module__", None): - mod_name = app.__module__ + # (1) Search loaded modules for the app_instance + # Skip fastloop package modules to avoid returning "fastloop.fastloop:app" + # which doesn't exist (FastLoop is a class, not an instance there) + for mod_name, mod in list(sys.modules.items()): + # Skip fastloop package, private modules, and None modules + if ( + mod is None + or mod_name.startswith("fastloop") + or mod_name.startswith("_") + ): + continue + try: - mod = importlib.import_module(mod_name) for name, val in vars(mod).items(): - if val is app: + if val is app_instance: return f"{mod_name}:{name}" - # app object found but variable name not recoverable — use fallback var - return f"{mod_name}:{fallback_var}" - except BaseException: - pass # fall through to argv-based inference + except Exception: + continue - # (2) Derive dotted module from the script path in sys.argv[0] + # (2) If app_instance has an 'app' attribute, try that (for wrapper patterns) + app = getattr(app_instance, "app", None) + if app is not None and getattr(app, "__module__", None): + mod_name = app.__module__ + # Skip fastloop package + if not mod_name.startswith("fastloop"): + try: + mod = importlib.import_module(mod_name) + for name, val in vars(mod).items(): + if val is app: + return f"{mod_name}:{name}" + except Exception: + pass + + # (3) Derive dotted module from the script path in sys.argv[0] script = Path(sys.argv[0]).resolve() if script.suffix == ".py": # Find the first sys.path entry that contains the script for base in map(Path, sys.path): try: rel = script.relative_to(base.resolve()) - except BaseException: + except Exception: continue # Convert path/to/module.py -> path.to.module diff --git a/pyproject.toml b/pyproject.toml index 8f37597..05d1891 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fastloop" -version = "0.1.73" +version = "0.1.74" description = "A Python package for deploying stateful loops" readme = "README.md" requires-python = ">=3.12" diff --git a/tests/test_app_initialization.py b/tests/test_app_initialization.py index 5ebf23f..3620c6a 100644 --- a/tests/test_app_initialization.py +++ b/tests/test_app_initialization.py @@ -75,6 +75,60 @@ def test_falls_back_to_argv_inference(self): assert "myapp.main" in result assert ":app" in result + def test_finds_app_in_external_module(self): + """ + Regression test for app defined in separate module (like internal_agents.app). + + When a FastLoop instance is created in a user module and imported into main, + infer_application_path should find it in that module. + + This is the pattern: + # mypackage/app.py + app = FastLoop(name="my-app") + + # mypackage/main.py + from mypackage.app import app + app.run() + """ + import types + + # Create a mock module simulating 'mypackage.app' + mock_module = types.ModuleType("mypackage.app") + app = FastLoop(name="external-module-app") + mock_module.app = app + + # Register the mock module + sys.modules["mypackage.app"] = mock_module + + try: + result = infer_application_path(app) + + # Should find the app in our mock module + assert result is not None, "infer_application_path should find app in external module" + assert result == "mypackage.app:app", f"Expected 'mypackage.app:app', got '{result}'" + assert not result.startswith("fastloop."), "Should not resolve to fastloop package" + finally: + # Clean up + del sys.modules["mypackage.app"] + + def test_finds_app_with_different_variable_name(self): + """Test that app is found even if stored with a different variable name.""" + import types + + mock_module = types.ModuleType("mypackage.server") + app = FastLoop(name="custom-var-app") + mock_module.my_server = app # Different variable name + + sys.modules["mypackage.server"] = mock_module + + try: + result = infer_application_path(app) + + assert result is not None + assert result == "mypackage.server:my_server" + finally: + del sys.modules["mypackage.server"] + class TestFastLoopWithLoops: """Tests for FastLoop app with registered loops.""" diff --git a/uv.lock b/uv.lock index a274023..185abdf 100644 --- a/uv.lock +++ b/uv.lock @@ -394,7 +394,7 @@ wheels = [ [[package]] name = "fastloop" -version = "0.1.73" +version = "0.1.74" source = { editable = "." } dependencies = [ { name = "aioboto3" },