Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions fastloop/fastloop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
50 changes: 33 additions & 17 deletions fastloop/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
54 changes: 54 additions & 0 deletions tests/test_app_initialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading