Skip to content

Commit ac9c483

Browse files
volks73edoakesChristopher Field
authored and
Victor
committed
Add --reload flag to the serve run subcommand (ray-project#38389)
I have implemented a custom watch using the `watchfiles` package, formerly named `watchgod`, for my own local development of Ray Serve applications. It would be nice if this functionality existed in the `serve` Command Line Interface (CLI). I have searched the GitHub issues and Ray forums. This feature has been requested off and on several times. This PR adds the `--reload` flag to the `serve run` subcommand. It will listen for file changes in the specified working directory and redeploy the application. This is similar uvicorn and other web frameworks. It is useful for application development. The `watchfiles` package is added to the `requirements.txt` file and the `setup.py` file as indicated. Co-authored-by: Edward Oakes <ed.nmi.oakes@gmail.com> Co-authored-by: Christopher Field <chris.field@theiascientific.com> Signed-off-by: Victor <vctr.y.m@example.com>
1 parent 5c2c29b commit ac9c483

File tree

8 files changed

+120
-9
lines changed

8 files changed

+120
-9
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ java/**/pom.xml
183183

184184
# python virtual env
185185
venv
186+
.venv
186187

187188
# pyenv version file
188189
.python-version

doc/requirements-doc.txt

+4-1
Original file line numberDiff line numberDiff line change
@@ -71,4 +71,7 @@ myst-nb==0.13.1
7171
jupytext==1.13.6
7272

7373
# Pin urllib to avoid downstream ssl incompatibility issues
74-
urllib3 < 1.27
74+
urllib3 < 1.27
75+
76+
# For `serve run --reload` CLI.
77+
watchfiles==0.19.0

python/ray/_private/utils.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -1285,9 +1285,11 @@ def new_func(*args, **kwargs):
12851285
return deprecated_wrapper
12861286

12871287

1288-
def import_attr(full_path: str):
1288+
def import_attr(full_path: str, *, reload_module: bool = False):
12891289
"""Given a full import path to a module attr, return the imported attr.
12901290
1291+
If `reload_module` is set, the module will be reloaded using `importlib.reload`.
1292+
12911293
For example, the following are equivalent:
12921294
MyClass = import_attr("module.submodule:MyClass")
12931295
MyClass = import_attr("module.submodule.MyClass")
@@ -1312,6 +1314,8 @@ def import_attr(full_path: str):
13121314
attr_name = full_path[last_period_idx + 1 :]
13131315

13141316
module = importlib.import_module(module_name)
1317+
if reload_module:
1318+
importlib.reload(module)
13151319
return getattr(module, attr_name)
13161320

13171321

python/ray/serve/scripts.py

+43-5
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import yaml
1111
import traceback
1212
import re
13+
import watchfiles
1314
from pydantic import ValidationError
1415

1516
import ray
@@ -323,6 +324,17 @@ def deploy(config_file_name: str, address: str):
323324
"as the ingress deployment."
324325
),
325326
)
327+
@click.option(
328+
"--reload",
329+
"-r",
330+
is_flag=True,
331+
help=(
332+
"Listens for changes to files in the working directory, --working-dir "
333+
"or the working_dir in the --runtime-env, and automatically redeploys "
334+
"the application. This will block until Ctrl-C'd, then clean up the "
335+
"app."
336+
),
337+
)
326338
def run(
327339
config_or_import_path: str,
328340
arguments: Tuple[str],
@@ -335,6 +347,7 @@ def run(
335347
port: int,
336348
blocking: bool,
337349
gradio: bool,
350+
reload: bool,
338351
):
339352
sys.path.insert(0, app_dir)
340353
args_dict = convert_args_to_dict(arguments)
@@ -453,11 +466,36 @@ def run(
453466

454467
visualizer = GraphVisualizer()
455468
visualizer.visualize_with_gradio(handle)
456-
else:
457-
if blocking:
458-
while True:
459-
# Block, letting Ray print logs to the terminal.
460-
time.sleep(10)
469+
elif reload:
470+
if not blocking:
471+
raise click.ClickException(
472+
"The --non-blocking option conflicts with the --reload option."
473+
)
474+
if working_dir:
475+
watch_dir = working_dir
476+
else:
477+
watch_dir = app_dir
478+
479+
for changes in watchfiles.watch(
480+
watch_dir,
481+
rust_timeout=10000,
482+
yield_on_timeout=True,
483+
):
484+
if changes:
485+
cli_logger.info(
486+
f"Detected file change in path {watch_dir}. Redeploying app."
487+
)
488+
# The module needs to be reloaded with `importlib` in order to pick
489+
# up any changes.
490+
app = _private_api.call_app_builder_with_args_if_necessary(
491+
import_attr(import_path, reload_module=True), args_dict
492+
)
493+
serve.run(app, host=host, port=port)
494+
495+
if blocking:
496+
while True:
497+
# Block, letting Ray print logs to the terminal.
498+
time.sleep(10)
461499

462500
except KeyboardInterrupt:
463501
cli_logger.info("Got KeyboardInterrupt, shutting down...")

python/ray/serve/tests/test_cli_2.py

+53
Original file line numberDiff line numberDiff line change
@@ -825,5 +825,58 @@ def test_deployment_contains_utils(ray_start_stop):
825825
)
826826

827827

828+
@pytest.mark.skipif(sys.platform == "win32", reason="File path incorrect on Windows.")
829+
def test_run_reload_basic(ray_start_stop, tmp_path):
830+
"""Test `serve run` with reload."""
831+
832+
code_template = """
833+
from ray import serve
834+
835+
@serve.deployment
836+
class MessageDeployment:
837+
def __init__(self, msg):
838+
self.msg = msg
839+
840+
def __call__(self):
841+
return self.msg
842+
843+
844+
msg_app = MessageDeployment.bind("Hello {message}!")
845+
"""
846+
847+
def write_file(message: str):
848+
with open(os.path.join(tmp_path, "reload_serve.py"), "w") as f:
849+
code = code_template.format(message=message)
850+
print(f"Writing updated code:\n{code}")
851+
f.write(code)
852+
f.flush()
853+
854+
write_file("World")
855+
856+
p = subprocess.Popen(
857+
[
858+
"serve",
859+
"run",
860+
"--app-dir",
861+
tmp_path,
862+
"--reload",
863+
"reload_serve:msg_app",
864+
]
865+
)
866+
wait_for_condition(lambda: ping_endpoint("") == "Hello World!", timeout=10)
867+
868+
# Sleep to ensure the `serve run` command is in the file watching loop when we
869+
# write the change, else it won't be picked up.
870+
time.sleep(5)
871+
872+
# Write the file: an update should be auto-triggered.
873+
write_file("Updated")
874+
wait_for_condition(lambda: ping_endpoint("") == "Hello Updated!", timeout=10)
875+
876+
p.send_signal(signal.SIGINT)
877+
p.wait()
878+
assert ping_endpoint("") == CONNECTION_ERROR_MSG
879+
880+
828881
if __name__ == "__main__":
829882
sys.exit(pytest.main(["-v", "-s", __file__]))

python/requirements.txt

+2
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ pyyaml
1717
aiosignal
1818
frozenlist
1919
requests
20+
watchfiles
2021

2122
# Python version-specific requirements
2223
dataclasses; python_version < '3.7'
@@ -61,3 +62,4 @@ fsspec
6162
pandas>=1.3
6263
pydantic<2
6364
py-spy>=0.2.0
65+
watchfiles

python/requirements/test-requirements.txt

+4-1
Original file line numberDiff line numberDiff line change
@@ -118,4 +118,7 @@ sympy==1.10.1; python_version <= '3.7'
118118

119119
# For test_basic.py::test_omp_threads_set
120120
threadpoolctl==3.1.0
121-
numexpr==2.8.4
121+
numexpr==2.8.4
122+
123+
# For `serve run --reload` CLI.
124+
watchfiles==0.19.0

python/setup.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,14 @@ def get_packages(self):
271271
if sys.platform == "darwin"
272272
else "grpcio",
273273
],
274-
"serve": ["uvicorn", "requests", "starlette", "fastapi", "aiorwlock"],
274+
"serve": [
275+
"uvicorn",
276+
"requests",
277+
"starlette",
278+
"fastapi",
279+
"aiorwlock",
280+
"watchfiles",
281+
],
275282
"tune": ["pandas", "tensorboardX>=1.9", "requests", pyarrow_dep, "fsspec"],
276283
"observability": [
277284
"opentelemetry-api",

0 commit comments

Comments
 (0)