diff --git a/README.md b/README.md index fe9924557..bdfd33d3c 100644 --- a/README.md +++ b/README.md @@ -51,8 +51,9 @@ Moreover, "optional extras" means that: - the websocket protocol will be handled by `websockets` (should you want to use `wsproto` you'd need to install it manually) if possible. - the `--reloader` flag in development mode will use `watchgod`. - windows users will have `colorama` installed for the colored logs. -- `python-dotenv` will be install should you want to use the `--env-file` option. - +- `python-dotenv` will be installed should you want to use the `--env-file` option. +- `PyYAML` will be installed to allow you to provide a `.yaml` file to `--log-config`, if desired. + Create an application, in `example.py`: ```python diff --git a/docs/deployment.md b/docs/deployment.md index ed9605a6e..3cf9a9034 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -52,6 +52,7 @@ Options: application interface. [default: auto] --env-file PATH Environment configuration file. --log-config PATH Logging configuration file. + Supported formats (.ini, .json, .yaml) --log-level [critical|error|warning|info|debug|trace] Log level. [default: info] --access-log / --no-access-log Enable/Disable access log. diff --git a/docs/index.md b/docs/index.md index fc3e09591..0991cbc65 100644 --- a/docs/index.md +++ b/docs/index.md @@ -103,6 +103,7 @@ Options: application interface. [default: auto] --env-file PATH Environment configuration file. --log-config PATH Logging configuration file. + Supported formats (.ini, .json, .yaml) --log-level [critical|error|warning|info|debug|trace] Log level. [default: info] --access-log / --no-access-log Enable/Disable access log. diff --git a/docs/settings.md b/docs/settings.md index 1fc0d028a..617ac63ef 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -33,10 +33,12 @@ $ pip install uvicorn[watchgodreload] ## Logging -* `--log-config <path>` - Logging configuration file. +* `--log-config <path>` - Logging configuration file. **Options:** *`dictConfig()` formats: .json, .yaml*. Any other format will be processed with `fileConfig()`. Set the `formatters.default.use_colors` and `formatters.access.use_colors` values to override the auto-detected behavior. + * If you wish to use a YAML file for your logging config, you will need to include PyYAML as a dependency for your project or install uvicorn with the `[standard]` optional extras. * `--log-level <str>` - Set the log level. **Options:** *'critical', 'error', 'warning', 'info', 'debug', 'trace'.* **Default:** *'info'*. * `--no-access-log` - Disable access log only, without changing log level. -* `--use-colors / --no-use-colors` - Enable / disable colorized formatting of the log records, in case this is not set it will be auto-detected. +* `--use-colors / --no-use-colors` - Enable / disable colorized formatting of the log records, in case this is not set it will be auto-detected. This option is ignored if the `--log-config` CLI option is used. + ## Implementation diff --git a/requirements.txt b/requirements.txt index 34a91e3fa..4a6d08473 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,6 +9,7 @@ websockets==8.* wsproto==0.15.* watchgod>=0.6,<0.7 python_dotenv==0.13.* +PyYAML>=5.1 # Packaging twine @@ -22,6 +23,7 @@ flake8 isort pytest pytest-cov +pytest-mock requests seed-isort-config mypy diff --git a/setup.py b/setup.py index 865b91cc9..2978d31bf 100755 --- a/setup.py +++ b/setup.py @@ -46,7 +46,7 @@ def get_packages(package): minimal_requirements = [ "click==7.*", "h11>=0.8,<0.10", - "typing-extensions;" + env_marker_below_38 + "typing-extensions;" + env_marker_below_38, ] extra_requirements = [ @@ -56,6 +56,7 @@ def get_packages(package): "colorama>=0.4.*;" + env_marker_win, "watchgod>=0.6,<0.7", "python-dotenv==0.13.*", + "PyYAML>=5.1", ] diff --git a/tests/test_config.py b/tests/test_config.py index ad33707d3..336ff5b8b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,14 +1,37 @@ +import json import socket +from copy import deepcopy import pytest +import yaml from uvicorn import protocols -from uvicorn.config import Config +from uvicorn.config import LOGGING_CONFIG, Config from uvicorn.middleware.debug import DebugMiddleware from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware from uvicorn.middleware.wsgi import WSGIMiddleware +@pytest.fixture +def mocked_logging_config_module(mocker): + return mocker.patch("logging.config") + + +@pytest.fixture +def logging_config(): + return deepcopy(LOGGING_CONFIG) + + +@pytest.fixture +def json_logging_config(logging_config): + return json.dumps(logging_config) + + +@pytest.fixture +def yaml_logging_config(logging_config): + return yaml.dump(logging_config) + + async def asgi_app(): pass # pragma: nocover @@ -77,9 +100,78 @@ async def asgi(receive, send): @pytest.mark.parametrize( - "app, expected_interface", [(asgi_app, "3.0",), (asgi2_app, "2.0",)] + "app, expected_interface", [(asgi_app, "3.0"), (asgi2_app, "2.0")] ) def test_asgi_version(app, expected_interface): config = Config(app=app) config.load() assert config.asgi_version == expected_interface + + +@pytest.mark.parametrize( + "use_colors, expected", + [ + pytest.param(None, None, id="use_colors_not_provided"), + pytest.param(True, True, id="use_colors_enabled"), + pytest.param(False, False, id="use_colors_disabled"), + pytest.param("invalid", False, id="use_colors_invalid_value"), + ], +) +def test_log_config_default(mocked_logging_config_module, use_colors, expected): + """ + Test that one can specify the use_colors option when using the default logging + config. + """ + config = Config(app=asgi_app, use_colors=use_colors) + config.load() + + mocked_logging_config_module.dictConfig.assert_called_once_with(LOGGING_CONFIG) + + ((provided_dict_config,), _,) = mocked_logging_config_module.dictConfig.call_args + assert provided_dict_config["formatters"]["default"]["use_colors"] == expected + + +def test_log_config_json( + mocked_logging_config_module, logging_config, json_logging_config, mocker +): + """ + Test that one can load a json config from disk. + """ + mocked_open = mocker.patch( + "uvicorn.config.open", mocker.mock_open(read_data=json_logging_config) + ) + + config = Config(app=asgi_app, log_config="log_config.json") + config.load() + + mocked_open.assert_called_once_with("log_config.json") + mocked_logging_config_module.dictConfig.assert_called_once_with(logging_config) + + +def test_log_config_yaml( + mocked_logging_config_module, logging_config, yaml_logging_config, mocker +): + """ + Test that one can load a yaml config from disk. + """ + mocked_open = mocker.patch( + "uvicorn.config.open", mocker.mock_open(read_data=yaml_logging_config) + ) + + config = Config(app=asgi_app, log_config="log_config.yaml") + config.load() + + mocked_open.assert_called_once_with("log_config.yaml") + mocked_logging_config_module.dictConfig.assert_called_once_with(logging_config) + + +def test_log_config_file(mocked_logging_config_module): + """ + Test that one can load a configparser config from disk. + """ + config = Config(app=asgi_app, log_config="log_config") + config.load() + + mocked_logging_config_module.fileConfig.assert_called_once_with( + "log_config", disable_existing_loggers=False + ) diff --git a/uvicorn/config.py b/uvicorn/config.py index 4e046de94..f8012fc50 100644 --- a/uvicorn/config.py +++ b/uvicorn/config.py @@ -1,5 +1,6 @@ import asyncio import inspect +import json import logging import logging.config import os @@ -10,6 +11,14 @@ import click +try: + import yaml +except ImportError: + # If the code below that depends on yaml is exercised, it will raise a NameError. + # Install the PyYAML package or the uvicorn[standard] optional dependencies to + # enable this functionality. + pass + from uvicorn.importer import ImportFromStringError, import_from_string from uvicorn.middleware.asgi2 import ASGI2Middleware from uvicorn.middleware.debug import DebugMiddleware @@ -221,7 +230,17 @@ def configure_logging(self): "use_colors" ] = self.use_colors logging.config.dictConfig(self.log_config) + elif self.log_config.endswith(".json"): + with open(self.log_config) as file: + loaded_config = json.load(file) + logging.config.dictConfig(loaded_config) + elif self.log_config.endswith(".yaml"): + with open(self.log_config) as file: + loaded_config = yaml.safe_load(file) + logging.config.dictConfig(loaded_config) else: + # See the note about fileConfig() here: + # https://docs.python.org/3/library/logging.config.html#configuration-file-format logging.config.fileConfig( self.log_config, disable_existing_loggers=False )