fix: make it reload app when yaml changes

This commit is contained in:
mateo-berri 2026-05-05 23:23:03 -07:00
parent d9cc076d97
commit 3face4a867
2 changed files with 147 additions and 0 deletions

View file

@ -177,6 +177,12 @@ class ProxyInitializationHelpers:
Uvicorn defaults to watching only `*.py` files in CWD; we extend the
watch set to include the config file's directory and filename.
Note: uvicorn's `reload_includes` is only honored by the
`WatchFilesReload` reloader (requires the optional `watchfiles`
package). Without `watchfiles`, uvicorn falls back to `StatReload`
which hard-codes `*.py` and silently ignores `reload_includes`.
:func:`_patch_statreload_for_config` handles that fallback.
"""
options: dict = {"reload": True}
if not config_path:
@ -198,6 +204,66 @@ class ProxyInitializationHelpers:
options["reload_includes"] = ["*.py", config_include]
return options
@staticmethod
def _patch_statreload_for_config(config_path: str) -> bool:
"""
Make uvicorn's `StatReload` reloader also notice changes to the
YAML config file at ``config_path``.
Background:
- Uvicorn picks `WatchFilesReload` if the optional `watchfiles`
package is installed, otherwise `StatReload`.
- `StatReload.iter_py_files()` only iterates `*.py` files in
`reload_dirs` (it ignores `reload_includes` and just logs a
warning saying so). That means setting `reload_includes` alone
is not enough to trigger a reload on YAML edits in environments
that lack `watchfiles` (e.g. plain `uvicorn`, not
`uvicorn[standard]`).
- `WatchFilesReload` honors `reload_includes` correctly, so this
patch is a no-op there.
We monkey-patch `StatReload.iter_py_files` (idempotently) to also
yield the absolute path of the config file. The patch is applied
in the parent process before `uvicorn.run` spawns the reloader,
which means the same Python process becomes the reloader and
inherits the patched method.
Returns True if the patch was applied (or was already applied),
False if uvicorn isn't importable (should never happen at runtime
but keeps this helper safe to call from tests).
"""
try:
from uvicorn.supervisors.statreload import StatReload
except ImportError: # pragma: no cover - uvicorn is a hard dep
return False
if not config_path:
return False
from pathlib import Path
config_abs = Path(config_path).resolve()
# Track the set of patched config files on the class so multiple
# invocations (e.g. tests, repeat calls) compose correctly without
# wrapping the original method N times.
patched_paths = getattr(StatReload, "_litellm_patched_config_paths", None)
if patched_paths is None:
original_iter = StatReload.iter_py_files
patched_paths = set()
def _iter_with_config(self): # type: ignore[no-untyped-def]
yield from original_iter(self)
for path in StatReload._litellm_patched_config_paths:
if path.exists():
yield path
StatReload.iter_py_files = _iter_with_config # type: ignore[assignment]
StatReload._litellm_patched_config_paths = patched_paths # type: ignore[attr-defined]
patched_paths.add(config_abs)
return True
@staticmethod
def _init_hypercorn_server(
app: FastAPI,
@ -1060,6 +1126,12 @@ def run_server( # noqa: PLR0915
uvicorn_args.update(
ProxyInitializationHelpers._get_reload_options(config)
)
# When `watchfiles` is not installed uvicorn falls back to
# `StatReload`, which hard-codes `*.py` and silently ignores
# `reload_includes`. Patch it so YAML edits also trigger a
# restart in that environment.
if config:
ProxyInitializationHelpers._patch_statreload_for_config(config)
uvicorn.run(
**uvicorn_args,

View file

@ -166,6 +166,81 @@ class TestProxyInitializationHelpers:
# which raises NotImplementedError on absolute patterns.
assert opts["reload_includes"] == ["*.py", "proxy.yaml"]
def test_patch_statreload_for_config_yields_yaml(self, tmp_path):
"""
When uvicorn falls back to StatReload (no watchfiles installed),
the patched iter_py_files() must yield the YAML config file alongside
the .py files so saving the config triggers a reload.
"""
from pathlib import Path
from uvicorn.supervisors.statreload import StatReload
# Reset any state from previous tests so we get a clean reading
# of the patched behavior.
if hasattr(StatReload, "_litellm_patched_config_paths"):
StatReload._litellm_patched_config_paths.clear()
config_file = tmp_path / "config.yaml"
config_file.write_text("model_list: []\n")
py_file = tmp_path / "module.py"
py_file.write_text("x = 1\n")
applied = ProxyInitializationHelpers._patch_statreload_for_config(
str(config_file)
)
assert applied is True
# Build a fake StatReload-like object whose `config.reload_dirs`
# points at tmp_path. We don't want to instantiate the real
# StatReload (which spawns processes); we just need to drive the
# patched iter_py_files() method directly.
fake_self = types.SimpleNamespace(
config=types.SimpleNamespace(reload_dirs=[tmp_path])
)
yielded = list(StatReload.iter_py_files(fake_self))
yielded_paths = {Path(p).resolve() for p in yielded}
assert config_file.resolve() in yielded_paths, (
"Patched StatReload should yield the YAML config file so "
"edits trigger a reload"
)
assert py_file.resolve() in yielded_paths, (
"Patched StatReload must still yield .py files (we extend, "
"not replace, the original behavior)"
)
def test_patch_statreload_for_config_is_idempotent(self, tmp_path):
"""
Re-applying the patch with the same config path should not wrap the
original iter_py_files() multiple times (which would cause the .py
files to be yielded N times per poll cycle).
"""
from pathlib import Path
from uvicorn.supervisors.statreload import StatReload
if hasattr(StatReload, "_litellm_patched_config_paths"):
StatReload._litellm_patched_config_paths.clear()
config_file = tmp_path / "config.yaml"
config_file.write_text("model_list: []\n")
py_file = tmp_path / "only.py"
py_file.write_text("x = 1\n")
for _ in range(3):
ProxyInitializationHelpers._patch_statreload_for_config(str(config_file))
fake_self = types.SimpleNamespace(
config=types.SimpleNamespace(reload_dirs=[tmp_path])
)
yielded = list(StatReload.iter_py_files(fake_self))
# Each path should appear at most once.
assert len(yielded) == len(set(map(str, yielded)))
yielded_paths = {Path(p).resolve() for p in yielded}
assert config_file.resolve() in yielded_paths
assert py_file.resolve() in yielded_paths
@patch("asyncio.run")
@patch("builtins.print")
def test_init_hypercorn_server(self, mock_print, mock_asyncio_run):