diff --git a/.vscode/launch.json b/.vscode/launch.json index 931f9e3..45e4851 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -13,6 +13,7 @@ "LOG_FORMAT": "uvicorn", "LOG_LEVEL": "debug", "LOGGING_CONF": "logging_conf.py", + "PROCESS_MANAGER": "uvicorn", "WITH_RELOAD": "true" }, "console": "integratedTerminal", diff --git a/README.md b/README.md index f899625..3d50e50 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,9 @@ ENV APP_MODULE="custom.module:api" WORKERS_PER_CORE="2" - [`BIND`](https://docs.gunicorn.org/en/latest/settings.html#server-socket): The actual host and port passed to Gunicorn. - Default: `HOST:PORT` (`"0.0.0.0:80"`) - Custom: `BIND="0.0.0.0:8080"` +- `PROCESS_MANAGER`: Manager for Uvicorn worker processes. As described in the [Uvicorn docs](https://www.uvicorn.org), "Uvicorn includes a Gunicorn worker class allowing you to run ASGI applications, with all of Uvicorn's performance benefits, while also giving you Gunicorn's fully-featured process management." + - Default: `"gunicorn"` (run Uvicorn with Gunicorn as the process manager) + - Custom: `"uvicorn"` (run Uvicorn alone for local development) - [`WORKER_CLASS`](https://docs.gunicorn.org/en/latest/settings.html#worker-processes): The class to be used by Gunicorn for the workers. - Default: `uvicorn.workers.UvicornWorker` - Custom: For the alternate Uvicorn worker, `WORKER_CLASS="uvicorn.workers.UvicornH11Worker"` diff --git a/inboard/start.py b/inboard/start.py index 0ac555c..adc4c4b 100644 --- a/inboard/start.py +++ b/inboard/start.py @@ -100,28 +100,41 @@ def start_server( gunicorn_conf: Path = Path("/gunicorn_conf.py"), logger: Logger = logging.getLogger(), logging_conf_dict: Dict[str, Any] = None, + process_manager: str = str(os.getenv("PROCESS_MANAGER", "gunicorn")), with_reload: bool = bool(os.getenv("WITH_RELOAD", False)), worker_class: str = str(os.getenv("WORKER_CLASS", "uvicorn.workers.UvicornWorker")), ) -> None: """Start the Uvicorn or Gunicorn server.""" try: - if with_reload: - logger.debug("Running Uvicorn without Gunicorn and with reloading") + if process_manager == "gunicorn": + logger.debug("Running Uvicorn with Gunicorn.") + subprocess.run( + [ + "gunicorn", + "-k", + worker_class, + "-c", + gunicorn_conf.name, + app_module, + "--reload", + str(with_reload), + ] + ) + elif process_manager == "uvicorn": + logger.debug("Running Uvicorn without Gunicorn.") uvicorn.run( app_module, host=os.getenv("HOST", "0.0.0.0"), port=int(os.getenv("PORT", "80")), log_config=logging_conf_dict, log_level=os.getenv("LOG_LEVEL", "info"), - reload=True, + reload=with_reload, ) else: - logger.debug("Running Uvicorn with Gunicorn.") - subprocess.run( - ["gunicorn", "-k", worker_class, "-c", gunicorn_conf.name, app_module] - ) + raise NameError("Process manager needs to be either uvicorn or gunicorn.") except Exception as e: logger.debug(f"Error when starting server with start script: {e}") + raise if __name__ == "__main__": diff --git a/tests/test_start.py b/tests/test_start.py index c3b7531..36f8e07 100644 --- a/tests/test_start.py +++ b/tests/test_start.py @@ -2,6 +2,7 @@ import logging import os from pathlib import Path +from typing import Any, Dict import pytest # type: ignore from _pytest.monkeypatch import MonkeyPatch # type: ignore @@ -245,3 +246,128 @@ def test_run_pre_start_script_no_file( mocker.call("No pre-start script found."), ] ) + + +class TestStartServer: + """Start Uvicorn and Gunicorn servers using the method in `start.py`. + --- + """ + + @pytest.mark.parametrize( + "app_module", + [ + "inboard.base.main:app", + "inboard.fastapibase.main:app", + "inboard.starlettebase.main:app", + ], + ) + def test_start_server_uvicorn( + self, + app_module: str, + gunicorn_conf_path: Path, + logging_conf_dict: Dict[str, Any], + mock_logger: logging.Logger, + mocker: MockerFixture, + monkeypatch: MonkeyPatch, + ) -> None: + monkeypatch.setenv("LOG_FORMAT", "uvicorn") + monkeypatch.setenv("LOG_LEVEL", "debug") + monkeypatch.setenv("PROCESS_MANAGER", "uvicorn") + start.start_server( + app_module=app_module, + gunicorn_conf=gunicorn_conf_path, + logger=mock_logger, + logging_conf_dict=logging_conf_dict, + with_reload=False, + ) + mock_logger.debug.assert_called_once_with("Running Uvicorn with Gunicorn.") # type: ignore # noqa: E501 + + @pytest.mark.parametrize( + "app_module", + [ + "inboard.base.main:app", + "inboard.fastapibase.main:app", + "inboard.starlettebase.main:app", + ], + ) + def test_start_server_uvicorn_gunicorn( + self, + app_module: str, + gunicorn_conf_path: Path, + logging_conf_dict: Dict[str, Any], + mock_logger: logging.Logger, + mocker: MockerFixture, + monkeypatch: MonkeyPatch, + ) -> None: + monkeypatch.setenv("LOG_FORMAT", "gunicorn") + monkeypatch.setenv("LOG_LEVEL", "debug") + monkeypatch.setenv("PROCESS_MANAGER", "gunicorn") + start.start_server( + app_module=app_module, + gunicorn_conf=gunicorn_conf_path, + logger=mock_logger, + logging_conf_dict=logging_conf_dict, + ) + mock_logger.debug.assert_called_once_with("Running Uvicorn with Gunicorn.") # type: ignore # noqa: E501 + + def test_start_server_uvicorn_incorrect_module( + self, + gunicorn_conf_path: Path, + logging_conf_dict: Dict[str, Any], + mock_logger: logging.Logger, + mocker: MockerFixture, + monkeypatch: MonkeyPatch, + ) -> None: + with pytest.raises(ModuleNotFoundError): + monkeypatch.setenv("LOG_LEVEL", "debug") + monkeypatch.setenv("WITH_RELOAD", "false") + start.start_server( + app_module="incorrect.base.main:app", + gunicorn_conf=gunicorn_conf_path, + logger=mock_logger, + logging_conf_dict=logging_conf_dict, + process_manager="uvicorn", + ) + logger_error_msg = "Error when starting server with start script:" + module_error_msg = "No module named incorrect.base.main:app" + mock_logger.debug.assert_has_calls( # type: ignore + calls=[ + mocker.call("Running Uvicorn without Gunicorn."), + mocker.call(f"{logger_error_msg} {module_error_msg}"), + ] + ) + + @pytest.mark.parametrize( + "app_module", + [ + "inboard.base.main:app", + "inboard.fastapibase.main:app", + "inboard.starlettebase.main:app", + ], + ) + def test_start_server_uvicorn_incorrect_process_manager( + self, + app_module: str, + gunicorn_conf_path: Path, + logging_conf_dict: Dict[str, Any], + mock_logger: logging.Logger, + mocker: MockerFixture, + monkeypatch: MonkeyPatch, + ) -> None: + with pytest.raises(NameError): + monkeypatch.setenv("LOG_LEVEL", "debug") + monkeypatch.setenv("WITH_RELOAD", "false") + start.start_server( + app_module=app_module, + gunicorn_conf=gunicorn_conf_path, + logger=mock_logger, + logging_conf_dict=logging_conf_dict, + process_manager="incorrect", + ) + logger_error_msg = "Error when starting server with start script:" + process_error_msg = ( + "Process manager needs to be either uvicorn or gunicorn." + ) + mock_logger.debug.assert_called_once_with( # type: ignore + f"{logger_error_msg} {process_error_msg}" + )