Skip to content

Commit 3ec2d17

Browse files
luke-lombardicursoragentluke-beamcloud
authored
Fix fastloop app initialization error (#17)
* Refactor infer_application_path to improve module discovery Co-authored-by: luke <luke@smartshare.io> * bump --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Luke Lombardi <luke@beam.cloud>
1 parent d5a1acc commit 3ec2d17

5 files changed

Lines changed: 96 additions & 22 deletions

File tree

fastloop/fastloop.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,12 +150,16 @@ def run(
150150
config.graceful_timeout = shutdown_timeout
151151
config.debug = debug
152152

153+
# For debug/reload mode, we need an application path for hypercorn to reload
154+
application_path = None
153155
if config.debug:
154156
config.use_reloader = True
155-
if not hasattr(config, "application_path"):
156-
config.application_path = infer_application_path(self)
157+
application_path = infer_application_path(self)
158+
if application_path:
159+
config.application_path = application_path
157160

158-
if not hasattr(config, "application_path"):
161+
# Use direct serve if no valid application_path (works without reload)
162+
if not application_path:
159163
asyncio.run(hypercorn.asyncio.serve(self, config))
160164
return
161165

fastloop/utils.py

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,8 @@ def infer_application_path(app_instance: Any, fallback_var: str = "app") -> str
4747
"""
4848
Infer the application path for Hypercorn reload support.
4949
50-
Try (1) to locate the app in its defining module and use 'module:var',
51-
else (2) derive module from sys.argv[0] and use 'module:fallback_var'.
50+
Searches loaded modules to find where app_instance is stored as a variable,
51+
then falls back to argv-based inference.
5252
5353
Args:
5454
app_instance: The FastLoop/FastAPI application instance
@@ -57,31 +57,47 @@ def infer_application_path(app_instance: Any, fallback_var: str = "app") -> str
5757
Returns:
5858
Application path string like "module.path:app" or None if cannot be determined
5959
"""
60-
# (1) Introspect app if it has an 'app' attribute (e.g., FastLoop wrapping FastAPI)
61-
# Note: We don't fall back to app_instance here because app_instance.__module__
62-
# would be "fastloop.fastloop" (where the class is defined), not the user's module.
63-
# If there's no 'app' attribute, we fall through to argv-based inference.
64-
app = getattr(app_instance, "app", None)
65-
if app is not None and getattr(app, "__module__", None):
66-
mod_name = app.__module__
60+
# (1) Search loaded modules for the app_instance
61+
# Skip fastloop package modules to avoid returning "fastloop.fastloop:app"
62+
# which doesn't exist (FastLoop is a class, not an instance there)
63+
for mod_name, mod in list(sys.modules.items()):
64+
# Skip fastloop package, private modules, and None modules
65+
if (
66+
mod is None
67+
or mod_name.startswith("fastloop")
68+
or mod_name.startswith("_")
69+
):
70+
continue
71+
6772
try:
68-
mod = importlib.import_module(mod_name)
6973
for name, val in vars(mod).items():
70-
if val is app:
74+
if val is app_instance:
7175
return f"{mod_name}:{name}"
72-
# app object found but variable name not recoverable — use fallback var
73-
return f"{mod_name}:{fallback_var}"
74-
except BaseException:
75-
pass # fall through to argv-based inference
76+
except Exception:
77+
continue
7678

77-
# (2) Derive dotted module from the script path in sys.argv[0]
79+
# (2) If app_instance has an 'app' attribute, try that (for wrapper patterns)
80+
app = getattr(app_instance, "app", None)
81+
if app is not None and getattr(app, "__module__", None):
82+
mod_name = app.__module__
83+
# Skip fastloop package
84+
if not mod_name.startswith("fastloop"):
85+
try:
86+
mod = importlib.import_module(mod_name)
87+
for name, val in vars(mod).items():
88+
if val is app:
89+
return f"{mod_name}:{name}"
90+
except Exception:
91+
pass
92+
93+
# (3) Derive dotted module from the script path in sys.argv[0]
7894
script = Path(sys.argv[0]).resolve()
7995
if script.suffix == ".py":
8096
# Find the first sys.path entry that contains the script
8197
for base in map(Path, sys.path):
8298
try:
8399
rel = script.relative_to(base.resolve())
84-
except BaseException:
100+
except Exception:
85101
continue
86102

87103
# Convert path/to/module.py -> path.to.module

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "fastloop"
3-
version = "0.1.73"
3+
version = "0.1.74"
44
description = "A Python package for deploying stateful loops"
55
readme = "README.md"
66
requires-python = ">=3.12"

tests/test_app_initialization.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,60 @@ def test_falls_back_to_argv_inference(self):
7575
assert "myapp.main" in result
7676
assert ":app" in result
7777

78+
def test_finds_app_in_external_module(self):
79+
"""
80+
Regression test for app defined in separate module (like internal_agents.app).
81+
82+
When a FastLoop instance is created in a user module and imported into main,
83+
infer_application_path should find it in that module.
84+
85+
This is the pattern:
86+
# mypackage/app.py
87+
app = FastLoop(name="my-app")
88+
89+
# mypackage/main.py
90+
from mypackage.app import app
91+
app.run()
92+
"""
93+
import types
94+
95+
# Create a mock module simulating 'mypackage.app'
96+
mock_module = types.ModuleType("mypackage.app")
97+
app = FastLoop(name="external-module-app")
98+
mock_module.app = app
99+
100+
# Register the mock module
101+
sys.modules["mypackage.app"] = mock_module
102+
103+
try:
104+
result = infer_application_path(app)
105+
106+
# Should find the app in our mock module
107+
assert result is not None, "infer_application_path should find app in external module"
108+
assert result == "mypackage.app:app", f"Expected 'mypackage.app:app', got '{result}'"
109+
assert not result.startswith("fastloop."), "Should not resolve to fastloop package"
110+
finally:
111+
# Clean up
112+
del sys.modules["mypackage.app"]
113+
114+
def test_finds_app_with_different_variable_name(self):
115+
"""Test that app is found even if stored with a different variable name."""
116+
import types
117+
118+
mock_module = types.ModuleType("mypackage.server")
119+
app = FastLoop(name="custom-var-app")
120+
mock_module.my_server = app # Different variable name
121+
122+
sys.modules["mypackage.server"] = mock_module
123+
124+
try:
125+
result = infer_application_path(app)
126+
127+
assert result is not None
128+
assert result == "mypackage.server:my_server"
129+
finally:
130+
del sys.modules["mypackage.server"]
131+
78132

79133
class TestFastLoopWithLoops:
80134
"""Tests for FastLoop app with registered loops."""

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)