From 6818129d5f126327171fbf06484be63d0d1e6481 Mon Sep 17 00:00:00 2001 From: Josh Wilson <josh.wilson@fivestars.com> Date: Sun, 5 Apr 2020 23:52:45 -0700 Subject: [PATCH 1/5] Allow .json or .yaml --log-config files This adds support for `.json` and `.yaml` config files when running `uvicorn` from the CLI. This allows clients to define how they wish to deal with existing loggers (#511, #512) by providing`disable_existing_loggers` in their config file (the last item described in [this section](https://docs.python.org/3/library/logging.config.html#dictionary-schema-details)). Furthermore, it addresses the desire to allow users to replicate the default hard-coded `LOGGING_CONFIG` in their own configs and tweak it as necessary, something that is not currently possible for clients that wish to run their apps from the CLI. --- docs/deployment.md | 1 + docs/index.md | 1 + docs/settings.md | 11 ++++-- requirements.txt | 2 ++ setup.py | 2 +- tests/test_config.py | 86 +++++++++++++++++++++++++++++++++++++++++++- uvicorn/config.py | 19 ++++++++++ 7 files changed, 118 insertions(+), 4 deletions(-) diff --git a/docs/deployment.md b/docs/deployment.md index d07e64177..0163f8eb5 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 d7d02e1aa..69706ce12 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 d0a069c59..aaf811de4 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -33,10 +33,17 @@ $ 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 want to specify the optional `yamllogconfig` dependency to ensure YAML support: + + ``` + $ pip install uvicorn[yamllogconfig] + ``` + * `--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 `--log-config` is used. + ## Implementation diff --git a/requirements.txt b/requirements.txt index a3592dbdf..bb855106c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ h11 # Optional httptools +PyYAML>=5.1 uvloop>=0.14.0 websockets==8.* wsproto==0.13.* @@ -15,6 +16,7 @@ flake8 isort pytest pytest-cov +pytest-mock requests # Documentation diff --git a/setup.py b/setup.py index ef6504c19..47f359ed9 100755 --- a/setup.py +++ b/setup.py @@ -48,7 +48,7 @@ def get_packages(package): "uvloop>=0.14.0 ;" + env_marker, ] -extras_require = {"watchgodreload": ["watchgod>=0.6,<0.7"]} +extras_require = {"watchgodreload": ["watchgod>=0.6,<0.7"], "yamllogconfig": ["PyYAML>=5.1"]} setup( diff --git a/tests/test_config.py b/tests/test_config.py index 1f3d34e87..c0f871a4a 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 @@ -66,3 +89,64 @@ def test_ssl_config(certfile_and_keyfile): config.load() assert config.is_ssl is True + + +@pytest.mark.parametrize( + "use_colors, expected", + [(None, None), (True, True), (False, False), ("invalid", False)], +) +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 an ini config from disk. + """ + config = Config(app=asgi_app, log_config="log_config.ini") + config.load() + + mocked_logging_config_module.fileConfig.assert_called_once_with("log_config.ini") diff --git a/uvicorn/config.py b/uvicorn/config.py index 331f73842..f73801fc3 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. + # Clients should install the PyYAML package to enable this functionality: + # pip install uvicorn[yamllogconfig] + pass + from uvicorn.importer import ImportFromStringError, import_from_string from uvicorn.middleware.asgi2 import ASGI2Middleware from uvicorn.middleware.debug import DebugMiddleware @@ -217,7 +226,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) if self.log_level is not None: From 89e5dd3cc58f43b85bb785d8e46650732925a865 Mon Sep 17 00:00:00 2001 From: Josh Wilson <josh.wilson@fivestars.com> Date: Mon, 4 May 2020 10:17:23 -0700 Subject: [PATCH 2/5] Add ids to parametrized config test case --- tests/test_config.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/test_config.py b/tests/test_config.py index c0f871a4a..dc59cffb3 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -93,7 +93,12 @@ def test_ssl_config(certfile_and_keyfile): @pytest.mark.parametrize( "use_colors, expected", - [(None, None), (True, True), (False, False), ("invalid", False)], + [ + 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): """ @@ -108,9 +113,7 @@ def test_log_config_default(mocked_logging_config_module, use_colors, expected): assert provided_dict_config["formatters"]["default"]["use_colors"] == expected -def test_log_config_json( - mocked_logging_config_module, logging_config, json_logging_config, mocker -): +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. """ @@ -125,9 +128,7 @@ def test_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 -): +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. """ From 3499b5c6a8acba453df3baa7b335480f8001c804 Mon Sep 17 00:00:00 2001 From: Josh Wilson <josh.wilson@fivestars.com> Date: Mon, 4 May 2020 10:26:44 -0700 Subject: [PATCH 3/5] Fix black formatting and don't use .ini extension in the fileConfig() test --- tests/test_config.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/test_config.py b/tests/test_config.py index dc59cffb3..fad0c0614 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -113,7 +113,9 @@ def test_log_config_default(mocked_logging_config_module, use_colors, expected): assert provided_dict_config["formatters"]["default"]["use_colors"] == expected -def test_log_config_json(mocked_logging_config_module, logging_config, json_logging_config, mocker): +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. """ @@ -128,7 +130,9 @@ def test_log_config_json(mocked_logging_config_module, logging_config, json_logg 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): +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. """ @@ -145,9 +149,9 @@ def test_log_config_yaml(mocked_logging_config_module, logging_config, yaml_logg def test_log_config_file(mocked_logging_config_module): """ - Test that one can load an ini config from disk. + Test that one can load a configparser config from disk. """ - config = Config(app=asgi_app, log_config="log_config.ini") + config = Config(app=asgi_app, log_config="log_config") config.load() - mocked_logging_config_module.fileConfig.assert_called_once_with("log_config.ini") + mocked_logging_config_module.fileConfig.assert_called_once_with("log_config") From cea267c4711f9872fa28d7dd966cc25bf8ce43f8 Mon Sep 17 00:00:00 2001 From: Josh Wilson <josh.wilson@fivestars.com> Date: Wed, 27 May 2020 15:47:12 -0700 Subject: [PATCH 4/5] Remove item from extras_require --- setup.py | 2 +- uvicorn/config.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 47f359ed9..ef6504c19 100755 --- a/setup.py +++ b/setup.py @@ -48,7 +48,7 @@ def get_packages(package): "uvloop>=0.14.0 ;" + env_marker, ] -extras_require = {"watchgodreload": ["watchgod>=0.6,<0.7"], "yamllogconfig": ["PyYAML>=5.1"]} +extras_require = {"watchgodreload": ["watchgod>=0.6,<0.7"]} setup( diff --git a/uvicorn/config.py b/uvicorn/config.py index 63f8d4e6f..8ce086d11 100644 --- a/uvicorn/config.py +++ b/uvicorn/config.py @@ -15,8 +15,7 @@ import yaml except ImportError: # If the code below that depends on yaml is exercised, it will raise a NameError. - # Clients should install the PyYAML package to enable this functionality: - # pip install uvicorn[yamllogconfig] + # Install the PyYAML package to enable this functionality. pass from uvicorn.importer import ImportFromStringError, import_from_string From d0892f1e7dc1e8ca7f8e906058403a674188f9d1 Mon Sep 17 00:00:00 2001 From: Josh Wilson <josh.wilson@fivestars.com> Date: Mon, 17 Aug 2020 23:16:00 -0700 Subject: [PATCH 5/5] Address PR feedback --- README.md | 5 +++-- docs/settings.md | 9 ++------- setup.py | 3 ++- tests/test_config.py | 11 +++++++---- uvicorn/config.py | 3 ++- 5 files changed, 16 insertions(+), 15 deletions(-) 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/settings.md b/docs/settings.md index 48405c36c..617ac63ef 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -34,15 +34,10 @@ $ pip install uvicorn[watchgodreload] ## Logging * `--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 want to specify the optional `yamllogconfig` dependency to ensure YAML support: - - ``` - $ pip install uvicorn[yamllogconfig] - ``` - + * 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. This option is ignored if `--log-config` is used. +* `--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/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 9e9ee7d48..336ff5b8b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -100,7 +100,7 @@ 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) @@ -119,14 +119,15 @@ def test_asgi_version(app, expected_interface): ) 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. + 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 + ((provided_dict_config,), _,) = mocked_logging_config_module.dictConfig.call_args assert provided_dict_config["formatters"]["default"]["use_colors"] == expected @@ -171,4 +172,6 @@ def test_log_config_file(mocked_logging_config_module): config = Config(app=asgi_app, log_config="log_config") config.load() - mocked_logging_config_module.fileConfig.assert_called_once_with("log_config") + 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 d925c91a1..f8012fc50 100644 --- a/uvicorn/config.py +++ b/uvicorn/config.py @@ -15,7 +15,8 @@ import yaml except ImportError: # If the code below that depends on yaml is exercised, it will raise a NameError. - # Install the PyYAML package to enable this functionality. + # Install the PyYAML package or the uvicorn[standard] optional dependencies to + # enable this functionality. pass from uvicorn.importer import ImportFromStringError, import_from_string