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